Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add .desktop prompt launcher #651

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions XMonad/Prompt/DotDesktop.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE LambdaCase #-}
module XMonad.Prompt.DotDesktop
( appLaunchPrompt
) where

import XMonad ( spawn, io, X )
import XMonad.Prompt ( mkXPrompt, XPConfig(searchPredicate) )
import XMonad.Prompt.Shell ( Shell(Shell) )
import XMonad.Prompt.DotDesktopParser ( runDotDesktopParser )

import qualified Data.Map as M
import Control.Monad (filterM)
import Control.Monad.Except
( runExceptT, ExceptT (ExceptT), liftEither )
import Control.Exception ( try, Exception )
import Data.Functor ( (<&>) )
import Data.List ( isSuffixOf, dropWhileEnd )
import Data.Maybe ( listToMaybe )
import System.Directory (listDirectory, doesDirectoryExist, getXdgDirectory, XdgDirectory (XdgData), XdgDirectoryList (XdgDataDirs), getXdgDirectoryList)
import System.FilePath ((</>))

import Data.Char (isSpace)
import Data.Either (rights, lefts)
import XMonad.Prelude (join)

isDotDesktop :: FilePath -> Bool
isDotDesktop = isSuffixOf ".desktop"

trimWhitespace :: String -> String
trimWhitespace = dropWhileEnd isSpace . dropWhile isSpace

cmdFilter :: String -> String -- fixme future do something other than dropping these
cmdFilter ('%':'f':xs) = cmdFilter xs
cmdFilter ('%':'F':xs) = cmdFilter xs
cmdFilter ('%':'u':xs) = cmdFilter xs
cmdFilter ('%':'U':xs) = cmdFilter xs
cmdFilter ('%':'c':xs) = cmdFilter xs
cmdFilter ('%':'k':xs) = cmdFilter xs
cmdFilter ('%':'i':xs) = cmdFilter xs
cmdFilter ('%':'%':xs) = '%' : cmdFilter xs
cmdFilter (x:xs) = x : cmdFilter xs
cmdFilter "" = ""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well this doesn't look fun :) I'm not familiar with .desktop files at all; what exactly are you doing here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to just execute the .desktop command specified, without arguments.
Per the spec here:
https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables
there are many field codes for arguments which I cannot pass directly to the shell to execute, so I just remove them.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to see if using xdg-open to run it gives you more options.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not understand how I would use xdg-open? Could you provide an example?

Just for context, here is an example from gimp.desktop:

[Desktop Entry]
Version=1.0
Type=Application
Name=GNU Image Manipulation Program
...
Exec=gimp-2.10 %U
...

Basically the user could type image, fuzzyMatch finds and displays GNU Image Manipulation Program, and if it is selected, the Exec is gimp-2.10 %U. This code filters out the %U and passes gimp-2.10 to the shell to launch.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gtk-launch or kioclient5 exec can be used to run a .desktop file: https://askubuntu.com/a/1114798/950919

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gtk-launch is (well, at least on my distro) installed with the gtk package itself, so chances are users will definitely have this installed.

Will the fact that it goes into /usr/share/applications by itself be a problem? Out of the current paths that the code searches (~/.local/share/applications, /usr/share/applications, and /usr/local/share/applications) on the first one seems not to get searched by gtk-launch.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gtk-launch does look into ~/.local/share/applications here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mh, doesn't work on my end; a .desktop file that's only in ~/.local/share/applications but not in any of the other directories will fail to be found

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's really weird, I looked into the source of gtk/glib and I don't see why it wouldn't look there. Maybe try stracing it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh indeed, strace tells me that the directory is searched. Upon closer inspection, the .desktop file contains a non-existant executable; instead of telling me that, gtk-launch told me that the .desktop file couldn't be found, however ._.


convertExceptionToString :: Exception e => IO (Either e a) -> IO (Either String a)
convertExceptionToString = fmap convertExceptionToStringHelper

convertExceptionToStringHelper :: Exception e => Either e a -> Either String a
convertExceptionToStringHelper = either (Left . convertExceptionToStringHelperHelper) Right

convertExceptionToStringHelperHelper :: Exception e => e -> String
convertExceptionToStringHelperHelper = show :: Exception e => e -> String
tulth marked this conversation as resolved.
Show resolved Hide resolved

