474 lines
16 KiB
Haskell
474 lines
16 KiB
Haskell
|
{-# LANGUAGE DeriveGeneric #-}
|
||
|
{-# LANGUAGE FlexibleContexts #-}
|
||
|
{-# LANGUAGE OverloadedStrings #-}
|
||
|
{-# LANGUAGE RecordWildCards #-}
|
||
|
|
||
|
module Main (main) where
|
||
|
|
||
|
import Control.Exception
|
||
|
import Control.Monad
|
||
|
|
||
|
import qualified Data.ByteString.Lazy.Char8 as B
|
||
|
import Data.Char
|
||
|
import Data.Csv hiding (lookup)
|
||
|
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.Vector (Vector, toList)
|
||
|
|
||
|
import GHC.Generics (Generic)
|
||
|
|
||
|
import Rofi.Command
|
||
|
|
||
|
import Text.Printf
|
||
|
import Text.Regex.TDFA
|
||
|
|
||
|
import System.Directory
|
||
|
import System.Environment
|
||
|
import System.Process
|
||
|
|
||
|
main :: IO ()
|
||
|
main = check >> getArgs >>= parse
|
||
|
|
||
|
parse :: [String] -> IO ()
|
||
|
parse = runMounts
|
||
|
|
||
|
-- TODO
|
||
|
check :: IO ()
|
||
|
check = return ()
|
||
|
|
||
|
runMounts :: [String] -> IO ()
|
||
|
runMounts a = do
|
||
|
let c = RofiConf { defArgs = a }
|
||
|
runRofiPrompt c runPrompt
|
||
|
|
||
|
runPrompt :: RofiPrompt ()
|
||
|
runPrompt = do
|
||
|
net <- titledGroup "Network Devices" <$> io getNetActions
|
||
|
rmv <- titledGroup "Removable Devices" <$> io getRemovableActions
|
||
|
mtp <- titledGroup "MTP Devices" <$> io getMTPActions
|
||
|
selectAction $ emptyMenu
|
||
|
{ groups = [net, rmv, mtp]
|
||
|
, prompt = Just "Select Device"
|
||
|
}
|
||
|
|
||
|
getNetActions :: IO RofiActions
|
||
|
getNetActions = alignEntries . toRofiActions . catMaybes
|
||
|
<$> (mapM csvToAction =<< getCSV)
|
||
|
|
||
|
alignSep :: String
|
||
|
alignSep = " | "
|
||
|
|
||
|
alignSepPre :: String
|
||
|
alignSepPre = "@@@"
|
||
|
|
||
|
alignEntries :: RofiActions -> RofiActions
|
||
|
alignEntries = O.fromList . withKeys . O.assocs
|
||
|
where
|
||
|
withKeys as = let (ks, vs) = unzip as in zip (align ks) vs
|
||
|
align ks = fmap (intercalate alignSep)
|
||
|
$ transpose
|
||
|
$ mapToLast pad
|
||
|
$ transpose
|
||
|
$ fmap (splitOn alignSepPre) ks
|
||
|
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
|
||
|
|
||
|
-- | Class and methods for type representing mountable devices
|
||
|
class Mountable a where
|
||
|
-- | Mount the given type (or dismount if False is passed)
|
||
|
mount :: a -> Bool -> IO ()
|
||
|
|
||
|
-- | Check if the mounting utilities are present
|
||
|
allInstalled :: a -> IO 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 -> IO 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 -> IO (String, RofiPrompt ())
|
||
|
mkAction dev = do
|
||
|
m <- isMounted dev
|
||
|
i <- allInstalled dev
|
||
|
let a = when i $ io $ mount dev m
|
||
|
let s = mountedPrefix m i ++ fmtEntry dev
|
||
|
return (s, a)
|
||
|
where
|
||
|
mountedPrefix False True = " "
|
||
|
mountedPrefix True True = "* "
|
||
|
mountedPrefix _ False = "! "
|
||
|
|
||
|
-- | 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)
|
||
|
|
||
|
-- | Given a string of comma-separated 'key=val' pairs, return a mount options
|
||
|
-- map
|
||
|
parseMountOptions :: String -> MountOptions
|
||
|
parseMountOptions s = M.fromList $ toCell . splitEq <$> splitBy ',' s
|
||
|
where
|
||
|
splitEq e = e =~ ("=" :: String) :: (String, String, String)
|
||
|
toCell (k, "=", v) = (k, Just v)
|
||
|
toCell (k, _, _) = (k, Nothing)
|
||
|
|
||
|
-- | Given a mount options map, return a string of comma separated items
|
||
|
fmtMountOptions :: MountOptions -> String
|
||
|
fmtMountOptions = intercalate "," . fmap fromCell . M.toList
|
||
|
where
|
||
|
fromCell (k, Just v) = k ++ "=" ++ v
|
||
|
fromCell (k, Nothing) = k
|
||
|
|
||
|
-- | Various credentials to be used with a given mountable type.
|
||
|
-- Secret: represents a lookup using 'secret-tool' where the map represents
|
||
|
-- the attribute/value pairs to pass.
|
||
|
-- NoCredentials: self explanatory
|
||
|
data Credentials = Secret (M.Map String String)
|
||
|
| NoCredentials
|
||
|
deriving (Eq, Show)
|
||
|
|
||
|
-- | Given a string, return a credentials type. The type of credentials is
|
||
|
-- determined by the prefix (which is followed by a colon) and is followed by
|
||
|
-- a comma-separated list of 'key=val' pairs
|
||
|
parseCredentials :: String -> Credentials
|
||
|
parseCredentials c = case splitPrefix c of
|
||
|
("secret", ":", r) -> Secret $ M.fromList $ mapMaybe (toCell . splitEq) $ splitBy ',' r
|
||
|
-- TODO fetch from bitwarden
|
||
|
-- add more here...
|
||
|
_ -> NoCredentials
|
||
|
where
|
||
|
splitPrefix s = s =~ (":" :: String) :: (String, String, String)
|
||
|
splitEq e = e =~ ("=" :: String) :: (String, String, String)
|
||
|
toCell (k, "=", v) = Just (k, v)
|
||
|
toCell _ = Nothing
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- | Removable devices
|
||
|
--
|
||
|
-- A device which can be removed (which is all the devices we care about)
|
||
|
-- This can be minimally described by a device PATH and LABEL. If MOUNTPOINT is
|
||
|
-- Nothing, this represents the device being mounted at a default location.
|
||
|
|
||
|
data Removable = Removable
|
||
|
{ path :: String
|
||
|
, label :: String
|
||
|
, mountpoint :: Maybe String
|
||
|
}
|
||
|
deriving (Eq, Show)
|
||
|
|
||
|
instance Mountable Removable where
|
||
|
-- | (Un)mount the device using udiskctl
|
||
|
mount Removable { path = p, label = l } m = do
|
||
|
res <- readCmdEither "udisksctl" [cmd, "-b", p] ""
|
||
|
notifyMounted (isRight res) m l
|
||
|
where
|
||
|
cmd = if m then "unmount" else "mount"
|
||
|
|
||
|
-- | Need udisksctl to mount and umount
|
||
|
allInstalled _ = isJust <$> findExecutable "udisksctl"
|
||
|
|
||
|
-- | Check if the device is mounted using /proc/mount
|
||
|
isMounted Removable { path = p, mountpoint = m } = do
|
||
|
cur <- curMountpoints
|
||
|
return $ case m of
|
||
|
Just m' -> (p, m') `elem` cur
|
||
|
Nothing -> p `elem` fmap fst cur
|
||
|
|
||
|
-- | Format the Rofi entry like 'LABEL - PATH' and add a star in the front
|
||
|
-- if the device is mounted
|
||
|
fmtEntry Removable { path = p, label = l } = l ++ alignSepPre ++ p
|
||
|
|
||
|
|
||
|
-- | 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 :: IO [Removable]
|
||
|
getRemovableDevices = mapMaybe toDev
|
||
|
. lines
|
||
|
. stripWS
|
||
|
<$> 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", p, "", s] -> mk p $ s ++ " Volume"
|
||
|
[_, "1", p, l, _] -> mk p l
|
||
|
_ -> Nothing
|
||
|
mk p l = Just $ Removable { path = p
|
||
|
, label = l
|
||
|
, mountpoint = Nothing
|
||
|
}
|
||
|
|
||
|
getRemovableActions :: IO RofiActions
|
||
|
getRemovableActions = alignEntries . toRofiActions
|
||
|
<$> (mapM mkAction =<< getRemovableDevices)
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- | CIFS Devices
|
||
|
--
|
||
|
-- This wraps the Removable device (since it is removable) and also adds its
|
||
|
-- own mount options and credentials for authentication.
|
||
|
|
||
|
data CIFS = CIFS Removable MountOptions Credentials
|
||
|
|
||
|
instance Mountable CIFS where
|
||
|
-- | Mount using udevil
|
||
|
mount (CIFS Removable{..} opts creds) False = do
|
||
|
pwd <- getPassword creds
|
||
|
let opts' = fmtOpts $ addPwd pwd opts
|
||
|
res <- readCmdEither "udevil" (["mount"] ++ opts' ++ ["-t", "cifs", path]) ""
|
||
|
notifyMounted (isRight res) False label
|
||
|
where
|
||
|
addPwd (Just pwd) o = M.insert "password" (Just pwd) o
|
||
|
addPwd Nothing o = o
|
||
|
fmtOpts o = if null o then [] else ["-o", fmtMountOptions o]
|
||
|
|
||
|
-- | Unmount using udevil
|
||
|
mount (CIFS Removable{..} _ _) True = do
|
||
|
res <- readCmdEither "udevil" ["unmount", path] ""
|
||
|
notifyMounted (isRight res) True label
|
||
|
|
||
|
-- | Need udevil and mount.cifs
|
||
|
allInstalled _ = all isJust <$> mapM findExecutable ["udevil", "mount.cifs"]
|
||
|
|
||
|
-- | Return True if mounted. Only checks the removable type wrapped within
|
||
|
isMounted (CIFS r _ _) = isMounted r
|
||
|
|
||
|
-- | Format the Rofi entry like 'LABEL - (CIFS) - PATH' and prefix with a star
|
||
|
-- if mounted
|
||
|
fmtEntry (CIFS r _ _) = fmtNetEntry r "CIFS"
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- | 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 MountOptions
|
||
|
|
||
|
instance Mountable SSHFS where
|
||
|
-- | Mount using sshfs
|
||
|
mount (SSHFS Removable{..} opts) False =
|
||
|
case mountpoint of
|
||
|
Just m -> cmd m
|
||
|
-- TODO only destroy mountpoint if it is not already another mountpoint
|
||
|
Nothing -> bracketOnError (makeFuseMount label)
|
||
|
(const $ destroyFuseMount label) (const $ cmd $ fmtFusePath label)
|
||
|
where
|
||
|
-- TODO add auto-dismount to options
|
||
|
opts' = if null opts then [] else ["-o", fmtMountOptions opts]
|
||
|
cmd m' = do
|
||
|
res <- readCmdEither "sshfs" ([path, m'] ++ opts') ""
|
||
|
notifyMounted (isRight res) False label
|
||
|
|
||
|
-- | Umount using fusermount
|
||
|
mount (SSHFS r _) True = fuseUnmount r
|
||
|
|
||
|
-- | Need sshfs (assume fuse is also installed)
|
||
|
allInstalled _ = isJust <$> findExecutable "sshfs"
|
||
|
|
||
|
-- | Return True if mounted. Only checks the removable type wrapped within
|
||
|
isMounted (SSHFS r _) = isMounted r
|
||
|
|
||
|
-- | Format the Rofi entry like 'LABEL - (SSHFS) - PATH' and prefix with a
|
||
|
-- star if mounted
|
||
|
fmtEntry (SSHFS r _) = fmtNetEntry r "SSHFS"
|
||
|
|
||
|
-- | Given a removable device, type string, and boolean for if the device is
|
||
|
-- mounted, return a string like 'LABEL - (TYPESTRING) - PATH' and prefix with a
|
||
|
-- star if mounted
|
||
|
fmtNetEntry :: Removable -> String -> String
|
||
|
fmtNetEntry Removable { label = l, path = p } t =
|
||
|
intercalate alignSepPre [l, t, p]
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- | MTP devices
|
||
|
|
||
|
data MTPFS = MTPFS
|
||
|
{ bus :: String
|
||
|
, device :: String
|
||
|
, mountpointMTP :: String
|
||
|
, description :: String
|
||
|
}
|
||
|
deriving (Eq, Show)
|
||
|
|
||
|
instance Mountable MTPFS where
|
||
|
-- | Mount using sshfs
|
||
|
mount MTPFS {..} False = do
|
||
|
-- TODO add autodismount to options
|
||
|
let dev = "-device=" ++ bus ++ "," ++ device
|
||
|
bracketOnError (createDirectoryIfMissing False mountpointMTP)
|
||
|
(const $ removePathForcibly mountpointMTP) $ \_ -> do
|
||
|
res <- readCmdEither "jmtpfs" [dev, mountpointMTP] ""
|
||
|
notifyMounted (isRight res) False description
|
||
|
|
||
|
-- | Umount using fusermount
|
||
|
mount MTPFS { mountpointMTP = m, description = d } True =
|
||
|
finally (fuseUnmount' d m) $ removePathForcibly m
|
||
|
|
||
|
-- | Need jmtpfs (assume fuse is also installed)
|
||
|
allInstalled _ = isJust <$> findExecutable "jmtpfs"
|
||
|
|
||
|
-- | Return True if mounted. Only checks the mountpoint path
|
||
|
isMounted MTPFS { mountpointMTP = m } = elem m . fmap snd <$> curMountpoints
|
||
|
|
||
|
-- | Format the Rofi entry like 'LABEL - (SSHFS) - PATH'
|
||
|
fmtEntry MTPFS { description = d } = d
|
||
|
|
||
|
getMTPDevices :: IO [MTPFS]
|
||
|
getMTPDevices = mapMaybe toDev . toDevList <$> readProcess "jmtpfs" ["-l"] ""
|
||
|
where
|
||
|
toDevList = reverse
|
||
|
. takeWhile (not . isPrefixOf "Available devices")
|
||
|
. reverse
|
||
|
. lines
|
||
|
toDev s = case splitOn ", " s of
|
||
|
[busNum, devNum, _, _, desc, vendor] -> let d = unwords [vendor, desc]
|
||
|
in Just $ MTPFS
|
||
|
{ bus = busNum
|
||
|
, device = devNum
|
||
|
, mountpointMTP = fuseMount ++ canonicalize d
|
||
|
, description = d
|
||
|
}
|
||
|
_ -> Nothing
|
||
|
canonicalize = mapMaybe repl
|
||
|
repl c
|
||
|
| c `elem` ("\"*/:<>?\\|" :: String) = Nothing
|
||
|
| c == ' ' = Just '-'
|
||
|
| otherwise = Just c
|
||
|
|
||
|
getMTPActions :: IO RofiActions
|
||
|
getMTPActions = toRofiActions <$> (mapM mkAction =<< getMTPDevices)
|
||
|
|
||
|
-- TODO add truecrypt volumes (see tcplay, will need root)
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- | Csv device parsing
|
||
|
--
|
||
|
-- These devices are stored in a CSV file which needs to be parsed into the
|
||
|
-- appropriate devices types
|
||
|
|
||
|
-- | Represents one parsable line in the network device config .tsv file
|
||
|
data CsvDev = CsvDev
|
||
|
{ csvLabel :: String
|
||
|
, csvType :: String
|
||
|
, csvPath :: String
|
||
|
, csvMountpoint :: Maybe String
|
||
|
, csvMountOptions :: Maybe String
|
||
|
, csvCredentials :: Maybe String
|
||
|
}
|
||
|
deriving (Generic, Show)
|
||
|
|
||
|
instance FromRecord CsvDev
|
||
|
|
||
|
-- | Return a list of all Csv lines from the network config file
|
||
|
getCSV :: IO [CsvDev]
|
||
|
getCSV = do
|
||
|
xdgConf <- getEnv "XDG_CONFIG_HOME"
|
||
|
-- TODO this shouldn't be hardcoded
|
||
|
contents <- B.readFile $ xdgConf ++ "/rofi/devices.tsv"
|
||
|
let opts = defaultDecodeOptions { decDelimiter = fromIntegral (ord '\t') }
|
||
|
case decodeWith opts HasHeader contents of
|
||
|
Left s -> putStrLn s >> return []
|
||
|
Right v -> return $ toList (v :: Vector CsvDev)
|
||
|
|
||
|
-- TODO split this into each device type so they can be separated in the prompt
|
||
|
-- | Given a parsed csv line from the network config file, return a
|
||
|
-- (ENTRY, ACTION) where ENTRY is a string to appear on the Rofi prompt and
|
||
|
-- ACTION is an action to perform on the device in the csv line when selected
|
||
|
csvToAction :: CsvDev -> IO (Maybe (String, RofiPrompt ()))
|
||
|
csvToAction CsvDev {..}
|
||
|
| csvType == "cifs" = Just <$> mkAction (CIFS r' opts creds)
|
||
|
| csvType == "sshfs" = Just <$> mkAction (SSHFS r opts)
|
||
|
| otherwise = return Nothing
|
||
|
where
|
||
|
r = Removable { label = csvLabel
|
||
|
, path = csvPath
|
||
|
, mountpoint = csvMountpoint
|
||
|
}
|
||
|
opts = maybe M.empty parseMountOptions csvMountOptions
|
||
|
creds = maybe NoCredentials parseCredentials csvCredentials
|
||
|
-- CIFS prefixes the path with two slashes
|
||
|
r' = r { path = smartSlashPrefix csvPath }
|
||
|
smartSlashPrefix s = if "//" `isPrefixOf` s then s else "//" ++ s
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- | Low-level mount functions
|
||
|
|
||
|
-- | Return a list of mountpoints like (PATH, MOUNTPOINT) from /proc/mount
|
||
|
curMountpoints :: IO [(String, String)]
|
||
|
curMountpoints = do
|
||
|
m <- readFile "/proc/mounts"
|
||
|
-- ASSUME this will never fail because the format of this file is fixed
|
||
|
return $ (\(path:mntpnt:_) -> (path, mntpnt)) . words <$> lines m
|
||
|
|
||
|
-- | Given a path, return its mountpoint if it exists
|
||
|
lookupMountpoint :: String -> IO (Maybe String)
|
||
|
lookupMountpoint path = lookup path <$> curMountpoints
|
||
|
|
||
|
-- | Given a removable device, unmount it using fuse
|
||
|
fuseUnmount :: Removable -> IO ()
|
||
|
fuseUnmount Removable { path = p, mountpoint = m, label = l } =
|
||
|
maybe umountDef umount m
|
||
|
where
|
||
|
umount = fuseUnmount' l
|
||
|
umountDef = lookupMountpoint p >>=
|
||
|
mapM_ (liftM2 finally umount removePathForcibly)
|
||
|
|
||
|
fuseUnmount' :: String -> String -> IO ()
|
||
|
fuseUnmount' label path = do
|
||
|
res <- readCmdEither "fusermount" ["-u", path] ""
|
||
|
notifyMounted (isRight res) True label
|
||
|
|
||
|
-- | Given credentials, return a password
|
||
|
getPassword :: Credentials -> IO (Maybe String)
|
||
|
getPassword NoCredentials = return Nothing
|
||
|
getPassword (Secret kvs) = do
|
||
|
let kvs' = concat [[a, b] | (a, b) <- M.toList kvs]
|
||
|
readCmdSuccess "secret-tool" ("lookup":kvs') ""
|
||
|
|
||
|
-- TODO this shouldn't be hardcoded
|
||
|
fuseMount :: FilePath
|
||
|
fuseMount = "/media/ndwar-fuse/"
|
||
|
|
||
|
-- TODO what if there is no trailing slash?
|
||
|
fmtFusePath :: String -> String
|
||
|
fmtFusePath label = fuseMount ++ label
|
||
|
|
||
|
makeFuseMount :: String -> IO ()
|
||
|
makeFuseMount label = createDirectoryIfMissing False $ fmtFusePath label
|
||
|
|
||
|
destroyFuseMount :: String -> IO ()
|
||
|
destroyFuseMount label = removePathForcibly $ fmtFusePath label
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- | Other functions
|
||
|
|
||
|
-- 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
|
||
|
|
||
|
notifyMounted :: Bool -> Bool -> String -> IO ()
|
||
|
notifyMounted succeeded mounted label = void $ spawnProcess "notify-send" [msg]
|
||
|
where
|
||
|
f = if succeeded then "Successfully %sed %s" else "Failed to %s %s"
|
||
|
m = if mounted then "unmount" else "mount" :: String
|
||
|
msg = printf f m label
|