From 9aa08f1f7ba8a33be37eb9a582a8070014215e14 Mon Sep 17 00:00:00 2001 From: Jaap Karssenberg Date: Sat, 30 Jan 2021 20:34:10 +0100 Subject: [PATCH] Update default application lookup to latest spec Proper support of default applicaitons according to xdg spec https://specifications.freedesktop.org/mime-apps-spec/latest/ Should give better inoperability on linux desktops Fixes #657 --- data/manual/Help/Default_Applications.txt | 10 +- tests/applications.py | 178 ++++++++++++++++- zim/gui/applications.py | 220 +++++++++++++++------- zim/gui/customtools.py | 31 ++- 4 files changed, 360 insertions(+), 79 deletions(-) diff --git a/data/manual/Help/Default_Applications.txt b/data/manual/Help/Default_Applications.txt index df7d46438..3947f4371 100644 --- a/data/manual/Help/Default_Applications.txt +++ b/data/manual/Help/Default_Applications.txt @@ -9,14 +9,14 @@ Zim opens files and attachments with other applications. Usually clicking on a l If you want to change the default application, or you want to add additional applications to the "//Open With...//" menu, there is a menu item "//Customize...//" at the bottom of the popup. This item brings up a dialog that allows changing the default application and adding new applications. ===== Configure Applications Dialog ===== -The //Configure Applications //dialog has a drop down menu to choice a default application. Applications zim knows about are shown in this drop down. Also there is a special menu item "**System Default**" which means to use whatever application is the default used by the operating system. +The //Configure Applications //dialog has a drop down menu to choice a default application. Applications zim knows about are shown in this drop down. Also there is a special menu item "**System Default**" which means to use whatever application is the default used by the operating system. Note that there may be applications installed that zim does not know about. Setting the default to "**System Default**" may result in the correct application being used, even if it is not shown in the list. To add applications to the list (and to the "//Open With...//" menu) click the button "**Add Application**", this will bring up the //Add New Application// dialog. ===== Add Application Dialog ===== -The //Add Application// dialog allows to add a new application for a specific file type. +The //Add Application// dialog allows to add a new application for a specific file type. The **Name** is the application name and **Command** is the command to execute. Usually this is just the name of an executable or a script to execute. The command can also use the following special codes: @@ -31,9 +31,11 @@ If "**Make default application**" is enabled the new application will become the ===== Technical Details ===== -Zim uses the XDG Desktop Entry spec to store and retrieve application informations. The default application per mimetype is stored in a file in the XDG_DATA_HOME folder, typically ''~/.local/share/applications/defaults.list'' . This file with defaults is not part of the spec, but it seems to be in line with the implementation for the Gnome and KDE desktop environments. +Zim uses the XDG Desktop Entry spec to store and retrieve application informations. The default application per mimetype is stored in a file in the ''XDG_CONFIG_HOME'' folder, typically ''~/.config/mimeapps.list'' according to the "mime-apps-spec". In the same file you can also +add or remove (blacklist) applications to be shown in the "Open With" menu for a given +mimetype. -To populate the "//Open With...//" menu zim searches the ''XDG_DATA_HOME/share/applications/'' and ''XDG_DATA_DIRS/share/applications/'' folders for ''.desktop'' files that list the specific mimetype. As an optimization we assume a file "''mimeinfo.cache''" to be present that lists applications entries by mimetype +To populate the "//Open With...//" menu first the ''mimeapps.list'' is read. In addtion zim searches the ''XDG_DATA_HOME/share/applications/'' and ''XDG_DATA_DIRS/share/applications/'' folders for a "''mimeinfo.cache''" file which lists applications per mimetype. Applications are configured via ''.desktop'' files that should reside in the same folder as the cache file. When the user adds a new application zim creates a new ''.desktop'' file in the XDG_DATA_HOME folder and updates the cache. Next time it lists applications for a specific type, this entry will show up. For a new default application we also update ''defaults.list'', but the desktop entry has "''NoDisplay''" set, so it is hidden from the menu. diff --git a/tests/applications.py b/tests/applications.py index ad3d02701..1c3efca5f 100644 --- a/tests/applications.py +++ b/tests/applications.py @@ -13,6 +13,7 @@ from gi.repository import Gtk from zim.gui.applications import * +from zim.gui.applications import _create_application from zim.notebook import Path from zim.fs import Dir, TmpFile @@ -124,6 +125,20 @@ def testPythonCmd(self): @tests.slowTest class TestApplicationManager(tests.TestCase): + def tearDown(self): + ApplicationManager._defaults_app_cache.clear() + + def remove_file(path): + #print("REMOVE", path) + assert path.startswith(tests.TMPDIR) + if os.path.exists(path): + os.unlink(path) + + remove_file(XDG_CONFIG_HOME.file('mimeapps.list').path) + dir = XDG_DATA_HOME.subdir('applications') + for basename in dir.list(): + remove_file(dir.file(basename).path) + def testGetMimeType(self): for obj, mimetype in ( (File('file.txt'), 'text/plain'), @@ -135,7 +150,7 @@ def testGetMimeType(self): ): self.assertEqual(get_mimetype(obj), mimetype) - def testGetSetApplications(self): + def testGetSetListApplications(self): # Typically a system will have multiple programs installed for # text/plain and text/html, but do not rely on them for # testing, so create our own first to test. @@ -153,8 +168,23 @@ def testGetSetApplications(self): self.assertFalse(entry['Desktop Entry']['NoDisplay']) ## Test Set & Get Default - defaults = XDG_DATA_HOME.file('applications/defaults.list') - self.assertFalse(defaults.exists()) + defaults = XDG_CONFIG_HOME.file('mimeapps.list') + self.assertEqual( + defaults.read(), + '[Added Associations]\n' + 'text/plain=test_entry_text-usercreated.desktop\n' + 'text/html=test_entry_html-usercreated.desktop\n' + 'x-scheme-handler/ssh=test_entry_ssh-usercreated.desktop\n' + ) + + cache = XDG_DATA_HOME.file('applications/mimeinfo.cache') + self.assertEqual( + cache.read(), + '[MIME Cache]\n' + 'text/plain=test_entry_text-usercreated.desktop\n' + 'text/html=test_entry_html-usercreated.desktop\n' + 'x-scheme-handler/ssh=test_entry_ssh-usercreated.desktop\n' + ) default = manager.get_default_application('text/plain') self.assertIsInstance(default, (None.__class__, DesktopEntryFile)) @@ -165,6 +195,11 @@ def testGetSetApplications(self): self.assertTrue(defaults.exists()) self.assertEqual(defaults.read(), + '[Added Associations]\n' + 'text/plain=test_entry_text-usercreated.desktop\n' + 'text/html=test_entry_html-usercreated.desktop\n' + 'x-scheme-handler/ssh=test_entry_ssh-usercreated.desktop\n' + '\n' '[Default Applications]\n' 'text/plain=test_entry_text-usercreated.desktop\n' ) @@ -172,6 +207,11 @@ def testGetSetApplications(self): manager.set_default_application('text/plain', None) self.assertEqual(defaults.read(), + '[Added Associations]\n' + 'text/plain=test_entry_text-usercreated.desktop\n' + 'text/html=test_entry_html-usercreated.desktop\n' + 'x-scheme-handler/ssh=test_entry_ssh-usercreated.desktop\n' + '\n' '[Default Applications]\n' ) self.assertNotEqual(manager.get_default_application('text/plain'), entry_text) @@ -198,6 +238,138 @@ def testGetSetApplications(self): self.assertIsInstance(manager.get_application('startfile'), StartFile) self.assertIsNone(manager.get_application('non_existing_application')) + def testSetGetWithoutCache(self): + manager = ApplicationManager() + entry_text = manager.create('text/plain', 'Test_Entry_Text', 'test_text 123', NoDisplay=False) + manager.set_default_application('text/plain', entry_text) + self.assertEqual(manager.get_default_application('text/plain'), entry_text) + manager._defaults_app_cache.clear() + self.assertEqual(manager.get_default_application('text/plain'), entry_text) + + def testSetGetForMimeappsWithMultipleSections(self): + # Make sure we respect the section when writing + defaults_file = XDG_CONFIG_HOME.file('mimeapps.list') + defaults_file.write( + '[Default Applications]\n' + 'text/html=foo.desktop\n' + '\n' + '[Added Associations]\n' + 'text/plain=bar.desktop\n' + ) + manager = ApplicationManager() + entry_text = manager.create('text/plain', 'Test_Entry_Text', 'test_text 123', NoDisplay=False) + manager.set_default_application('text/plain', entry_text) + self.assertEqual( + defaults_file.read(), + '[Default Applications]\n' + 'text/html=foo.desktop\n' + 'text/plain=test_entry_text-usercreated.desktop\n' + '\n' + '[Added Associations]\n' + 'text/plain=test_entry_text-usercreated.desktop;bar.desktop\n' + ) + + def testSupportDesktopMimeappsList(self): + orig_desktop = os.environ.get('XDG_CURRENT_DESKTOP') + def restore_desktop(): + os.environ['XDG_CURRENT_DESKTOP'] = orig_desktop + self.addCleanup(restore_desktop) + + os.environ['XDG_CURRENT_DESKTOP'] = 'Test' + desktopfile = XDG_CONFIG_HOME.file('Test-mimeapps.list') + defaultfile = XDG_CONFIG_HOME.file('mimeapps.list') + + dir = XDG_DATA_HOME.subdir('applications') + for basename in ('desktop-foo.desktop', 'normal-foo.desktop', 'ignore_this.desktop'): + _create_application(dir, basename, 'test', 'test') + + desktopfile.write( + '[Default Applications]\n' + 'text/plain=desktop-foo.desktop\n' + '\n' + '[Removed Associations]\n' + 'text/html=ignore_this.desktop\n' + ) + defaultfile.write( + '[Default Applications]\n' + 'text/plain=normal-foo.desktop\n' + 'text/html=ignore_this.desktop\n' + '\n' + ) + manager = ApplicationManager() + + # Test default picked up from desktop file + self.assertEqual(manager.get_default_application('text/plain').key, 'desktop-foo') + + # Test blacklist in effect + self.assertNotIn('ignore_this', manager.list_applications('text/plain')) + + def testSupportBackwardDefaultsList(self): + defaultfile = XDG_CONFIG_HOME.file('mimeapps.list') + backwardfile = XDG_DATA_HOME.file('applications/defaults.list') + + dir = XDG_DATA_HOME.subdir('applications') + for basename in ('foo.desktop', 'bar.desktop', 'ignore_this.desktop'): + _create_application(dir, basename, 'test', 'test') + + defaultfile.write( + '[Default Applications]\n' + 'text/html=bar.desktop\n' + '\n' + ) + backwardfile.write( + '[Default Applications]\n' + 'text/plain=foo.desktop\n' + 'text/html=ignore_this.desktop\n' + '\n' + ) + + manager = ApplicationManager() + self.assertEqual(manager.get_default_application('text/plain').key, 'foo') + self.assertEqual(manager.get_default_application('text/html').key, 'bar') + + def testListApplications(self): + defaultfile = XDG_CONFIG_HOME.file('mimeapps.list') + cachefile = XDG_DATA_HOME.file('applications/mimeinfo.cache') + + dir = XDG_DATA_HOME.subdir('applications') + for basename in ('aaa.desktop', 'bbb.desktop', 'ccc.desktop', 'ddd.desktop', 'ignore_this.desktop', 'browser.desktop'): + _create_application(dir, basename, 'test', 'test', NoDisplay=False) + _create_application(dir, 'do_not_list.desktop', 'test', 'test', NoDisplay=True) + + defaultfile.write( + '[Default Applications]\n' + 'text/plain=aaa.desktop\n' + 'text/html=browser.desktop\n' + '\n' + '[Added Associations]\n' + 'text/plain=bbb.desktop;ccc.desktop;do_not_list.desktop\n' + '\n' + '[Removed Associations]\n' + 'text/plain=ignore_this.desktop\n' + ) + cachefile.write( + '[MIME Cache]\n' + 'text/plain=ddd.desktop;ignore_this.desktop\n' + ) + + manager = ApplicationManager() + self.assertEqual( + [e.key for e in manager.list_applications('text/plain')], + ['aaa', 'bbb', 'ccc', 'ddd'] + ) + + # Test url scheme also falls back to text/html + self.assertEqual( + [e.key for e in manager.list_applications('text/html')], + ['browser'] + ) + self.assertEqual( + [e.key for e in manager.list_applications('x-scheme-handler/http')], + ['browser'] + ) + + #~ class TestOpenWithMenu(tests.TestCase): class Foo(object): # FIXME - this test blocks on full test runs ?? diff --git a/zim/gui/applications.py b/zim/gui/applications.py index 11c29f019..3287bb21d 100644 --- a/zim/gui/applications.py +++ b/zim/gui/applications.py @@ -23,7 +23,8 @@ import zim.fs from zim.fs import File, Dir, TmpFile, cleanup_filename -from zim.config import XDG_DATA_HOME, XDG_DATA_DIRS, data_dirs, SectionedConfigDict, INIConfigFile +from zim.config import XDG_CONFIG_HOME, XDG_CONFIG_DIRS, XDG_DATA_HOME, XDG_DATA_DIRS, \ + data_dirs, SectionedConfigDict, INIConfigFile from zim.parsing import split_quoted_strings, uri_scheme from zim.applications import Application, WebBrowser, StartFile from zim.gui.widgets import Dialog, ErrorDialog, MessageDialog, strip_boolean_result @@ -61,22 +62,18 @@ def _application_dirs(): yield dir.subdir('applications') -def _create_application(dir, Name, Exec, klass=None, NoDisplay=True, **param): - n = cleanup_filename(Name.lower()) + '-usercreated' - key = n - file = dir.file(key + '.desktop') +def _create_application(dir, basename, Name, Exec, NoDisplay=True, **param): + file = dir.file(basename) i = 0 while file.exists(): assert i < 1000, 'BUG: Infinite loop ?' i += 1 - key = n + '-' + str(i) - file = dir.file(key + '.desktop') + basename = basename[:-8] + '-' + str(i) + '.desktop' + file = dir.file(basename) - if klass is None: - klass = DesktopEntryFile - entry = klass(file) + entry = DesktopEntryFile(file) entry.update( - Type=param.pop('Type', 'Application'), + Type='Application', Version=1.0, NoDisplay=NoDisplay, Name=Name, @@ -88,22 +85,15 @@ def _create_application(dir, Name, Exec, klass=None, NoDisplay=True, **param): entry.write() if param.get('MimeType'): - # Update mimetype cache - cache = dir.file('mimeinfo.cache') - if not cache.exists(): - lines = ['[MIME Cache]\n'] - else: - lines = cache.readlines() - mimetype = param.get('MimeType') - for i, line in enumerate(lines): - if line.startswith(mimetype + '='): - lines[i] = line.strip() + ';' + key + '.desktop\n' - break - else: - lines.append(mimetype + '=' + key + '.desktop\n') - cache.writelines(lines) + # Update mimeapps + defaults = XDG_CONFIG_HOME.file('mimeapps.list') + _update_mimeapps_file(defaults, '[Added Associations]', mimetype, basename, replace=False) + + # Update mimetype cache + cache = dir.file('mimeinfo.cache') + _update_mimeapps_file(cache, '[MIME Cache]', mimetype, basename, replace=False) return entry @@ -211,6 +201,49 @@ def _read_comment_from(file): else: return None +def _update_mimeapps_file(file, section, key, value, replace): + assert section.startswith('[') + assert not (not value and not replace) + assert value is None or value.endswith('.desktop') + key = key + '=' + + if file.exists(): + lines = file.readlines() + else: + lines = [section + '\n'] + + insert_location = None + in_section = False + for i, line in enumerate(lines): + if line.startswith(section): + in_section = True + insert_location = i + 1 + elif in_section: + if line.startswith(key): + if replace: + if value: + lines[i] = key + value + '\n' + else: + lines[i] = '' + else: + lines[i] = key + value + ';' + lines[i][len(key):] + break + elif line.startswith('['): + in_section = False + elif not line.isspace(): + insert_location = i + 1 + else: + pass # empty lines + else: + if value: + if insert_location is None: # section not found - add it + lines.extend(['\n', section + '\n', key + value + '\n']) + else: + lines.insert(insert_location, key + value + '\n') + + file.writelines(lines) + + class ApplicationManager(object): '''Manager object for dealing with desktop applications. Uses the @@ -218,6 +251,8 @@ class ApplicationManager(object): installed applications. ''' + _defaults_app_cache = {} + @staticmethod def get_application(name): '''Get an application by name. Will search installed ".desktop" @@ -249,30 +284,61 @@ def get_default_application(klass, mimetype): @param mimetype: the mime-type of the file (e.g. "text/html") @returns: an L{Application} object or C{None} ''' - ## Based on logic from xdg-mime defapp_generic() - ## Obtained from http://portland.freedesktop.org/wiki/ (2012-05-31) - ## - ## Considered calling xdg-mime directly with code below as fallback. - ## But xdg-mime has only a special case for KDE, all others are generic. - ## Our purpose is to be able to set defaults ourselves and read them back. - ## If we fail we fallback to opening files with the file browser which - ## defaults to xdg-open. So even if the system does not support the - ## generic implementation, it will behave sanely and fall back to system - ## defaults. + # TODO: also check timestamp of mimeapps for validity of the cache ? + if mimetype in klass._defaults_app_cache \ + and klass._defaults_app_cache[mimetype] is not None \ + and not klass._defaults_app_cache[mimetype].file.exists(): + del klass._defaults_app_cache[mimetype] - ## TODO: optimize for being called very often ? + if mimetype not in klass._defaults_app_cache: + klass._defaults_app_cache[mimetype] = klass._get_default_application(mimetype) - for dir in _application_dirs(): - default_file = dir.file('defaults.list') - if not default_file.exists(): - continue + return klass._defaults_app_cache[mimetype] + @staticmethod + def _mimeapps_files(): + desktops = [] + if os.environ.get('XDG_CURRENT_DESKTOP'): + desktops = os.environ['XDG_CURRENT_DESKTOP'].split(';') + folders = [XDG_CONFIG_HOME] + XDG_CONFIG_DIRS + folders += [f.subdir('applications') for f in [XDG_DATA_HOME] + XDG_DATA_DIRS] + + for folder in folders: + for desktop in desktops: + file = folder.file('%s-mimeapps.list' % desktop) + if file.exists(): + yield file + + file = folder.file('mimeapps.list') + if file.exists(): + yield file + + for folder in _application_dirs(): + file = folder.file('defaults.list') + if file.exists(): + yield file + + @classmethod + def _get_default_application(klass, mimetype): + ## Based on https://specifications.freedesktop.org/mime-apps-spec/latest/ + ## Version 1.0 dated 2 April 2014 + ## + ## Implemented from scratch to ensure platform independent support + ## + ## Kept "defaults.list" file support for backward compatibility + ## when changing zim versions. + + for default_file in klass._mimeapps_files(): + in_default_section = False for line in default_file.readlines(): - if line.startswith(mimetype + '='): + if line.startswith('[Default Applications]'): + in_default_section = True + elif in_default_section and line.startswith('['): + break # new group, look no further + + if in_default_section and line.startswith(mimetype + '='): _, key = line.strip().split('=', 1) for k in key.split(';'): - # Copied logic from xdg-mime, apparently entries - # can be ";" seperated lists k = k.strip() application = klass.get_application(k) if application is not None: @@ -281,8 +347,8 @@ def get_default_application(klass, mimetype): else: return None - @staticmethod - def set_default_application(mimetype, application): + @classmethod + def set_default_application(klass, mimetype, application): '''Set the default application to open a file with a specific mimetype. Updates the C{applications/defaults.list} file. As a special case when you set the default to C{None} it will @@ -291,10 +357,10 @@ def set_default_application(mimetype, application): @param mimetype: the mime-type of the file (e.g. "text/html") @param application: an L{Application} object or C{None} ''' - ## Based on logic from xdg-mime make_default_generic() - ## Obtained from http://portland.freedesktop.org/wiki/ (2012-05-31) - ## - ## See comment in get_default_application() + ## Based on https://specifications.freedesktop.org/mime-apps-spec/latest/ + ## Version 1.0 dated 2 April 2014 + + klass._defaults_app_cache[mimetype] = application if application is not None: if not isinstance(application, str): @@ -303,16 +369,8 @@ def set_default_application(mimetype, application): if not application.endswith('.desktop'): application += '.desktop' - default_file = XDG_DATA_HOME.file('applications/defaults.list') - if default_file.exists(): - lines = default_file.readlines() - lines = [l for l in lines if not l.startswith(mimetype + '=')] - else: - lines = ['[Default Applications]\n'] - - if application: - lines.append('%s=%s\n' % (mimetype, application)) - default_file.writelines(lines) + file = XDG_CONFIG_HOME.file('mimeapps.list') + _update_mimeapps_file(file, '[Default Applications]', mimetype, application, replace=True) @staticmethod def create(mimetype, Name, Exec, **param): @@ -335,7 +393,8 @@ def create(mimetype, Name, Exec, **param): ''' dir = XDG_DATA_HOME.subdir('applications') param['MimeType'] = mimetype - file = _create_application(dir, Name, Exec, **param) + basename = cleanup_filename(Name.lower()) + '-usercreated.desktop' + file = _create_application(dir, basename, Name, Exec, **param) return file @classmethod @@ -395,9 +454,40 @@ def list_applications(klass, mimetype, nodisplay=False): @returns: a list of L{Application} objects that are known to be able to handle this file type ''' + ## Supporting mimapps.list + ## Based on https://specifications.freedesktop.org/mime-apps-spec/latest/ + ## Version 1.0 dated 2 April 2014 + ## + ## Also using older "mimeinfo.cache" which is deprecated in the spec + ## but still in use at least on Ubuntu as of Jan 2021 + ## + ## TODO: cache these as well ? + seen = set() entries = [] + blacklist = set() key = '%s=' % mimetype + + def add_entry(basename, dirs): + if basename not in seen and basename not in blacklist: + file = _application_file(basename, dirs) + if file: + entries.append(DesktopEntryFile(File(file))) + seen.add(basename) + + for file in klass._mimeapps_files(): + section=None + for line in file.readlines(): + if line.startswith('['): + section=line.strip() + elif line.startswith(key): + if section in ('[Default Applications]', '[Added Associations]'): + for basename in line[len(key):].strip().split(';'): + add_entry(basename, _application_dirs()) + elif section == '[Removed Associations]': + for basename in line[len(key):].strip().split(';'): + blacklist.add(basename) + for dir in _application_dirs(): cache = dir.file('mimeinfo.cache') if not cache.exists(): @@ -405,20 +495,14 @@ def list_applications(klass, mimetype, nodisplay=False): for line in cache.readlines(): if line.startswith(key): for basename in line[len(key):].strip().split(';'): - if basename in seen: - continue - else: - file = _application_file(basename, (dir,)) - if file: - entries.append(DesktopEntryFile(File(file))) - seen.add(basename) + add_entry(basename, (dir,)) if mimetype in ('x-scheme-handler/http', 'x-scheme-handler/https'): # Since "x-scheme-handler" is not in the standard, some browsers # only identify themselves with "text/html". for entry in klass.list_applications('text/html', nodisplay): # recurs basename = entry.key + '.desktop' - if not basename in seen: + if basename not in seen and basename not in blacklist: entries.append(entry) seen.add(basename) diff --git a/zim/gui/customtools.py b/zim/gui/customtools.py index 0508ce444..bf289f632 100644 --- a/zim/gui/customtools.py +++ b/zim/gui/customtools.py @@ -23,8 +23,7 @@ from zim.config import ConfigManager, XDG_CONFIG_HOME, INIConfigFile from zim.signals import SignalEmitter, SIGNAL_NORMAL, SignalHandler -from zim.gui.applications import Application, DesktopEntryDict, \ - _create_application, String, Boolean +from zim.gui.applications import Application, DesktopEntryDict, String, Boolean from zim.gui.widgets import Dialog, IconButton, IconChooserButton import zim.errors @@ -32,6 +31,30 @@ logger = logging.getLogger('zim.gui') +def _create_application(dir, basename, Name, Exec, NoDisplay=True, **param): + file = dir.file(basename) + i = 0 + while file.exists(): + assert i < 1000, 'BUG: Infinite loop ?' + i += 1 + basename = basename[:-8] + '-' + str(i) + '.desktop' + file = dir.file(basename) + + entry = CustomTool(file) + entry.update( + Type='X-Zim-CustomTool', + Version=1.0, + NoDisplay=NoDisplay, + Name=Name, + Exec=Exec, + **param + ) + + assert entry.isvalid(), 'BUG: created invalid desktop entry' + entry.write() + return entry + + class CustomToolManager(SignalEmitter): '''Manager for dealing with the desktop files which are used to store custom tools. @@ -119,9 +142,9 @@ def create(self, Name, **properties): @returns: a new L{CustomTool} object. ''' - properties['Type'] = 'X-Zim-CustomTool' dir = XDG_CONFIG_HOME.subdir('zim/customtools') - tool = _create_application(dir, Name, '', klass=CustomTool, NoDisplay=False, **properties) + basename = cleanup_filename(Name.lower()) + '-usercreated.desktop' + tool = _create_application(dir, basename, Name, '', NoDisplay=False, **properties) # XXX - hack to ensure we link to configmanager file = ConfigManager.get_config_file('customtools/' + tool.file.basename)