doReadFileLBS :: String -> ExceptT String IO String
doReadFileLBS = ExceptT . convertExceptionToString . try @IOError . readFile

getVal :: String -> String -> M.Map String String -> Either String String
getVal msg k kvmap = maybeToEither msg $ M.lookup k kvmap

maybeToEither :: b -> Maybe a -> Either b a
maybeToEither _ (Just a) = Right a
maybeToEither b Nothing = Left b

doParseFile :: String -> ExceptT String IO DotDesktopApp
doParseFile filePath = do
content <- doReadFileLBS filePath
parsed <- liftEither $ runDotDesktopParser content
let kvMaybe = snd <$> listToMaybe (rights parsed)
keyVals <- liftEither $
maybe
(Left $ "Parse Resulted in no KeyVals in file " ++ filePath)
Right
kvMaybe
let errMsg = "Unable to find Name in file " ++ filePath
nom <- liftEither $ getVal errMsg "Name" keyVals
exc <- liftEither $ getVal errMsg "Exec" keyVals
typ <- liftEither $ getVal errMsg "Type" keyVals
return DotDesktopApp { fileName = filePath
, name = nom
, type_ = typ
, exec = exc
, cmd = (trimWhitespace . cmdFilter) exc
}

data DotDesktopApp = DotDesktopApp { fileName :: String
, name :: String
, type_ :: String
, exec :: String
, cmd :: String
} deriving Show


getAppFolders :: IO [FilePath]
getAppFolders = do
xdgDataHome <- getXdgDirectory XdgData ""
xdgDataDirs <- getXdgDirectoryList XdgDataDirs
let possibleAppDirs = (xdgDataHome : xdgDataDirs) <&> (</> "applications")
filterM doesDirectoryExist possibleAppDirs

getDirContents :: FilePath -> ExceptT String IO [FilePath]
tulth marked this conversation as resolved.
Show resolved Hide resolved
getDirContents dir = do
fn <- ExceptT . convertExceptionToString . try @IOError . listDirectory $ dir
return $ (dir </>) <$> fn

getDotDesktopApps :: IO [DotDesktopApp]
getDotDesktopApps = do
appFolders <- getAppFolders
contentsPerFolder <- mapM (runExceptT . getDirContents) appFolders
let folderFiles = join $ rights contentsPerFolder
dotDesktopFiles = filter isDotDesktop folderFiles
folderWarnings = join $ lefts contentsPerFolder
mapM_ print folderWarnings
parseResults <- mapM (runExceptT . doParseFile) dotDesktopFiles
let parseErrs = lefts parseResults
dotDesktopApps = rights parseResults
mapM_ print parseErrs
return dotDesktopApps

appLaunchPrompt :: XPConfig -> X ()
appLaunchPrompt cfg = do
cmdNameMap <- io $ getDotDesktopApps <&> map (\el -> (name el, cmd el)) <&> M.fromList
let cmdNameMapKeys = M.keys cmdNameMap
complFunc :: String -> [String]
complFunc s = filter (searchPredicate cfg s) cmdNameMapKeys
--
complAction :: String -> X ()
complAction s = do
spawn $ cmdNameMap M.! s
mkXPrompt Shell cfg (pure . complFunc) complAction
171 changes: 171 additions & 0 deletions XMonad/Prompt/DotDesktopParser.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE LambdaCase #-}
module XMonad.Prompt.DotDesktopParser
( runDotDesktopParser
) where

import Data.Maybe ( catMaybes )
import Control.Monad ( MonadPlus(..) )
import Control.Applicative ( Alternative(..) )
import qualified Data.Map as MAP

newtype Parser a = Parser { parse :: String -> [(a,String)] }
tulth marked this conversation as resolved.
Show resolved Hide resolved

runParser :: Parser a -> String -> Either String a
runParser m s =
case parse m s of
[(res, [])] -> Right res
[(_, b)] -> Left $ "Parser did not consume entire stream. Remaining: " ++ show b -- ++ " " ++ show b
_ -> Left "Parser error."

item :: Parser Char
item = Parser $ \case
[] -> []
(c:cs) -> [(c,cs)]

instance Functor Parser where
fmap f (Parser cs) = Parser (\s -> [(f a, b) | (a, b) <- cs s])

