#!/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()