dmesktop.py 5.4 KB

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