Browse Source

Add basic dmesktop script

Getty Ritter 6 years ago
commit
b765922b0d
1 changed files with 154 additions and 0 deletions
  1. 154 0
      dmesktop.py

+ 154 - 0
dmesktop.py

@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+
+import os
+import re
+import subprocess
+import sys
+from typing import List, Mapping, NamedTuple, Union
+
+# compiled regexes for faux-parsing INI files
+KV = re.compile('([A-Za-z0-9-]*(\[[A-Za-z@_]*\])?)=(.*)')
+SECTION = re.compile('\[([A-Za-z -]*)\]')
+METAVAR = re.compile('%.')
+# default argv for running dmenu
+DMENU_CMD = ['dmenu', '-i', '-l', '10']
+
+# this is probably not right, in the long term!
+XDG_APP_DIRS = [
+    '/usr/share/applications',
+    os.path.join(os.getenv('HOME'), '/.local/share/applications'),
+]
+
+class DesktopEntry(NamedTuple):
+    '''This is our wrapper struct for .desktop files. Right now, we only
+    bother caring about the name, the command to execute, the type,
+    and the other associated actions. Eventually this could be expanded?
+    '''
+    name: str
+    exec: str
+    type: str
+    actions: Mapping[str, 'DesktopEntry']
+
+    @classmethod
+    def from_map(cls, m: Mapping[str, Union[str, Mapping[str, str]]]) -> 'DesktopEntry':
+        '''Constructor function to take the key-value map we create in reading
+        the file and turn it into a DesktopEntry.
+        '''
+        actions = dict(((e['Name'], cls.from_map(e))
+                       for e in m.get('ACTIONS', {}).values()))
+        return cls(
+            m['Name'],
+            m['Exec'],
+            m.get('Type', None),
+            actions,
+        )
+
+    def command(self) -> str:
+        '''Clean out the metavariables we don't care about in
+        the provided Exec field
+        '''
+        return METAVAR.sub('', self.exec)
+
+def main():
+    ensure_dmenu()
+    desktop_entries = get_all_entries()
+
+    # let's only ask about Applications for now, at least
+    all_choices = sorted([key for (key, e)
+                          in desktop_entries.items()
+                          if e.type == 'Application'])
+    choice = dmenu_choose(all_choices)
+    if choice in desktop_entries:
+        try:
+            entry = desktop_entries[choice]
+            if not entry.actions:
+                os.execvp('/bin/sh', ['sh', '-c', entry.command()])
+            else:
+                choice = dmenu_choose(entry.actions.keys())
+                if choice in entry.actions:
+                    entry = entry.actions[choice]
+                    os.execvp('/bin/sh', ['sh', '-c', entry.command()])
+        except Exception as e:
+            # this should be more granular eventually!
+            pass
+
+def dmenu_choose(stuff: List[str]) -> str:
+    '''Given a list of strings, we provide them to dmenu and
+    return back which one the user chose (or an empty string,
+    if the user chose nothing)
+    '''
+    choices = '\n'.join(stuff).encode('utf-8')
+    dmenu = subprocess.Popen(
+        DMENU_CMD,
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE)
+    # we probably shouldn't ignore stderr, but whatevs
+    choice, _ = dmenu.communicate(choices)
+    return choice.decode('utf-8').strip()
+
+def get_all_entries() -> Mapping[str, DesktopEntry]:
+    '''Walk the relevant XDG dirs and parse all the Desktop files we can
+    find, returning them as a map from the Name of the desktop file to
+    the actual contents
+    '''
+    desktop_entries = {}
+
+    # walk all the app dirs
+    for dir in XDG_APP_DIRS:
+        for root, dirs, files in os.walk(dir):
+            # for whatever .desktop files we find
+            for f in files:
+                if f.endswith('.desktop'):
+                    # add 'em to our Name-indexed map of files
+                    entry = parse_entry(os.path.join(root, f))
+                    desktop_entries[entry.name] = entry
+
+    return desktop_entries
+
+def parse_entry(path: str) -> DesktopEntry:
+    '''Read and parse the .desktop file at the provided path
+    '''
+    # the `entry` is the basic key-value bag for the whole file
+    entry = {}
+    # but `current_bag` points to the current section being parsed,
+    # which may or may not be the current overall file
+    current_bag = entry
+    with open(path) as f:
+        for line in f:
+            # this will be non-None if it's of the form `Key=Value`
+            match = KV.match(line)
+            # this will be non-None if it's of the form `[Name]`
+            sect = SECTION.match(line)
+            if match:
+                # if it's a key-value pair, add it to the current bag
+                current_bag[match.group(1)] = match.group(3)
+            elif sect and sect.group(1) != 'Desktop Entry':
+                # if it's a section header, then we ask: is it the
+                # Desktop Entry section, which is the main obligatory
+                # chunk of a desktop file? If so, then we ignore this
+                # chunk entirely. Otherwise: make sure we have an
+                # ACTIONS field
+                actions = entry.get('ACTIONS', {})
+                # create a new key/value map for this new sub-entry
+                current_bag = {}
+                # add the new key/value map to the actions map
+                actions[sect.group(1)] = current_bag
+                # and make sure the ACTIONS key points to that map
+                entry['ACTIONS'] = actions
+
+    # wrap it in in our nice wrapper type, too!
+    return DesktopEntry.from_map(entry)
+
+def ensure_dmenu():
+    '''Shells out to `which` to find out whether dmenu is installed. We
+    won't do anything useful if it's not, after all!
+    '''
+    try:
+        subprocess.check_output(['which', 'dmenu'])
+    except subprocess.CalledProcessError:
+        sys.stderr.write("Error: could not find `dmenu'\n")
+        sys.exit(99)
+
+
+if __name__ == '__main__':
+    main()