init commit
This commit is contained in:
commit
49c4f4cf1c
|
@ -0,0 +1,4 @@
|
|||
.stack-work/
|
||||
*~
|
||||
TAGS
|
||||
*.cabal
|
|
@ -0,0 +1,30 @@
|
|||
Copyright Nathan Dwarshuis (c) 2020
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided
|
||||
with the distribution.
|
||||
|
||||
* Neither the name of Nathan Dwarshuis nor the names of other
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@ -0,0 +1,273 @@
|
|||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
|
||||
module Main (main) where
|
||||
|
||||
import Control.Concurrent
|
||||
import Control.Monad
|
||||
|
||||
import Data.Aeson
|
||||
import Data.Maybe
|
||||
import Data.String
|
||||
import Data.UnixTime
|
||||
|
||||
import DBus
|
||||
import DBus.Client
|
||||
|
||||
import GHC.Generics
|
||||
|
||||
import Rofi.Command
|
||||
|
||||
import Text.Read
|
||||
|
||||
import System.Clipboard
|
||||
import System.Directory
|
||||
import System.Environment
|
||||
import System.Exit
|
||||
import System.Process
|
||||
|
||||
main :: IO ()
|
||||
main = runChecks >> getArgs >>= parse
|
||||
|
||||
parse :: [String] -> IO ()
|
||||
parse ["-d", t] = case readMaybe t of { Just t' -> runDaemon t'; _ -> usage }
|
||||
parse ("-c":args) = runClient args
|
||||
parse _ = usage
|
||||
|
||||
usage :: IO ()
|
||||
usage = putStrLn $ joinNewline
|
||||
[ "daemon mode: rofi-bw -d TIMEOUT"
|
||||
, "client mode: rofi-bw -c [ROFI-ARGS]"
|
||||
]
|
||||
|
||||
runChecks :: IO ()
|
||||
runChecks = checkExe "bw" >> checkExe "rofi"
|
||||
|
||||
checkExe :: String -> IO ()
|
||||
checkExe cmd = do
|
||||
res <- findExecutable cmd
|
||||
unless (isJust res) $ do
|
||||
putStrLn $ "Could not find executable: " ++ cmd
|
||||
exitWith $ ExitFailure 1
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- | Daemon
|
||||
--
|
||||
-- Daemon will export an interface on DBus with two methods:
|
||||
-- * get current session id - if no session is active, launch Rofi to prompt
|
||||
-- for a password; return session id or null if password is invalid
|
||||
-- * lock session - destroy the current session id if active
|
||||
--
|
||||
-- The session ID will be valid only as long as TIMEOUT
|
||||
|
||||
newtype BWConf = BWConf
|
||||
{ timeout :: UnixDiffTime
|
||||
}
|
||||
|
||||
data CurrentSession = CurrentSession
|
||||
{ timestamp :: UnixTime
|
||||
, hash :: String
|
||||
}
|
||||
|
||||
type Session = MVar (Maybe CurrentSession)
|
||||
|
||||
runDaemon :: Int -> IO ()
|
||||
runDaemon t = do
|
||||
ses <- newMVar Nothing
|
||||
let c = BWConf { timeout = UnixDiffTime (fromIntegral t) 0 }
|
||||
startService c ses
|
||||
forever $ threadDelay 1000000
|
||||
|
||||
lockSession :: Session -> IO ()
|
||||
lockSession ses = void $ swapMVar ses Nothing
|
||||
|
||||
getSession :: BWConf -> Session -> IO String
|
||||
getSession BWConf { timeout = t } ses = do
|
||||
ut <- getUnixTime
|
||||
modifyMVar ses $ \s -> case s of
|
||||
Just CurrentSession { timestamp = ts, hash = h } ->
|
||||
if diffUnixTime ut ts > t then getNewSession else return (s, h)
|
||||
Nothing -> getNewSession
|
||||
where
|
||||
getNewSession = do
|
||||
pwd <- readPassword
|
||||
newHash <- join <$> mapM readSession pwd
|
||||
(, fromMaybe "" newHash) <$> mapM newSession newHash
|
||||
newSession h = do
|
||||
ut <- getUnixTime
|
||||
return CurrentSession { timestamp = ut, hash = h }
|
||||
|
||||
readPassword :: IO (Maybe String)
|
||||
readPassword = readCmdSuccess "rofi" args ""
|
||||
where
|
||||
args = dmenuArgs ++ ["-p", "Password", "-password"]
|
||||
|
||||
readSession :: String -> IO (Maybe String)
|
||||
readSession pwd = readCmdSuccess "bw" ["unlock", pwd, "--raw"] ""
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- | Client
|
||||
--
|
||||
-- The client will get the current session from the daemon (if it can) and then
|
||||
-- go through a decision-tree like selection process to retrieve information as
|
||||
-- needed. This will be in the form of the following menus:
|
||||
--
|
||||
-- Main menus
|
||||
-- - Lock Session -> lock the session
|
||||
-- - Browse logins -> show new menu of all logins
|
||||
-- - select an entry -> show new menu with entry contents
|
||||
-- - All -> copy all to clipboard
|
||||
-- - username (if applicable) -> copy to clipboard
|
||||
-- - password (if applicable) -> copy to clipboard
|
||||
-- - anything else (notes and such) -> copy to clipboard
|
||||
|
||||
runClient :: [String] -> IO ()
|
||||
runClient a = do
|
||||
let c = RofiConf { defArgs = a }
|
||||
runRofiPrompt c $ selectAction $ emptyMenu
|
||||
{ groups = [untitledGroup $ toRofiActions ras]
|
||||
, prompt = Just "Action"
|
||||
}
|
||||
where
|
||||
ras = [ ("Browse Logins", browseLogins)
|
||||
, ("Lock Session", io callLockSession)
|
||||
]
|
||||
|
||||
browseLogins :: RofiPrompt ()
|
||||
browseLogins = do
|
||||
session <- io callGetSession
|
||||
forM_ session $ getItems >=> selectItem
|
||||
|
||||
getItems :: String -> RofiPrompt [Item]
|
||||
getItems session = do
|
||||
items <- io $ readProcess "bw" ["list", "items", "--session", session] ""
|
||||
return $ filter notEmpty $ fromMaybe [] $ decode $ fromString items
|
||||
where
|
||||
notEmpty Item { login = Login { username = Nothing, password = Nothing } }
|
||||
= False
|
||||
notEmpty _ = True
|
||||
|
||||
data Item = Item
|
||||
{ name :: String
|
||||
, login :: Login
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
instance FromJSON Item where
|
||||
parseJSON (Object o) = Item
|
||||
<$> o .: "name"
|
||||
<*> o .:? "login" .!= Login { username = Nothing, password = Nothing }
|
||||
parseJSON _ = mzero
|
||||
|
||||
data Login = Login
|
||||
{ username :: Maybe String
|
||||
, password :: Maybe String
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
instance FromJSON Login
|
||||
|
||||
-- TODO make menu buttons here to go back and to copy without leaving
|
||||
-- the current menu
|
||||
selectItem :: [Item] -> RofiPrompt ()
|
||||
selectItem items = selectAction $ emptyMenu
|
||||
{ groups = [untitledGroup $ itemsToRofiActions items]
|
||||
, prompt = Just "Login"
|
||||
}
|
||||
|
||||
itemsToRofiActions :: [Item] -> RofiActions
|
||||
itemsToRofiActions = toRofiActions . fmap (\i -> (name i, selectCopy $ login i))
|
||||
|
||||
selectCopy :: Login -> RofiPrompt ()
|
||||
selectCopy l = selectAction $ emptyMenu
|
||||
{ groups = [untitledGroup $ loginToRofiActions l copy]
|
||||
, prompt = Just "Copy"
|
||||
, hotkeys = [copyHotkey, backHotkey]
|
||||
}
|
||||
where
|
||||
copy = io . setClipboardString
|
||||
copyRepeat s = copy s >> selectCopy l
|
||||
copyHotkey = Hotkey
|
||||
{ keyCombo = "Alt+c"
|
||||
, keyIndex = 1
|
||||
, keyDescription = "Copy One"
|
||||
, keyActions = loginToRofiActions l copyRepeat
|
||||
}
|
||||
backHotkey = Hotkey
|
||||
{ keyCombo = "Alt+q"
|
||||
, keyIndex = 2
|
||||
, keyDescription = "Back"
|
||||
-- TODO this is overly complicated, all entries do the same thing
|
||||
-- TODO this is slow, we can cache the logins somehow...
|
||||
, keyActions = loginToRofiActions l (const browseLogins)
|
||||
}
|
||||
|
||||
loginToRofiActions :: Login -> (String -> RofiPrompt ()) -> RofiActions
|
||||
loginToRofiActions Login { username = u, password = p } a =
|
||||
toRofiActions $ catMaybes [user, pwd]
|
||||
where
|
||||
copyIfJust f = fmap $ liftM2 (,) f a
|
||||
fmtUsername s = "Username (" ++ s ++ ")"
|
||||
fmtPassword s = "Password (" ++ take 32 (replicate (length s) '*') ++ ")"
|
||||
user = copyIfJust fmtUsername u
|
||||
pwd = copyIfJust fmtPassword p
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- | DBus
|
||||
|
||||
busname :: BusName
|
||||
busname = "org.rofi.bitwarden"
|
||||
|
||||
path :: ObjectPath
|
||||
path = "/bitwarden"
|
||||
|
||||
interface :: InterfaceName
|
||||
interface = "org.rofi.bitwarden.session"
|
||||
|
||||
memGetSession :: MemberName
|
||||
memGetSession = "GetSession"
|
||||
|
||||
memLockSession :: MemberName
|
||||
memLockSession = "LockSession"
|
||||
|
||||
startService :: BWConf -> Session -> IO ()
|
||||
startService c ses = do
|
||||
client <- connectSession
|
||||
let flags = [nameAllowReplacement, nameReplaceExisting]
|
||||
_ <- requestName client busname flags
|
||||
putStrLn "Started rofi bitwarden dbus client"
|
||||
export client path defaultInterface
|
||||
{ interfaceName = interface
|
||||
, interfaceMethods =
|
||||
[ autoMethod memGetSession $ getSession c ses
|
||||
, autoMethod memLockSession $ lockSession ses
|
||||
]
|
||||
}
|
||||
|
||||
callLockSession :: IO ()
|
||||
callLockSession = do
|
||||
reply <- callMethod $ methodCall path interface memLockSession
|
||||
case reply of
|
||||
Left err -> putStrLn $ methodErrorMessage err
|
||||
Right _ -> return ()
|
||||
|
||||
callGetSession :: IO (Maybe String)
|
||||
callGetSession = do
|
||||
reply <- callMethod $ methodCall path interface memGetSession
|
||||
case reply of
|
||||
Left err -> putStrLn (methodErrorMessage err) >> return Nothing
|
||||
Right body -> return $ getBodySession body
|
||||
|
||||
getBodySession :: [Variant] -> Maybe String
|
||||
getBodySession [b] = case ses of { Just "" -> Nothing; _ -> ses }
|
||||
where
|
||||
ses = fromVariant b :: Maybe String
|
||||
getBodySession _ = Nothing
|
||||
|
||||
callMethod :: MethodCall -> IO (Either MethodError [Variant])
|
||||
callMethod mc = do
|
||||
client <- connectSession
|
||||
reply <- call client mc { methodCallDestination = Just busname }
|
||||
disconnect client
|
||||
return $ methodReturnBody <$> reply
|
|
@ -0,0 +1,473 @@
|
|||
{-# 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
|
|
@ -0,0 +1,164 @@
|
|||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
|
||||
module Rofi.Command
|
||||
( RofiConf(..)
|
||||
, RofiMenu(..)
|
||||
, RofiAction
|
||||
, RofiActions
|
||||
, RofiPrompt
|
||||
, Hotkey(..)
|
||||
, io
|
||||
, emptyMenu
|
||||
, runRofiPrompt
|
||||
, toRofiActions
|
||||
, rofiActionKeys
|
||||
, untitledGroup
|
||||
, titledGroup
|
||||
, selectAction
|
||||
, readCmdSuccess
|
||||
, readCmdEither
|
||||
, dmenuArgs
|
||||
, joinNewline
|
||||
, stripWS
|
||||
) where
|
||||
|
||||
import Control.Monad.Reader
|
||||
|
||||
import Data.Char
|
||||
import Data.List
|
||||
import qualified Data.Map.Ordered as M
|
||||
import Data.Maybe
|
||||
|
||||
import System.Exit
|
||||
import System.Process
|
||||
|
||||
newtype RofiConf = RofiConf
|
||||
{ defArgs :: [String]
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
type RofiAction = (String, RofiPrompt ())
|
||||
|
||||
type RofiActions = M.OMap String (RofiPrompt ())
|
||||
|
||||
data RofiGroup = RofiGroup
|
||||
{ actions :: RofiActions
|
||||
, title :: Maybe String
|
||||
}
|
||||
|
||||
untitledGroup :: RofiActions -> RofiGroup
|
||||
untitledGroup a = RofiGroup { actions = a, title = Nothing }
|
||||
|
||||
titledGroup :: String -> RofiActions -> RofiGroup
|
||||
titledGroup t a = (untitledGroup a) { title = Just t }
|
||||
|
||||
data Hotkey = Hotkey
|
||||
{ keyCombo :: String
|
||||
-- only 1-10 are valid
|
||||
, keyIndex :: Int
|
||||
, keyDescription :: String
|
||||
, keyActions :: RofiActions
|
||||
}
|
||||
|
||||
hotkeyBinding :: Hotkey -> [String]
|
||||
hotkeyBinding Hotkey { keyIndex = e, keyCombo = c } = [k, c]
|
||||
where
|
||||
k = "-kb-custom-" ++ show e
|
||||
|
||||
hotkeyMsg1 :: Hotkey -> String
|
||||
hotkeyMsg1 Hotkey { keyCombo = c, keyDescription = d } =
|
||||
c ++ ": <i>" ++ d ++ "</i>"
|
||||
|
||||
hotkeyMsg :: [Hotkey] -> [String]
|
||||
hotkeyMsg [] = []
|
||||
hotkeyMsg hs = ["-mesg", intercalate " | " $ fmap hotkeyMsg1 hs]
|
||||
|
||||
hotkeyArgs :: [Hotkey] -> [String]
|
||||
hotkeyArgs hks = hotkeyMsg hks ++ concatMap hotkeyBinding hks
|
||||
|
||||
data RofiMenu = RofiMenu
|
||||
{ groups :: [RofiGroup]
|
||||
, prompt :: Maybe String
|
||||
, hotkeys :: [Hotkey]
|
||||
}
|
||||
|
||||
emptyMenu :: RofiMenu
|
||||
emptyMenu = RofiMenu
|
||||
{ groups = []
|
||||
, prompt = Nothing
|
||||
, hotkeys = []
|
||||
}
|
||||
|
||||
newtype RofiPrompt a = RofiPrompt (ReaderT RofiConf IO a)
|
||||
deriving (Functor, Monad, MonadIO, MonadReader RofiConf)
|
||||
|
||||
instance Applicative RofiPrompt where
|
||||
pure = return
|
||||
(<*>) = ap
|
||||
|
||||
io :: IO a -> RofiPrompt a
|
||||
io = liftIO
|
||||
|
||||
runRofiPrompt :: RofiConf -> RofiPrompt a -> IO a
|
||||
runRofiPrompt c (RofiPrompt a) = runReaderT a c
|
||||
|
||||
toRofiActions :: [(String, RofiPrompt ())] -> RofiActions
|
||||
toRofiActions = M.fromList
|
||||
|
||||
rofiActionKeys :: RofiActions -> String
|
||||
rofiActionKeys = joinNewline . map fst . M.assocs
|
||||
|
||||
lookupRofiAction :: String -> RofiActions -> RofiPrompt ()
|
||||
lookupRofiAction key ras = fromMaybe (return ()) $ M.lookup key ras
|
||||
|
||||
groupEntries :: RofiGroup -> String
|
||||
groupEntries RofiGroup { actions = a, title = t }
|
||||
| null a = ""
|
||||
| otherwise = title' ++ rofiActionKeys a
|
||||
where
|
||||
title' = maybe "" (++ "\n") t
|
||||
|
||||
menuActions :: RofiMenu -> RofiActions
|
||||
menuActions = foldr1 (M.<>|) . fmap actions . groups
|
||||
|
||||
menuEntries :: RofiMenu -> String
|
||||
menuEntries = intercalate "\n\n" . fmap groupEntries . groups
|
||||
|
||||
selectAction :: RofiMenu -> RofiPrompt ()
|
||||
selectAction rm = do
|
||||
let p = maybeOption "-p" $ prompt rm
|
||||
let hArgs = hotkeyArgs $ hotkeys rm
|
||||
res <- readRofi (p ++ hArgs) $ menuEntries rm
|
||||
case res of
|
||||
Right key -> lookupRofiAction key $ menuActions rm
|
||||
Left (n, key, _) -> mapM_ (lookupRofiAction key . keyActions)
|
||||
$ find ((==) n . (+ 9) . keyIndex)
|
||||
$ hotkeys rm
|
||||
|
||||
maybeOption :: String -> Maybe String -> [String]
|
||||
maybeOption switch = maybe [] (\o -> [switch, o])
|
||||
|
||||
dmenuArgs :: [String]
|
||||
dmenuArgs = ["-dmenu"]
|
||||
|
||||
readRofi :: [String] -> String -> RofiPrompt (Either (Int, String, String) String)
|
||||
readRofi uargs input = do
|
||||
dargs <- asks defArgs
|
||||
io $ readCmdEither "rofi" (dmenuArgs ++ dargs ++ uargs) input
|
||||
|
||||
readCmdSuccess :: String -> [String] -> String -> IO (Maybe String)
|
||||
readCmdSuccess cmd args input = either (const Nothing) Just
|
||||
<$> readCmdEither cmd args input
|
||||
|
||||
readCmdEither :: String -> [String] -> String -> IO (Either (Int, String, String) String)
|
||||
readCmdEither cmd args input = do
|
||||
(ec, out, err) <- readProcessWithExitCode cmd args input
|
||||
return $ case ec of
|
||||
ExitSuccess -> Right $ stripWS out
|
||||
ExitFailure n -> Left (n, stripWS out, stripWS err)
|
||||
|
||||
stripWS :: String -> String
|
||||
stripWS = reverse . dropWhile isSpace . reverse
|
||||
|
||||
joinNewline :: [String] -> String
|
||||
joinNewline = intercalate "\n"
|
|
@ -0,0 +1,64 @@
|
|||
name: rofi-extras
|
||||
version: 0.1.0.0
|
||||
github: "ndwarshuis/rofi-extras"
|
||||
license: BSD3
|
||||
author: "Nathan Dwarshuis"
|
||||
maintainer: "ndwar@yavin4.ch"
|
||||
copyright: "2020 Nathan Dwarshuis"
|
||||
|
||||
extra-source-files:
|
||||
- README.md
|
||||
|
||||
# Metadata used when publishing your package
|
||||
# synopsis: Short description of your package
|
||||
# category: Web
|
||||
|
||||
# To avoid duplicated efforts in documentation and dealing with the
|
||||
# complications of embedding Haddock markup inside cabal files, it is
|
||||
# common to point users to the README.md file.
|
||||
description: Please see the README on GitHub at <https://github.com/ndwarshuis/rofi-extras#readme>
|
||||
|
||||
dependencies:
|
||||
- base >= 4.7 && < 5
|
||||
- process >= 1.6.5.0
|
||||
- aeson >= 1.4.5.0
|
||||
- unix-time >= 0.4.7
|
||||
- dbus >= 1.2.7
|
||||
- ordered-containers >= 0.2.2
|
||||
- Clipboard >= 2.3.2.0
|
||||
- mtl >= 2.2.2
|
||||
- directory >= 1.3.3.0
|
||||
- cassava >= 0.5.2.0
|
||||
- bytestring >= 0.10.8.2
|
||||
- vector >= 0.12.0.3
|
||||
- regex-tdfa >= 1.2.3.2
|
||||
- split >= 0.2.3.4
|
||||
|
||||
library:
|
||||
source-dirs: lib/
|
||||
ghc-options:
|
||||
- -Wall
|
||||
- -Werror
|
||||
- -threaded
|
||||
exposed-modules:
|
||||
- Rofi.Command
|
||||
|
||||
executables:
|
||||
rofi-bw:
|
||||
main: rofi-bitwarden.hs
|
||||
source-dirs: app
|
||||
ghc-options:
|
||||
- -Wall
|
||||
- -Werror
|
||||
- -threaded
|
||||
dependencies:
|
||||
- rofi-extras
|
||||
rofi-dev:
|
||||
main: rofi-devices.hs
|
||||
source-dirs: app
|
||||
ghc-options:
|
||||
- -Wall
|
||||
- -Werror
|
||||
- -threaded
|
||||
dependencies:
|
||||
- rofi-extras
|
|
@ -0,0 +1,66 @@
|
|||
# This file was automatically generated by 'stack init'
|
||||
#
|
||||
# Some commonly used options have been documented as comments in this file.
|
||||
# For advanced use and comprehensive documentation of the format, please see:
|
||||
# https://docs.haskellstack.org/en/stable/yaml_configuration/
|
||||
|
||||
# Resolver to choose a 'specific' stackage snapshot or a compiler version.
|
||||
# A snapshot resolver dictates the compiler version and the set of packages
|
||||
# to be used for project dependencies. For example:
|
||||
#
|
||||
# resolver: lts-3.5
|
||||
# resolver: nightly-2015-09-21
|
||||
# resolver: ghc-7.10.2
|
||||
#
|
||||
# The location of a snapshot can be provided as a file or url. Stack assumes
|
||||
# a snapshot provided as a file might change, whereas a url resource does not.
|
||||
#
|
||||
# resolver: ./custom-snapshot.yaml
|
||||
# resolver: https://example.com/snapshots/2018-01-01.yaml
|
||||
resolver: lts-14.12
|
||||
|
||||
# User packages to be built.
|
||||
# Various formats can be used as shown in the example below.
|
||||
#
|
||||
# packages:
|
||||
# - some-directory
|
||||
# - https://example.com/foo/bar/baz-0.0.2.tar.gz
|
||||
# subdirs:
|
||||
# - auto-update
|
||||
# - wai
|
||||
packages:
|
||||
- .
|
||||
# Dependency packages to be pulled from upstream that are not in the resolver.
|
||||
# These entries can reference officially published versions as well as
|
||||
# forks / in-progress versions pinned to a git hash. For example:
|
||||
#
|
||||
# extra-deps:
|
||||
# - acme-missiles-0.3
|
||||
# - git: https://github.com/commercialhaskell/stack.git
|
||||
# commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a
|
||||
#
|
||||
# extra-deps: []
|
||||
|
||||
# Override default flag values for local packages and extra-deps
|
||||
# flags: {}
|
||||
|
||||
# Extra package databases containing global packages
|
||||
# extra-package-dbs: []
|
||||
|
||||
# Control whether we use the GHC we find on the path
|
||||
# system-ghc: true
|
||||
#
|
||||
# Require a specific version of stack, using version ranges
|
||||
# require-stack-version: -any # Default
|
||||
# require-stack-version: ">=2.1"
|
||||
#
|
||||
# Override the architecture used by stack, especially useful on Windows
|
||||
# arch: i386
|
||||
# arch: x86_64
|
||||
#
|
||||
# Extra directories used by stack for building
|
||||
# extra-include-dirs: [/path/to/dir]
|
||||
# extra-lib-dirs: [/path/to/dir]
|
||||
#
|
||||
# Allow a newer minor version of GHC than the snapshot specifies
|
||||
# compiler-check: newer-minor
|
|
@ -0,0 +1,12 @@
|
|||
# This file was autogenerated by Stack.
|
||||
# You should not edit this file by hand.
|
||||
# For more information, please see the documentation at:
|
||||
# https://docs.haskellstack.org/en/stable/lock_files
|
||||
|
||||
packages: []
|
||||
snapshots:
|
||||
- completed:
|
||||
size: 545658
|
||||
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/14/12.yaml
|
||||
sha256: 26b807457213126d26b595439d705dc824dbb7618b0de6b900adc2bf6a059406
|
||||
original: lts-14.12
|
Loading…
Reference in New Issue