instance Applicative Parser where
pure a = Parser (\s -> [(a,s)])
(Parser cs1) <*> (Parser cs2) = Parser (\s -> [(f a, s2) | (f, s1) <- cs1 s, (a, s2) <- cs2 s1])

instance Monad Parser where
p >>= f = Parser $ \s -> concatMap (\(a, s') -> parse (f a) s') $ parse p s

instance MonadPlus Parser where
mzero = failure
mplus = combine

instance Alternative Parser where
empty = mzero
(<|>) = option

combine :: Parser a -> Parser a -> Parser a
combine p q = Parser (\s -> parse p s ++ parse q s)

failure :: Parser a
failure = Parser (const [])

option :: Parser a -> Parser a -> Parser a
option p q = Parser $ \s ->
case parse p s of
[] -> parse q s
res -> res

satisfy :: (Char -> Bool) -> Parser Char
satisfy p = item >>= \c ->
if p c
then return c
else failure

type Predicate a = a -> Bool

notP :: Predicate a -> Predicate a
notP = (not .)

-------------------------------------------------------------------------------
-- Combinators
-------------------------------------------------------------------------------

oneOf :: String -> Parser Char
oneOf s = satisfy (`elem` s)

notOneOf :: String -> Parser Char
notOneOf s = satisfy (notP (`elem` s))

char :: Char -> Parser Char
char c = satisfy (c ==)

string :: String -> Parser String
string [] = return []
string (c:cs) = do { char c; string cs; return (c:cs)}

token :: Parser a -> Parser a
token p = do { a <- p; spaces ; return a}

reserved :: String -> Parser String
reserved s = token (string s)

spaces :: Parser String
spaces = many $ oneOf " \t"

newline :: Parser Char
newline = char '\n'

squareBrackets :: Parser a -> Parser a
squareBrackets m = do
reserved "["
n <- m
reserved "]"
return n

data IniFile
= DesktopEntrySection String
| KeyValues [(String, String)]
deriving Show

keyName :: Parser String
keyName = some (notOneOf "=\n \t")

keyValue :: Parser (String, String)
keyValue = do
key <- keyName
spaces
char '='
spaces
val <- many (notOneOf "\n")
newline
return (key, val)

nonSectionLine :: Parser String
nonSectionLine = do
startChar <- notOneOf "["
otherChar <- many $ notOneOf "\n"
newline
return $ startChar : otherChar

desktopEntrySectionLine :: Parser (Either String String)
desktopEntrySectionLine = do
sectionName <- squareBrackets (string "Desktop Entry")
newline
return $ Right sectionName

badSectionLine :: Parser (Either String String)
badSectionLine = do
startChar <- char '['
otherChar <- many $ notOneOf "\n"
newline
return $ Left $ startChar : otherChar

emptyLine :: Parser ()
emptyLine = do
whitespaceLine <|> commentLine
return ()
where whitespaceLine = spaces >> newline
commentLine = spaces
>> char '#'
>> many (notOneOf "\n")
>> newline

sectionBodyLine :: Parser (Maybe (String, String))
sectionBodyLine = (Just <$> keyValue)
<|> (Nothing <$ emptyLine)


section :: Parser (Either String (String, MAP.Map String String))
section = do
many nonSectionLine
sectionLabel <- desktopEntrySectionLine <|> badSectionLine
keyValsList <- catMaybes <$> many sectionBodyLine
let keyVals = MAP.fromList keyValsList
return $ (,keyVals) <$> sectionLabel

dotDesktopParser :: Parser [Either String (String, MAP.Map String String)]
dotDesktopParser = do
sections <- many section
many nonSectionLine
return sections

runDotDesktopParser :: String -> Either String [Either String (String, MAP.Map String String)]
runDotDesktopParser = runParser dotDesktopParser
2 changes: 2 additions & 0 deletions xmonad-contrib.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ library
XMonad.Prompt.AppendFile
XMonad.Prompt.ConfirmPrompt
XMonad.Prompt.DirExec
XMonad.Prompt.DotDesktop
XMonad.Prompt.DotDesktopParser
XMonad.Prompt.Directory
XMonad.Prompt.Email
XMonad.Prompt.FuzzyMatch
Expand Down