Add basic dmesktop script
Getty Ritter
6 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() |