barbly-0.2.0.0: src/Main.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ExtendedDefaultRules #-}
module Main where
import Control.Concurrent
import Control.Concurrent.Async
import Control.Monad
import Control.Monad.Cont
import Control.Monad.IO.Class (liftIO)
import Data.Aeson ((.:), (<?>))
import qualified Data.Aeson as JSON
import qualified Data.Aeson.Types as JSON
import System.Process
import System.Exit
import System.IO
import System.Environment
import qualified Data.Attoparsec.Text as P
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString as BS
import Data.ByteString.Lazy (toStrict)
import Data.Char
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import GHC.Generics
import Options.Applicative
import qualified System.IO as IO
import qualified Data.Map as Map
import AppKit
data Menu = Menu
{ title :: Text
, items :: [MenuItem]
} deriving (Generic, JSON.FromJSON, JSON.ToJSON)
data MenuItem
= MenuSeparator
| MenuItem {label :: Text, exec :: [Text]}
| MenuRaw {label :: Text, runio :: IO ()}
| MenuSub Menu
-- Custom parser to get better error messages.
instance JSON.FromJSON MenuItem where
parseJSON = JSON.withObject "Menu Item" $ \o ->
parseSep o <|> parseOther o
where
parseSep o = do
guard $ o == mempty
pure MenuSeparator
parseOther o = do
t <- o .: "label"
if length o == 1
then
pure $ MenuItem t []
else do
r <- Right <$> o .: "exec"
<|> Left <$> o .: "items"
<|> fail "Expected key \"items\" or \"exec\"."
case r of
Right j -> MenuItem t <$> JSON.parseJSON j <?> JSON.Key "exec"
Left j -> MenuSub . Menu t <$> JSON.parseJSON j <?> JSON.Key "items"
instance JSON.ToJSON MenuItem where
toJSON MenuRaw{} = error "Attempting to serialise internal structure"
toJSON MenuSeparator = JSON.object []
toJSON (MenuItem t e) = JSON.object
[ ("label", JSON.toJSON t)
, ("exec", JSON.toJSON e)
]
toJSON (MenuSub (Menu t is)) = JSON.object
[ ("label", JSON.toJSON t)
, ("items", JSON.toJSON is)
]
createMenu :: Menu -> ContT r IO NSMenu
createMenu m = do
nm <- newMenu (title m)
mapM_ (\mi -> createMenuItem mi >>= liftIO . addMenuItem nm) (items m)
pure nm
where
createMenuItem :: MenuItem -> ContT r IO NSMenuItem
createMenuItem (MenuItem s []) = newMenuItem s
createMenuItem (MenuItem s cmds) = do
mi <- newMenuItem s
let cmd:args = map Text.unpack cmds
let cp = (proc cmd args)
{ std_in = NoStream
, std_out = NoStream
, std_err = NoStream
, close_fds = False
, delegate_ctlc = True
}
liftIO (assignAction mi (createProcess cp >>= \(_, _,_, h) -> void (waitForProcess h)))
pure mi
createMenuItem (MenuRaw t a) = do
mi <- newMenuItem t
liftIO (assignAction mi a)
pure mi
createMenuItem MenuSeparator = newSeparator
createMenuItem (MenuSub sm) = do
nssm <- createMenu sm
mi <- newMenuItem (title sm)
liftIO $ assignSubMenu mi nssm
pure mi
data Options = Options
{ debug :: Bool
, period :: Double
, format :: ByteString -> Menu
, command :: [String]
}
optionParser :: Parser Options
optionParser = Options <$> parseDebug <*> parsePeriod <*> parseFormat <*> parseCommand
where
parsePeriod :: Parser Double
parsePeriod = option auto
( long "period"
<> short 'p'
<> value 300
<> help "Period between running the command in seconds"
<> metavar "SECONDS"
<> showDefault
)
parseFormat :: Parser (ByteString -> Menu)
parseFormat = flag' parseBitBar
( long "bitbar"
<> help "Assume script output is bitbar syntax (default auto detect)"
) <|>
flag' parseJSON
( long "json"
<> help "Assume script output is JSON (default auto detect)"
) <|>
pure parseAuto
parseCommand :: Parser [String]
parseCommand = (:)
<$> strArgument (metavar "CMD" <> help "Command to run")
<*> many (strArgument (metavar "ARGS"))
parseDebug :: Parser Bool
parseDebug = switch
( long "debug"
<> help "Enable menu items that assist in debugging"
)
view :: String -> IO ()
view = void . readProcess "/usr/bin/open" ["-f"]
main :: IO ()
main = runInBoundThread $ do
opts <- execParser $ info (optionParser <**> helper) fullDesc
mvMenu <- newEmptyMVar
initApp
runContT newStatusItem $ \si -> do
let
cmd:args = (Main.command opts)
cp = (proc cmd args)
{ close_fds = False
, std_in = NoStream
, std_out = CreatePipe
, std_err = CreatePipe
}
runner = forever $ do
(Nothing, Just sout, Just serr, h) <- createProcess cp
runConcurrently ((,,)
<$> Concurrently (hGetContents' sout)
<*> Concurrently (hGetContents' serr)
<*> Concurrently (waitForProcess h)
) >>= \case
(out, err, ExitFailure f) -> do
putMVar mvMenu
( Menu "Error!" $
[ MenuItem (Text.pack $ "Exit failure: " ++ show f) [] ]
++
[ MenuRaw "View stdout" (view out)
, MenuRaw "View stderr" (view err)
]
)
(res, err, ExitSuccess) -> do
let
menu' = format opts (Text.encodeUtf8 $ Text.pack res)
menu
| debug opts = menu'
{ items = items menu'
++ [ MenuSeparator
, MenuRaw "Debug: view output"
$ view res
, MenuRaw "Debug: view stderr"
$ view err
]
}
| otherwise = menu'
putMVar mvMenu menu
sendEvent
threadDelay (round $ period opts * 1000000)
withAsync (runner) $ \_ -> do
runApp $ takeMVar mvMenu >>= \menu -> do
runContT (createMenu menu) $ \nsmenu -> do
setTitle si (if Text.null (title menu) then "[no title]" else (title menu))
setStatusItemMenu si nsmenu
-- BitBar compatible Parser
parseItem :: Int -> P.Parser MenuItem
parseItem lev = parseLevelIndicator *> P.choice
[ parseSep
, parseGeneric
, parseSubMenu
, parseInfo
]
where
parseLevelIndicator :: P.Parser ()
parseLevelIndicator = do
P.count lev (P.string "--")
when (lev > 0) $ void $ P.space
parseSubMenu = do
t <- P.takeWhile (/= '\n')
P.endOfLine
is <- P.many1 (parseItem $ succ lev)
pure $ MenuSub $ Menu t is
parseSep = P.string "---" *> P.endOfLine *> pure MenuSeparator
parseInfo = MenuItem <$> (P.takeWhile (/= '\n') <* P.endOfLine) <*> pure []
parseGeneric = do
(name, params) <- parseBodyWithTags
let pglob = Map.fromListWith (++) $ map (fmap (:[])) params
case lookup "href" params of
Just s -> pure $ MenuItem name ["/usr/bin/open", s]
_ -> case lookup "bash" params of
Just cmd -> do
pure $ MenuItem name $ cmd : (Map.findWithDefault [] "param" pglob)
_ -> fail "hmm"
parseBodyWithTags :: P.Parser (Text, [(Text, Text)])
parseBodyWithTags = do
t <- P.takeTill (\s -> s == '|' || s == '\n')
ts <- parseAllTags
optional $ P.skipWhile P.isHorizontalSpace
P.endOfLine
pure (t, ts)
parseAllTags :: P.Parser [(Text, Text)]
parseAllTags = P.option [] $ do
P.char '|'
optional $ P.skipWhile P.isHorizontalSpace
P.sepBy parseGenericTag (P.skipWhile P.isHorizontalSpace)
where
parseGenericTag :: P.Parser (Text, Text)
parseGenericTag = do
key <- P.takeWhile isAlphaNum
P.char '='
val <- parseString
pure (key, val)
parseString :: P.Parser Text
parseString = P.choice [quoted,raw]
where
quoted = do
P.char '"'
s <- parseU ""
pure (Text.pack $ reverse s)
parseU :: String -> P.Parser String
parseU s = do
P.anyChar >>= \case
'"' -> pure s
'\\' -> P.anyChar >>= \case
'n' -> parseU ('\n':s)
c -> parseU (c:s)
'\n' -> fail "Unexpected newline"
c -> parseU (c:s)
raw = P.takeWhile (\c -> isAlphaNum c || c `elem` "./()[]{}!@#$%^&*,:;-\\")
parseTitle :: P.Parser Text
--parseTitle = Text.strip . Text.pack <$> P.manyTill P.anyChar (void (P.string "---\n") <|> P.endOfInput)
parseTitle = do
(t, ts) <- parseBodyWithTags
void (P.string "---\n") <|> P.endOfInput
pure t
parseMenu :: P.Parser Menu
parseMenu = Menu <$> parseTitle <*> many (parseItem 0)
stripControlSequences :: ByteString -> ByteString
stripControlSequences i = case Char8.uncons i of
Just ('\ESC', r) -> go1 r
Just (c, r) -> Char8.cons c (stripControlSequences r)
Nothing -> Char8.empty
where
go1 :: ByteString -> ByteString
go1 i = case Char8.uncons i of
Just ('[', r) -> go2 r
Just (a, r) -> Char8.cons a $ stripControlSequences r
Nothing -> Char8.empty
go2 :: ByteString -> ByteString
go2 i = case BS.uncons i of
Just (c, r) -> if c >= 0x40 && c <= 0x7E then stripControlSequences r else go2 r
Nothing -> Char8.empty
parseBitBar :: ByteString -> Menu
parseBitBar s = case P.parseOnly parseMenu (Text.decodeUtf8 $ stripControlSequences s) of
Left _ -> Menu "Error parsing bitbar syntax"
[ MenuRaw "Show document" (view $ Text.unpack $ Text.decodeUtf8 s)
]
Right m -> m
parseJSON :: ByteString -> Menu
parseJSON s = case JSON.eitherDecodeStrict' s of
Left e -> Menu "Error parsing json" $
[MenuItem l [] | l <- Text.lines (Text.pack e)]
++ [MenuRaw "Open JSON document" (view $ Text.unpack $ Text.decodeUtf8 s)]
Right m -> m
-- | Parse auto attempts to detect if this is meant to be a JSON object by looking
-- for a leading @{@. If it is, it assumes JSON output, and will report errors
-- as such. If the first non-whitespace character is anything else, it will assume
-- bitbar syntax.
parseAuto :: ByteString -> Menu
parseAuto s = case Char8.uncons (Char8.dropWhile isSpace s) of
Just ('{',_) -> parseJSON s
_ -> parseBitBar s