Add basic dmesktop script
Getty Ritter
7 years ago
| 1 | #!/usr/bin/env python3 | |
| 2 | ||
| 3 | import os | |
| 4 | import re | |
| 5 | import subprocess | |
| 6 | import sys | |
| 7 | from typing import List, Mapping, NamedTuple, Union | |
| 8 | ||
| 9 | # compiled regexes for faux-parsing INI files | |
| 10 | KV = re.compile('([A-Za-z0-9-]*(\[[A-Za-z@_]*\])?)=(.*)') | |
| 11 | SECTION = re.compile('\[([A-Za-z -]*)\]') | |
| 12 | METAVAR = re.compile('%.') | |
| 13 | # default argv for running dmenu | |
| 14 | DMENU_CMD = ['dmenu', '-i', '-l', '10'] | |
| 15 | ||
| 16 | # this is probably not right, in the long term! | |
| 17 | XDG_APP_DIRS = [ | |
| 18 | '/usr/share/applications', | |
| 19 | os.path.join(os.getenv('HOME'), '/.local/share/applications'), | |
| 20 | ] | |
| 21 | ||
| 22 | class DesktopEntry(NamedTuple): | |
| 23 | '''This is our wrapper struct for .desktop files. Right now, we only | |
| 24 | bother caring about the name, the command to execute, the type, | |
| 25 | and the other associated actions. Eventually this could be expanded? | |
| 26 | ''' | |
| 27 | name: str | |
| 28 | exec: str | |
| 29 | type: str | |
| 30 | actions: Mapping[str, 'DesktopEntry'] | |
| 31 | ||
| 32 | @classmethod | |
| 33 | def from_map(cls, m: Mapping[str, Union[str, Mapping[str, str]]]) -> 'DesktopEntry': | |
| 34 | '''Constructor function to take the key-value map we create in reading | |
| 35 | the file and turn it into a DesktopEntry. | |
| 36 | ''' | |
| 37 | actions = dict(((e['Name'], cls.from_map(e)) | |
| 38 | for e in m.get('ACTIONS', {}).values())) | |
| 39 | return cls( | |
| 40 | m['Name'], | |
| 41 | m['Exec'], | |
| 42 | m.get('Type', None), | |
| 43 | actions, | |
| 44 | ) | |
| 45 | ||
| 46 | def command(self) -> str: | |
| 47 | '''Clean out the metavariables we don't care about in | |
| 48 | the provided Exec field | |
| 49 | ''' | |
| 50 | return METAVAR.sub('', self.exec) | |
| 51 | ||
| 52 | def main(): | |
| 53 | ensure_dmenu() | |
| 54 | desktop_entries = get_all_entries() | |
| 55 | ||
| 56 | # let's only ask about Applications for now, at least | |
| 57 | all_choices = sorted([key for (key, e) | |
| 58 | in desktop_entries.items() | |
| 59 | if e.type == 'Application']) | |
| 60 | choice = dmenu_choose(all_choices) | |
| 61 | if choice in desktop_entries: | |
| 62 | try: | |
| 63 | entry = desktop_entries[choice] | |
| 64 | if not entry.actions: | |
| 65 | os.execvp('/bin/sh', ['sh', '-c', entry.command()]) | |
| 66 | else: | |
| 67 | choice = dmenu_choose(entry.actions.keys()) | |
| 68 | if choice in entry.actions: | |
| 69 | entry = entry.actions[choice] | |
| 70 | os.execvp('/bin/sh', ['sh', '-c', entry.command()]) | |
| 71 | except Exception as e: | |
| 72 | # this should be more granular eventually! | |
| 73 | pass | |
| 74 | ||
| 75 | def dmenu_choose(stuff: List[str]) -> str: | |
| 76 | '''Given a list of strings, we provide them to dmenu and | |
| 77 | return back which one the user chose (or an empty string, | |
| 78 | if the user chose nothing) | |
| 79 | ''' | |
| 80 | choices = '\n'.join(stuff).encode('utf-8') | |
| 81 | dmenu = subprocess.Popen( | |
| 82 | DMENU_CMD, | |
| 83 | stdin=subprocess.PIPE, | |
| 84 | stdout=subprocess.PIPE) | |
| 85 | # we probably shouldn't ignore stderr, but whatevs | |
| 86 | choice, _ = dmenu.communicate(choices) | |
| 87 | return choice.decode('utf-8').strip() | |
| 88 | ||
| 89 | def get_all_entries() -> Mapping[str, DesktopEntry]: | |
| 90 | '''Walk the relevant XDG dirs and parse all the Desktop files we can | |
| 91 | find, returning them as a map from the Name of the desktop file to | |
| 92 | the actual contents | |
| 93 | ''' | |
| 94 | desktop_entries = {} | |
| 95 | ||
| 96 | # walk all the app dirs | |
| 97 | for dir in XDG_APP_DIRS: | |
| 98 | for root, dirs, files in os.walk(dir): | |
| 99 | # for whatever .desktop files we find | |
| 100 | for f in files: | |
| 101 | if f.endswith('.desktop'): | |
| 102 | # add 'em to our Name-indexed map of files | |
| 103 | entry = parse_entry(os.path.join(root, f)) | |
| 104 | desktop_entries[entry.name] = entry | |
| 105 | ||
| 106 | return desktop_entries | |
| 107 | ||
| 108 | def parse_entry(path: str) -> DesktopEntry: | |
| 109 | '''Read and parse the .desktop file at the provided path | |
| 110 | ''' | |
| 111 | # the `entry` is the basic key-value bag for the whole file | |
| 112 | entry = {} | |
| 113 | # but `current_bag` points to the current section being parsed, | |
| 114 | # which may or may not be the current overall file | |
| 115 | current_bag = entry | |
| 116 | with open(path) as f: | |
| 117 | for line in f: | |
| 118 | # this will be non-None if it's of the form `Key=Value` | |
| 119 | match = KV.match(line) | |
| 120 | # this will be non-None if it's of the form `[Name]` | |
| 121 | sect = SECTION.match(line) | |
| 122 | if match: | |
| 123 | # if it's a key-value pair, add it to the current bag | |
| 124 | current_bag[match.group(1)] = match.group(3) | |
| 125 | elif sect and sect.group(1) != 'Desktop Entry': | |
| 126 | # if it's a section header, then we ask: is it the | |
| 127 | # Desktop Entry section, which is the main obligatory | |
| 128 | # chunk of a desktop file? If so, then we ignore this | |
| 129 | # chunk entirely. Otherwise: make sure we have an | |
| 130 | # ACTIONS field | |
| 131 | actions = entry.get('ACTIONS', {}) | |
| 132 | # create a new key/value map for this new sub-entry | |
| 133 | current_bag = {} | |
| 134 | # add the new key/value map to the actions map | |
| 135 | actions[sect.group(1)] = current_bag | |
| 136 | # and make sure the ACTIONS key points to that map | |
| 137 | entry['ACTIONS'] = actions | |
| 138 | ||
| 139 | # wrap it in in our nice wrapper type, too! | |
| 140 | return DesktopEntry.from_map(entry) | |
| 141 | ||
| 142 | def ensure_dmenu(): | |
| 143 | '''Shells out to `which` to find out whether dmenu is installed. We | |
| 144 | won't do anything useful if it's not, after all! | |
| 145 | ''' | |
| 146 | try: | |
| 147 | subprocess.check_output(['which', 'dmenu']) | |
| 148 | except subprocess.CalledProcessError: | |
| 149 | sys.stderr.write("Error: could not find `dmenu'\n") | |
| 150 | sys.exit(99) | |
| 151 | ||
| 152 | ||
| 153 | if __name__ == '__main__': | |
| 154 | main() |