274 lines
7.9 KiB
Haskell
274 lines
7.9 KiB
Haskell
|
{-# 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
|