1"""
2Complete implementation of the XDG Desktop Entry Specification
3http://standards.freedesktop.org/desktop-entry-spec/
4
5Not supported:
6- Encoding: Legacy Mixed
7- Does not check exec parameters
8- Does not check URL's
9- Does not completly validate deprecated/kde items
10- Does not completly check categories
11"""
12
13from xdg.IniFile import IniFile, is_ascii
14import xdg.Locale
15from xdg.Exceptions import ParsingError
16from xdg.util import which
17import os.path
18import re
19import warnings
20
21class DesktopEntry(IniFile):
22 "Class to parse and validate Desktop Entries"
23
24 defaultGroup = 'Desktop Entry'
25
26 def __init__(self, filename=None):
27 """Create a new DesktopEntry.
28
29 If filename exists, it will be parsed as a desktop entry file. If not,
30 or if filename is None, a blank DesktopEntry is created.
31 """
32 self.content = dict()
33 if filename and os.path.exists(filename):
34 self.parse(filename)
35 elif filename:
36 self.new(filename)
37
38 def __str__(self):
39 return self.getName()
40
41 def parse(self, file):
42 """Parse a desktop entry file.
43
44 This can raise :class:`~xdg.Exceptions.ParsingError`,
45 :class:`~xdg.Exceptions.DuplicateGroupError` or
46 :class:`~xdg.Exceptions.DuplicateKeyError`.
47 """
48 IniFile.parse(self, file, ["Desktop Entry", "KDE Desktop Entry"])
49
50 def findTryExec(self):
51 """Looks in the PATH for the executable given in the TryExec field.
52
53 Returns the full path to the executable if it is found, None if not.
54 Raises :class:`~xdg.Exceptions.NoKeyError` if TryExec is not present.
55 """
56 tryexec = self.get('TryExec', strict=True)
57 return which(tryexec)
58
59 # start standard keys
60 def getType(self):
61 return self.get('Type')
62 def getVersion(self):
63 """deprecated, use getVersionString instead """
64 return self.get('Version', type="numeric")
65 def getVersionString(self):
66 return self.get('Version')
67 def getName(self):
68 return self.get('Name', locale=True)
69 def getGenericName(self):
70 return self.get('GenericName', locale=True)
71 def getNoDisplay(self):
72 return self.get('NoDisplay', type="boolean")
73 def getComment(self):
74 return self.get('Comment', locale=True)
75 def getIcon(self):
76 return self.get('Icon', locale=True)
77 def getHidden(self):
78 return self.get('Hidden', type="boolean")
79 def getOnlyShowIn(self):
80 return self.get('OnlyShowIn', list=True)
81 def getNotShowIn(self):
82 return self.get('NotShowIn', list=True)
83 def getTryExec(self):
84 return self.get('TryExec')
85 def getExec(self):
86 return self.get('Exec')
87 def getPath(self):
88 return self.get('Path')
89 def getTerminal(self):
90 return self.get('Terminal', type="boolean")
91 def getMimeType(self):
92 """deprecated, use getMimeTypes instead """
93 return self.get('MimeType', list=True, type="regex")
94 def getMimeTypes(self):
95 return self.get('MimeType', list=True)
96 def getCategories(self):
97 return self.get('Categories', list=True)
98 def getStartupNotify(self):
99 return self.get('StartupNotify', type="boolean")
100 def getStartupWMClass(self):
101 return self.get('StartupWMClass')
102 def getURL(self):
103 return self.get('URL')
104 # end standard keys
105
106 # start kde keys
107 def getServiceTypes(self):
108 return self.get('ServiceTypes', list=True)
109 def getDocPath(self):
110 return self.get('DocPath')
111 def getKeywords(self):
112 return self.get('Keywords', list=True, locale=True)
113 def getInitialPreference(self):
114 return self.get('InitialPreference')
115 def getDev(self):
116 return self.get('Dev')
117 def getFSType(self):
118 return self.get('FSType')
119 def getMountPoint(self):
120 return self.get('MountPoint')
121 def getReadonly(self):
122 return self.get('ReadOnly', type="boolean")
123 def getUnmountIcon(self):
124 return self.get('UnmountIcon', locale=True)
125 # end kde keys
126
127 # start deprecated keys
128 def getMiniIcon(self):
129 return self.get('MiniIcon', locale=True)
130 def getTerminalOptions(self):
131 return self.get('TerminalOptions')
132 def getDefaultApp(self):
133 return self.get('DefaultApp')
134 def getProtocols(self):
135 return self.get('Protocols', list=True)
136 def getExtensions(self):
137 return self.get('Extensions', list=True)
138 def getBinaryPattern(self):
139 return self.get('BinaryPattern')
140 def getMapNotify(self):
141 return self.get('MapNotify')
142 def getEncoding(self):
143 return self.get('Encoding')
144 def getSwallowTitle(self):
145 return self.get('SwallowTitle', locale=True)
146 def getSwallowExec(self):
147 return self.get('SwallowExec')
148 def getSortOrder(self):
149 return self.get('SortOrder', list=True)
150 def getFilePattern(self):
151 return self.get('FilePattern', type="regex")
152 def getActions(self):
153 return self.get('Actions', list=True)
154 # end deprecated keys
155
156 # desktop entry edit stuff
157 def new(self, filename):
158 """Make this instance into a new, blank desktop entry.
159
160 If filename has a .desktop extension, Type is set to Application. If it
161 has a .directory extension, Type is Directory. Other extensions will
162 cause :class:`~xdg.Exceptions.ParsingError` to be raised.
163 """
164 if os.path.splitext(filename)[1] == ".desktop":
165 type = "Application"
166 elif os.path.splitext(filename)[1] == ".directory":
167 type = "Directory"
168 else:
169 raise ParsingError("Unknown extension", filename)
170
171 self.content = dict()
172 self.addGroup(self.defaultGroup)
173 self.set("Type", type)
174 self.filename = filename
175 # end desktop entry edit stuff
176
177 # validation stuff
178 def checkExtras(self):
179 # header
180 if self.defaultGroup == "KDE Desktop Entry":
181 self.warnings.append('[KDE Desktop Entry]-Header is deprecated')
182
183 # file extension
184 if self.fileExtension == ".kdelnk":
185 self.warnings.append("File extension .kdelnk is deprecated")
186 elif self.fileExtension != ".desktop" and self.fileExtension != ".directory":
187 self.warnings.append('Unknown File extension')
188
189 # Type
190 try:
191 self.type = self.content[self.defaultGroup]["Type"]
192 except KeyError:
193 self.errors.append("Key 'Type' is missing")
194
195 # Name
196 try:
197 self.name = self.content[self.defaultGroup]["Name"]
198 except KeyError:
199 self.errors.append("Key 'Name' is missing")
200
201 def checkGroup(self, group):
202 # check if group header is valid
203 if not (group == self.defaultGroup \
204 or re.match("^Desktop Action [a-zA-Z0-9-]+$", group) \
205 or (re.match("^X-", group) and is_ascii(group))):
206 self.errors.append("Invalid Group name: %s" % group)
207 else:
208 #OnlyShowIn and NotShowIn
209 if ("OnlyShowIn" in self.content[group]) and ("NotShowIn" in self.content[group]):
210 self.errors.append("Group may either have OnlyShowIn or NotShowIn, but not both")
211
212 def checkKey(self, key, value, group):
213 # standard keys
214 if key == "Type":
215 if value == "ServiceType" or value == "Service" or value == "FSDevice":
216 self.warnings.append("Type=%s is a KDE extension" % key)
217 elif value == "MimeType":
218 self.warnings.append("Type=MimeType is deprecated")
219 elif not (value == "Application" or value == "Link" or value == "Directory"):
220 self.errors.append("Value of key 'Type' must be Application, Link or Directory, but is '%s'" % value)
221
222 if self.fileExtension == ".directory" and not value == "Directory":
223 self.warnings.append("File extension is .directory, but Type is '%s'" % value)
224 elif self.fileExtension == ".desktop" and value == "Directory":
225 self.warnings.append("Files with Type=Directory should have the extension .directory")
226
227 if value == "Application":
228 if "Exec" not in self.content[group]:
229 self.warnings.append("Type=Application needs 'Exec' key")
230 if value == "Link":
231 if "URL" not in self.content[group]:
232 self.warnings.append("Type=Link needs 'URL' key")
233
234 elif key == "Version":
235 self.checkValue(key, value)
236
237 elif re.match("^Name"+xdg.Locale.regex+"$", key):
238 pass # locale string
239
240 elif re.match("^GenericName"+xdg.Locale.regex+"$", key):
241 pass # locale string
242
243 elif key == "NoDisplay":
244 self.checkValue(key, value, type="boolean")
245
246 elif re.match("^Comment"+xdg.Locale.regex+"$", key):
247 pass # locale string
248
249 elif re.match("^Icon"+xdg.Locale.regex+"$", key):
250 self.checkValue(key, value)
251
252 elif key == "Hidden":
253 self.checkValue(key, value, type="boolean")
254
255 elif key == "OnlyShowIn":
256 self.checkValue(key, value, list=True)
257 self.checkOnlyShowIn(value)
258
259 elif key == "NotShowIn":
260 self.checkValue(key, value, list=True)
261 self.checkOnlyShowIn(value)
262
263 elif key == "TryExec":
264 self.checkValue(key, value)
265 self.checkType(key, "Application")
266
267 elif key == "Exec":
268 self.checkValue(key, value)
269 self.checkType(key, "Application")
270
271 elif key == "Path":
272 self.checkValue(key, value)
273 self.checkType(key, "Application")
274
275 elif key == "Terminal":
276 self.checkValue(key, value, type="boolean")
277 self.checkType(key, "Application")
278
279 elif key == "Actions":
280 self.checkValue(key, value, list=True)
281 self.checkType(key, "Application")
282
283 elif key == "MimeType":
284 self.checkValue(key, value, list=True)
285 self.checkType(key, "Application")
286
287 elif key == "Categories":
288 self.checkValue(key, value)
289 self.checkType(key, "Application")
290 self.checkCategories(value)
291
292 elif re.match("^Keywords"+xdg.Locale.regex+"$", key):
293 self.checkValue(key, value, type="localestring", list=True)
294 self.checkType(key, "Application")
295
296 elif key == "StartupNotify":
297 self.checkValue(key, value, type="boolean")
298 self.checkType(key, "Application")
299
300 elif key == "StartupWMClass":
301 self.checkType(key, "Application")
302
303 elif key == "URL":
304 self.checkValue(key, value)
305 self.checkType(key, "URL")
306
307 # kde extensions
308 elif key == "ServiceTypes":
309 self.checkValue(key, value, list=True)
310 self.warnings.append("Key '%s' is a KDE extension" % key)
311
312 elif key == "DocPath":
313 self.checkValue(key, value)
314 self.warnings.append("Key '%s' is a KDE extension" % key)
315
316 elif key == "InitialPreference":
317 self.checkValue(key, value, type="numeric")
318 self.warnings.append("Key '%s' is a KDE extension" % key)
319
320 elif key == "Dev":
321 self.checkValue(key, value)
322 self.checkType(key, "FSDevice")
323 self.warnings.append("Key '%s' is a KDE extension" % key)
324
325 elif key == "FSType":
326 self.checkValue(key, value)
327 self.checkType(key, "FSDevice")
328 self.warnings.append("Key '%s' is a KDE extension" % key)
329
330 elif key == "MountPoint":
331 self.checkValue(key, value)
332 self.checkType(key, "FSDevice")
333 self.warnings.append("Key '%s' is a KDE extension" % key)
334
335 elif key == "ReadOnly":
336 self.checkValue(key, value, type="boolean")
337 self.checkType(key, "FSDevice")
338 self.warnings.append("Key '%s' is a KDE extension" % key)
339
340 elif re.match("^UnmountIcon"+xdg.Locale.regex+"$", key):
341 self.checkValue(key, value)
342 self.checkType(key, "FSDevice")
343 self.warnings.append("Key '%s' is a KDE extension" % key)
344
345 # deprecated keys
346 elif key == "Encoding":
347 self.checkValue(key, value)
348 self.warnings.append("Key '%s' is deprecated" % key)
349
350 elif re.match("^MiniIcon"+xdg.Locale.regex+"$", key):
351 self.checkValue(key, value)
352 self.warnings.append("Key '%s' is deprecated" % key)
353
354 elif key == "TerminalOptions":
355 self.checkValue(key, value)
356 self.warnings.append("Key '%s' is deprecated" % key)
357
358 elif key == "DefaultApp":
359 self.checkValue(key, value)
360 self.warnings.append("Key '%s' is deprecated" % key)
361
362 elif key == "Protocols":
363 self.checkValue(key, value, list=True)
364 self.warnings.append("Key '%s' is deprecated" % key)
365
366 elif key == "Extensions":
367 self.checkValue(key, value, list=True)
368 self.warnings.append("Key '%s' is deprecated" % key)
369
370 elif key == "BinaryPattern":
371 self.checkValue(key, value)
372 self.warnings.append("Key '%s' is deprecated" % key)
373
374 elif key == "MapNotify":
375 self.checkValue(key, value)
376 self.warnings.append("Key '%s' is deprecated" % key)
377
378 elif re.match("^SwallowTitle"+xdg.Locale.regex+"$", key):
379 self.warnings.append("Key '%s' is deprecated" % key)
380
381 elif key == "SwallowExec":
382 self.checkValue(key, value)
383 self.warnings.append("Key '%s' is deprecated" % key)
384
385 elif key == "FilePattern":
386 self.checkValue(key, value, type="regex", list=True)
387 self.warnings.append("Key '%s' is deprecated" % key)
388
389 elif key == "SortOrder":
390 self.checkValue(key, value, list=True)
391 self.warnings.append("Key '%s' is deprecated" % key)
392
393 # "X-" extensions
394 elif re.match("^X-[a-zA-Z0-9-]+", key):
395 pass
396
397 else:
398 self.errors.append("Invalid key: %s" % key)
399
400 def checkType(self, key, type):
401 if not self.getType() == type:
402 self.errors.append("Key '%s' only allowed in Type=%s" % (key, type))
403
404 def checkOnlyShowIn(self, value):
405 values = self.getList(value)
406 valid = ["GNOME", "KDE", "LXDE", "MATE", "Razor", "ROX", "TDE", "Unity",
407 "XFCE", "Old"]
408 for item in values:
409 if item not in valid and item[0:2] != "X-":
410 self.errors.append("'%s' is not a registered OnlyShowIn value" % item);
411
412 def checkCategories(self, value):
413 values = self.getList(value)
414
415 main = ["AudioVideo", "Audio", "Video", "Development", "Education", "Game", "Graphics", "Network", "Office", "Science", "Settings", "System", "Utility"]
416 if not any(item in main for item in values):
417 self.errors.append("Missing main category")
418
419 additional = ['Building', 'Debugger', 'IDE', 'GUIDesigner', 'Profiling', 'RevisionControl', 'Translation', 'Calendar', 'ContactManagement', 'Database', 'Dictionary', 'Chart', 'Email', 'Finance', 'FlowChart', 'PDA', 'ProjectManagement', 'Presentation', 'Spreadsheet', 'WordProcessor', '2DGraphics', 'VectorGraphics', 'RasterGraphics', '3DGraphics', 'Scanning', 'OCR', 'Photography', 'Publishing', 'Viewer', 'TextTools', 'DesktopSettings', 'HardwareSettings', 'Printing', 'PackageManager', 'Dialup', 'InstantMessaging', 'Chat', 'IRCClient', 'Feed', 'FileTransfer', 'HamRadio', 'News', 'P2P', 'RemoteAccess', 'Telephony', 'TelephonyTools', 'VideoConference', 'WebBrowser', 'WebDevelopment', 'Midi', 'Mixer', 'Sequencer', 'Tuner', 'TV', 'AudioVideoEditing', 'Player', 'Recorder', 'DiscBurning', 'ActionGame', 'AdventureGame', 'ArcadeGame', 'BoardGame', 'BlocksGame', 'CardGame', 'KidsGame', 'LogicGame', 'RolePlaying', 'Shooter', 'Simulation', 'SportsGame', 'StrategyGame', 'Art', 'Construction', 'Music', 'Languages', 'ArtificialIntelligence', 'Astronomy', 'Biology', 'Chemistry', 'ComputerScience', 'DataVisualization', 'Economy', 'Electricity', 'Geography', 'Geology', 'Geoscience', 'History', 'Humanities', 'ImageProcessing', 'Literature', 'Maps', 'Math', 'NumericalAnalysis', 'MedicalSoftware', 'Physics', 'Robotics', 'Spirituality', 'Sports', 'ParallelComputing', 'Amusement', 'Archiving', 'Compression', 'Electronics', 'Emulator', 'Engineering', 'FileTools', 'FileManager', 'TerminalEmulator', 'Filesystem', 'Monitor', 'Security', 'Accessibility', 'Calculator', 'Clock', 'TextEditor', 'Documentation', 'Adult', 'Core', 'KDE', 'GNOME', 'XFCE', 'GTK', 'Qt', 'Motif', 'Java', 'ConsoleOnly']
420 allcategories = additional + main
421
422 for item in values:
423 if item not in allcategories and not item.startswith("X-"):
424 self.errors.append("'%s' is not a registered Category" % item);
425
426 def checkCategorie(self, value):
427 """Deprecated alias for checkCategories - only exists for backwards
428 compatibility.
429 """
430 warnings.warn("checkCategorie is deprecated, use checkCategories",
431 DeprecationWarning)
432 return self.checkCategories(value)
433