rofi-extras/app/rofi-dev.hs

685 lines
25 KiB
Haskell

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
--------------------------------------------------------------------------------
-- | rofi-dev - a rofi prompt for mountable devices
--
-- Like all "mount helpers" this is basically a wrapper for low-level utilities
-- the mount things from the command line. It also creates/destroys mountpoint
-- paths given a specific location for such mountpoints.
module Main (main) where
import Bitwarden.Internal
import Control.Monad
import Control.Monad.Reader
import Data.Either
import Data.List
import Data.List.Split (splitOn)
import qualified Data.Map as M
import qualified Data.Map.Ordered as O
import Data.Maybe
import Data.Text (unpack)
import Rofi.Command
import Text.Printf
import Text.Regex.TDFA
import Text.Wrap
import System.Console.GetOpt
import System.Directory
import System.Environment
import System.Exit (ExitCode(..))
import System.FilePath.Posix
import System.Posix.User (getEffectiveUserName)
import System.Process
import UnliftIO.Exception
main :: IO ()
main = getArgs >>= parse
parse :: [String] -> IO ()
parse args = case getOpt Permute options args of
(o, n, []) -> initMountConf n >>= \i -> runMounts $ foldl (flip id) i o
(_, _, errs) -> ioError $ userError $ concat errs ++ usageInfo h options
where
h = "Usage: rofi-dev [OPTIONS] [-- ROFI-OPTIONS]"
options :: [OptDescr (MountConf -> MountConf)]
options =
[ Option ['s'] ["secret"]
(ReqArg (\s m -> m { passwords = addSecret (passwords m) s } ) "SECRET")
$ wrap "Use libsecret to retrieve password for DIR using ATTR/VAL pairs. \
\The pairs will be supplied to a 'secret-tool lookup' call. \
\ Argument is formatted like 'DIR:ATTR1=VAL1,ATTR2=VAL2...'"
, Option ['b'] ["bitwarden"]
(ReqArg (\s m -> m { passwords = addBitwarden (passwords m) s } ) "BW")
$ wrap "Use the Bitwarden CLI to retrieve a password for DIR. \
\The argument is formatted like 'DIR:NAME' where NAME is the \
\name of the Bitwarden entry to find."
, Option ['p'] ["password"]
(ReqArg (\s m -> m { passwords = addPwdPrompt (passwords m) s } ) "DIR")
"Prompt for password when mounting DIR."
, Option ['d'] ["directory"]
(ReqArg (\s m -> m { mountDir = s } ) "DIR")
$ wrap "The DIR in which new mountpoints will be created. This is assumed \
\to be writable to the current user, and will be used for fuse \
\entries as well as user mounts in fstab. For the latter, it is \
\assumed that all user mounts contain this directory if a \
\mountpoint does not already exist for them. If not given this will \
\default to '/tmp/media/USER'."
, Option ['v'] ["veracrypt"]
(ReqArg (\s m -> m { vcMounts = addVeracryptMount (vcMounts m) s } ) "VC")
$ wrap "A veracrypt mount specification formatted like DIR:VOL where \
\DIR is the mountpoint and VOL is the path to the encrypted \
\volume. To specify a password, use the -p, -b- or -s options."
]
where
wrap = unpack . wrapText defaultWrapSettings 40
--------------------------------------------------------------------------------
-- | Static configuration
--
-- This is defined by the mount options on the command line, and holds:
-- - a map between mountpoints and a means to get passwords when mounting those
-- mountpoints
-- - a mount directory where mountpoints will be created if needed (defaults
-- to '/tmp/media/USER'
-- - any arguments to be passed to the rofi command
type PasswordGetter = IO (Maybe String)
type MountpointPasswords = M.Map String PasswordGetter
type VeracryptMount = (FilePath, FilePath)
addVeracryptMount :: [VeracryptMount] -> String -> [VeracryptMount]
addVeracryptMount l s = case splitPrefix s of
(dir, ":", vol) -> (dir, vol):l
_ -> l
-- TODO check if mountdir exists or puke
data MountConf = MountConf
{ passwords :: MountpointPasswords
, mountDir :: FilePath
, rofiArgs :: [String]
, vcMounts :: [VeracryptMount]
}
instance RofiConf MountConf where
defArgs MountConf { rofiArgs = a } = a
initMountConf :: [String] -> IO MountConf
initMountConf a = conf <$> getEffectiveUserName
where
conf u = MountConf
{ passwords = M.empty
, mountDir = "/tmp/media" </> u
, rofiArgs = a
, vcMounts = []
}
--------------------------------------------------------------------------------
-- | Password-getting functions
addSecret :: MountpointPasswords -> String -> MountpointPasswords
addSecret pwds c = case splitPrefix c of
(dir, ":", r) -> M.insert dir (runSecret $ fromCommaSepString' r) pwds
_ -> pwds
runSecret :: [(String, String)] -> PasswordGetter
runSecret kvs = readCmdSuccess "secret-tool" ("lookup":kvs') ""
where
kvs' = concatMap (\(k, v) -> [k, v]) kvs
addBitwarden :: MountpointPasswords -> String -> MountpointPasswords
addBitwarden pwds c = case splitPrefix c of
(dir, ":", name) -> M.insert dir (runBitwarden name) pwds
_ -> pwds
runBitwarden :: String -> PasswordGetter
runBitwarden pname = ((password . login) <=< find (\i -> name i == pname))
<$> getItems
addPwdPrompt :: MountpointPasswords -> String -> MountpointPasswords
addPwdPrompt pwds dir = M.insert dir readPassword pwds
splitPrefix :: String -> (String, String, String)
splitPrefix s = s =~ (":" :: String)
--------------------------------------------------------------------------------
-- | Main prompt
--
-- This command will have one Rofi prompt and will display all available
-- mounts grouped by device type (eg removable, sshfs, cifs, etc). I like
-- pretty things, so ensure the entries are aligned properly as well
runMounts :: MountConf -> IO ()
runMounts c = runRofiIO c $ runPrompt =<< getGroups
runPrompt :: RofiConf c => [RofiGroup c] -> RofiIO c ()
runPrompt gs = selectAction $ emptyMenu
{ groups = gs
, prompt = Just "Select Device"
}
getGroups :: RofiIO MountConf [RofiGroup MountConf]
getGroups = do
fstab <- readFSTab
sysd <- io getSystemdDevices
sequence
[ mkGroup2 "SSHFS Devices" (filterSysd SystemdSSHFS sysd) $ sshfsDevices fstab
, mkGroup "CIFS Devices" $ cifsDevices fstab
, mkGroup2 "Veracrypt Devices" (filterSysd SystemdVeracrypt sysd) =<< getVeracryptDevices
, mkGroup "Removable Devices" =<< getRemovableDevices
, mkGroup "MTP Devices" =<< getMTPDevices
]
where
filterSysd t = filter (\s -> sysdType s == t)
mkGroup :: Mountable d => String -> [d] -> RofiIO MountConf (RofiGroup MountConf)
mkGroup header devs = sortGroup header <$> mapM mkAction devs
mkGroup2 :: (Mountable d, Mountable e) => String
-> [d] -> [e] -> RofiIO MountConf (RofiGroup MountConf)
mkGroup2 header devs1 devs2 = do
r1 <- mapM mkAction devs1
r2 <- mapM mkAction devs2
return $ sortGroup header (r1 ++ r2)
sortGroup :: String -> [(String, RofiIO MountConf ())] -> RofiGroup MountConf
sortGroup header = titledGroup header . alignEntries . toRofiActions
alignSep :: String
alignSep = " | "
alignSepPre :: String
alignSepPre = "@@@"
alignEntries :: RofiActions c -> RofiActions c
alignEntries = O.fromList . withKeys . O.assocs
where
withKeys as = let (ks, vs) = unzip as in zip (align ks) vs
align = fmap (intercalate alignSep)
. transpose
. mapToLast pad
. transpose
. fmap (splitOn alignSepPre)
pad xs = let m = getMax xs in fmap (\x -> take m (x ++ repeat ' ')) xs
getMax = maximum . fmap length
mapToLast _ [] = []
mapToLast _ [x] = [x]
mapToLast f (x:xs) = f x : mapToLast f xs
--------------------------------------------------------------------------------
-- | Removable devices
--
-- A device which can be removed (which is all the devices we care about)
-- This can be minimally described by a device DEVICESPEC and LABEL.
data Removable = Removable
{ deviceSpec :: String
, label :: String
}
deriving (Eq, Show)
instance Mountable Removable where
mount Removable { deviceSpec = d, label = l } m =
io $ runMountNotify "udisksctl" [c, "-b", d] l m
where
c = if m then "unmount" else "mount"
allInstalled _ = fmap isJust $ io $ findExecutable "udisksctl"
isMounted Removable { deviceSpec = d } = elem d <$> io curDeviceSpecs
fmtEntry Removable { deviceSpec = d, label = l } = l ++ alignSepPre ++ d
-- | Return list of possible rofi actions for removable devices
-- A 'removable device' is defined as a hotplugged device with a filesystem as
-- reported by 'lsblk'. If the LABEL does not exist on the filesystem, the
-- label shown on the prompt will be 'SIZE Volume' where size is the size of
-- the device
getRemovableDevices :: RofiConf c => RofiIO c [Removable]
getRemovableDevices = fromLines toDev . lines
<$> io (readProcess "lsblk" ["-n", "-r", "-o", columns] "")
where
columns = "FSTYPE,HOTPLUG,PATH,LABEL,SIZE"
-- can't use 'words' here since it will drop spaces in the front
toDev line = case splitBy ' ' line of
("":_) -> Nothing
[_, "1", d, "", s] -> mk d $ s ++ " Volume"
[_, "1", d, l, _] -> mk d l
_ -> Nothing
mk d l = Just $ Removable { deviceSpec = d, label = l }
--------------------------------------------------------------------------------
-- | CIFS Devices
--
-- This wraps the Removable device (since it is removable) and also adds its
-- own mount options and passwords for authentication.
data CIFS = CIFS Removable FilePath (Maybe PasswordGetter)
instance Mountable CIFS where
mount (CIFS Removable{ label = l } m getPwd) False =
bracketOnError_
(mkDirMaybe m)
(rmDirMaybe m)
$ io $ do
res <- case getPwd of
Just pwd -> do
p <- maybe [] (\p -> [("PASSWD", p)]) <$> pwd
readCmdEither' "mount" [m] "" p
Nothing -> readCmdEither "mount" [m] ""
notifyMounted (isRight res) False l
mount (CIFS Removable{ label = l } m _) True = umountNotify l m
allInstalled _ = io $ isJust <$> findExecutable "mount.cifs"
isMounted (CIFS _ dir _) = io $ isDirMounted dir
fmtEntry (CIFS r _ _) = fmtEntry r
-- TODO this smells like something that should be in a typeclass
fstabToCIFS :: FSTabEntry -> RofiIO MountConf CIFS
fstabToCIFS FSTabEntry{ fstabSpec = s, fstabDir = d, fstabOptions = o } = do
-- If the options specify "guest" don't require a password. Else try to find a
-- means to get the password from the command line options provided for the
-- this mountpoint. If nothing is found, prompt for a password. In any case,
-- the output will be passed to env variable PASSWD when mounting this cifs
-- directory and cause it to fail. Setting the env variable is necessary as
-- the cifs mount call will prompt for a password and hang otherwise.
pwd <- if M.member "guest" o
then return Nothing
else asks $ Just . M.findWithDefault readPassword d . passwords
let r = Removable { deviceSpec = smartSlashPrefix s, label = takeFileName d }
return $ CIFS r d pwd
where
smartSlashPrefix a = if "//" `isPrefixOf` a then a else "//" ++ a
--------------------------------------------------------------------------------
-- | SSHFS Devices
--
-- This wraps the Removable device (since it is removable) and also adds its
-- own mount options. If the path does not point to an aliased entry in the ssh
-- config that specifies the port, hostname, user, and identity file, these
-- need to be passed as mount options.
data SSHFS = SSHFS Removable FilePath
instance Mountable SSHFS where
mount (SSHFS Removable{ label = l } m) False =
bracketOnError_
(mkDirMaybe m)
(rmDirMaybe m)
(io $ runMountNotify "mount" [m] l False)
mount (SSHFS Removable{ label = l } m) True = umountNotify l m
allInstalled _ = fmap isJust $ io $ findExecutable "sshfs"
isMounted (SSHFS _ dir) = io $ isDirMounted dir
fmtEntry (SSHFS r _) = fmtEntry r
fstabToSSHFS :: FSTabEntry -> RofiIO MountConf SSHFS
fstabToSSHFS FSTabEntry{ fstabSpec = s, fstabDir = d } = return $ SSHFS r d
where
r = Removable { deviceSpec = s, label = takeFileName d }
--------------------------------------------------------------------------------
-- | VeraCrypt Devices
--
data VeraCrypt = VeraCrypt Removable FilePath (Maybe PasswordGetter)
instance Mountable VeraCrypt where
mount (VeraCrypt Removable{ deviceSpec = s, label = l } m getPwd) False =
bracketOnError_ (mkDirMaybe m) (rmDirMaybe m) mountMaybe
where
mountMaybe = io $ maybe (runVeraCryptWith "" []) (runVeraCryptWithPwd =<<) getPwd
runVeraCryptWithPwd = maybe notifyFail (\p -> runVeraCryptWith p ["--stdin"])
runVeraCryptWith stdin args = (\res -> notifyMounted (isRight res) False l)
=<< runVeraCrypt stdin ([s, m] ++ args)
notifyFail = notify "dialog-error-symbolic" $
printf "Failed to get volume password for %s" l
mount (VeraCrypt Removable{ label = l } m _) True = io $ do
res <- runVeraCrypt "" ["-d", m]
notifyMounted (isRight res) True l
allInstalled _ = io $ isJust <$> findExecutable "veracrypt"
isMounted (VeraCrypt _ dir _) = io $ isDirMounted dir
fmtEntry (VeraCrypt r _ _) = fmtEntry r
-- NOTE: the user is assumed to have added themselves to the sudoers file so
-- that this command will work
runVeraCrypt :: String -> [String] -> IO (Either (Int, String, String) String)
runVeraCrypt stdin args = do
readCmdEither "sudo" (defaultArgs ++ args) stdin
where
defaultArgs = ["/usr/bin/veracrypt", "--text", "--non-interactive"]
getVeracryptDevices :: RofiIO MountConf [VeraCrypt]
getVeracryptDevices = mapM toDev =<< asks vcMounts
where
toDev (d, s) = do
pwd <- asks $ Just . M.findWithDefault readPassword d . passwords
let r = Removable { deviceSpec = s, label = takeFileName d }
return $ VeraCrypt r d pwd
--------------------------------------------------------------------------------
-- | MTP devices
--
-- These devices are a bit special because they are not based on Removable
-- devices (eg they don't have a label and a device spec). Instead they
-- are defined by a bus:device path. The program used for this is jmtpfs
-- (which seems to be the fastest and most robust)
data MTPFS = MTPFS
{ bus :: String
, device :: String
, mountpoint :: FilePath
, description :: String
}
deriving (Eq, Show)
instance Mountable MTPFS where
mount MTPFS {..} False = do
-- TODO add autodismount to options
let dev = "-device=" ++ bus ++ "," ++ device
bracketOnError_
(mkDirMaybe mountpoint)
(rmDirMaybe mountpoint)
(io $ runMountNotify "jmtpfs" [dev, mountpoint] description False)
mount MTPFS { mountpoint = m, description = d } True = umountNotify d m
-- | return True always since the list won't even show without jmtpfs
allInstalled _ = return True
isMounted MTPFS { mountpoint = dir } = io $ isDirMounted dir
fmtEntry MTPFS { description = d } = d
-- | Return list of all available MTP devices
getMTPDevices :: RofiIO MountConf [MTPFS]
getMTPDevices = do
dir <- asks mountDir
res <- io $ readProcess "jmtpfs" ["-l"] ""
return $ fromLines (toDev dir) $ toDevList res
where
toDevList = reverse
. takeWhile (not . isPrefixOf "Available devices")
. reverse
. lines
toDev dir s = case splitOn ", " s of
[busNum, devNum, _, _, desc, vendor] -> let d = unwords [vendor, desc]
in Just $ MTPFS
{ bus = busNum
, device = devNum
, mountpoint = dir </> canonicalize d
, description = d
}
_ -> Nothing
canonicalize = mapMaybe repl
repl c
| c `elem` ("\"*/:<>?\\|" :: String) = Nothing
| c == ' ' = Just '-'
| otherwise = Just c
--------------------------------------------------------------------------------
-- | Systemd typeclass
data SystemdMountType = SystemdVeracrypt | SystemdSSHFS deriving (Eq, Show)
data Systemd = Systemd
{ sysdType :: SystemdMountType
, sysdInstance :: String
}
deriving (Eq, Show)
instance Mountable Systemd where
mount s@Systemd { sysdInstance = i } m = let
unit = fmtSysdInstanceName s
op = if m then "stop" else "start" in
io $ runMountNotify "systemctl" ["--user", op, unit] i m
allInstalled Systemd { sysdType = SystemdVeracrypt } =
io $ isJust <$> findExecutable "veracrypt"
allInstalled Systemd { sysdType = SystemdSSHFS } =
io $ isJust <$> findExecutable "sshfs"
isMounted s = let
unit = fmtSysdInstanceName s
args = ["--user", "is-active", "--quiet", unit] in
io $ (\(ec, _, _) -> ec == ExitSuccess)
<$> readProcessWithExitCode "systemctl" args ""
fmtEntry Systemd { sysdInstance = i } = i ++ alignSepPre ++ "Systemd"
fmtSysdInstanceName :: Systemd -> String
fmtSysdInstanceName Systemd { sysdType = SystemdVeracrypt, sysdInstance = i } =
"mount-veracrypt@" ++ i ++ ".service"
fmtSysdInstanceName Systemd { sysdType = SystemdSSHFS, sysdInstance = i } =
"mount-sshfs@" ++ i ++ ".service"
getSystemdDevices :: IO [Systemd]
getSystemdDevices = do
systemdHome <- io $ getXdgDirectory XdgConfig "systemd/user"
io $ mapMaybe toDev
<$> (filterM (doesDirectoryExist . (systemdHome </>))
=<< listDirectory systemdHome)
where
toDev (splitInstance "mount-veracrypt@" -> Just s) =
Just $ Systemd { sysdType = SystemdVeracrypt , sysdInstance = s }
toDev (splitInstance "mount-sshfs@" -> Just s) =
Just $ Systemd { sysdType = SystemdSSHFS , sysdInstance = s }
toDev _ = Nothing
splitInstance p = fmap (takeWhile (not . (==) '.')) . stripPrefix p
--------------------------------------------------------------------------------
-- | Mountable typeclass
--
-- Let this class represent anything that can be mounted. The end goal is to
-- create a Rofi action which will define an entry in the rofi prompt for the
-- device at hand. In order to make an action, we need functions to mount the
-- device, check if the necessary mounting program(s) is installed, make the
-- entry to go in the prompt, and test if the device is mounted.
class Mountable a where
-- | Mount the given type (or dismount if False is passed)
mount :: a -> Bool -> RofiIO MountConf ()
-- | Check if the mounting utilities are present
allInstalled :: a -> RofiIO MountConf Bool
-- | Return a string to go in the Rofi menu for the given type
fmtEntry :: a -> String
-- | Determine if the given type is mounted or not
isMounted :: a -> RofiIO MountConf Bool
-- | Given a mountable type, return a rofi action (string to go in the
-- Rofi prompt and an action to perform when it is selected)
mkAction :: a -> RofiIO MountConf (String, RofiIO MountConf ())
mkAction dev = do
m <- isMounted dev
i <- allInstalled dev
let a = when i $ mount dev m
let s = mountedPrefix m i ++ fmtEntry dev
return (s, a)
where
mountedPrefix False True = " "
mountedPrefix True True = "* "
mountedPrefix _ False = "! "
-- TODO add truecrypt volumes
--------------------------------------------------------------------------------
-- | Fstab devices
--
-- Functions to gather all user fstab mounts on the system
-- | Intermediate structure to hold fstab devices
data FSTab = FSTab
{ sshfsDevices :: [SSHFS]
, cifsDevices :: [CIFS]
-- , veracryptDevices :: [VeraCrypt]
}
-- | Data structure representing an fstab device (or one line in the fstab file)
data FSTabEntry = FSTabEntry
{ fstabSpec :: String
, fstabDir :: FilePath
, fstabType :: String
, fstabOptions :: MountOptions
}
-- | Key/val pairs to represent mount options. A Nothing for the value signifies
-- a standalone option (eg 'rw' and 'ro')
type MountOptions = M.Map String (Maybe String)
-- | Return all user fstab devices from /etc/fstab
readFSTab :: RofiIO MountConf FSTab
readFSTab = do
-- let i = FSTab { sshfsDevices = [], cifsDevices = [], veracryptDevices = []}
let i = FSTab { sshfsDevices = [], cifsDevices = []}
fstab <- io $ readFile "/etc/fstab"
foldM addFstabDevice i $ fromLines toEntry $ lines fstab
where
toEntry line = case words line of
(('#':_):_) -> Nothing
[spec, dir, fsType, opts, _, _] -> Just $ FSTabEntry
{ fstabSpec = spec
, fstabDir = dir
, fstabType = fsType
, fstabOptions = parseOptions opts
}
_ -> Nothing
parseOptions = M.fromList . fromCommaSepString
-- | Add entry to the fstab devices list, but only if it is a known user mount
addFstabDevice :: FSTab -> FSTabEntry -> RofiIO MountConf FSTab
addFstabDevice f@FSTab{..} e@FSTabEntry{..}
| M.notMember "users" fstabOptions = return f
| fstabType == "cifs" =
(\d -> f { cifsDevices = append d cifsDevices }) <$> fstabToCIFS e
| fstabType == "fuse.sshfs" =
(\d -> f { sshfsDevices = append d sshfsDevices }) <$> fstabToSSHFS e
-- | fstabType == "veracrypt" =
-- (\d -> f { veracryptDevices = append d veracryptDevices }) <$> fstabToVeraCrypt e
| otherwise = return f
where
append x xs = xs ++ [x]
--------------------------------------------------------------------------------
-- | Low-level mount functions
-- ASSUME these will never fail because the format of /proc/mounts is fixed
curMountField :: Int -> IO [String]
curMountField i = fmap ((!! i) . words) . lines <$> readFile "/proc/mounts"
curDeviceSpecs :: IO [String]
curDeviceSpecs = curMountField 0
curMountpoints :: IO [String]
curMountpoints = curMountField 1
-- ASSUME the base mount path will always be created because
-- 'createDirectoryIfMissing' will make parents if missing, and that removing
-- all the directories will leave the base mount path intact regardless of if it
-- was present before doing anything (which matters here since I'm putting the
-- base path in /tmp, so all this is saying is that umounting everything will
-- leave /tmp/media/USER without removing all the way down to /tmp)
mkDirMaybe :: FilePath -> RofiIO MountConf ()
mkDirMaybe fp = whenInMountDir fp $ io $ createDirectoryIfMissing True fp
rmDirMaybe :: FilePath -> RofiIO MountConf ()
rmDirMaybe fp = whenInMountDir fp $ unlessMountpoint fp
$ asks mountDir >>= io . rmUntil fp
where
rmUntil cur target = unless (target == cur) $ do
removePathForcibly cur
rmUntil (takeDirectory cur) target
whenInMountDir :: FilePath -> RofiIO MountConf () -> RofiIO MountConf ()
whenInMountDir fp f = do
mDir <- asks mountDir
when (mDir `isPrefixOf` fp) f
unlessMountpoint :: FilePath -> RofiIO MountConf () -> RofiIO MountConf ()
unlessMountpoint fp f = do
mounted <- io $ isDirMounted fp
unless mounted f
isDirMounted :: FilePath -> IO Bool
isDirMounted fp = elem fp <$> curMountpoints
runMountNotify :: String -> [String] -> String -> Bool -> IO ()
runMountNotify cmd args msg mounted = do
res <- readCmdEither cmd args ""
notifyMounted (isRight res) mounted msg
umountNotify' :: String -> String -> FilePath -> RofiIO MountConf ()
umountNotify' cmd msg dir = finally
(io $ runMountNotify cmd [dir] msg True)
(rmDirMaybe dir)
umountNotify :: String -> FilePath -> RofiIO MountConf ()
umountNotify = umountNotify' "umount"
-- | Send a notification indicating the mount succeeded
notifyMounted :: Bool -> Bool -> String -> IO ()
notifyMounted succeeded mounted label = notify icon body
where
(format, icon) = if succeeded
then ("Successfully %sed %s", "dialog-information-symbolic")
else ("Failed to %s %s", "dialog-error-symbolic")
m = if mounted then "unmount" else "mount" :: String
body = printf format m label
notify :: String -> String -> IO ()
notify icon body = void $ spawnProcess "notify-send" ["-i", icon, body]
--------------------------------------------------------------------------------
-- | Other functions
fromLines :: (String -> Maybe a) -> [String] -> [a]
fromLines f = mapMaybe (f . stripWS)
-- TODO this exists somewhere...
splitBy :: Char -> String -> [String]
splitBy delimiter = foldr f [[]]
where
f _ [] = []
f c l@(x:xs) | c == delimiter = []:l
| otherwise = (c:x):xs
-- | Like fromCommaSepString but only return substrings with '='
fromCommaSepString' :: String -> [(String, String)]
fromCommaSepString' s = [(k, v) | (k, Just v) <- fromCommaSepString s]
-- | Split a string of comma-separated values into an alist
-- If the substrings have an '=' in them, the left side will become the key and
-- the right will become the value of the cell. If there is not '=' then the
-- entire substring will become the key and the value will be Nothing
fromCommaSepString :: String -> [(String, Maybe String)]
fromCommaSepString = fmap (toCell . splitEq) . splitBy ','
where
splitEq e = e =~ ("=" :: String) :: (String, String, String)
toCell (k, "=", v) = (k, Just v)
toCell (k, _, _) = (k, Nothing)