gdritter repos dmesktop / b765922
Add basic dmesktop script Getty Ritter 5 years ago
1 changed file(s) with 154 addition(s) and 0 deletion(s). Collapse all Expand all
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()