1#!/usr/bin/python3
2# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
3#   MenuLibre - Advanced fd.o Compliant Menu Editor
4#   Copyright (C) 2012-2021 Sean Davis <sean@bluesabre.org>
5#   Copyright (C) 2017-2018 OmegaPhil <OmegaPhil@startmail.com>
6#
7#   This program is free software: you can redistribute it and/or modify it
8#   under the terms of the GNU General Public License version 3, as published
9#   by the Free Software Foundation.
10#
11#   This program is distributed in the hope that it will be useful, but
12#   WITHOUT ANY WARRANTY; without even the implied warranties of
13#   MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
14#   PURPOSE.  See the GNU General Public License for more details.
15#
16#   You should have received a copy of the GNU General Public License along
17#   with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19import os
20import re
21import subprocess
22
23import getpass
24import psutil
25
26from locale import gettext as _
27
28from gi.repository import GLib, Gdk
29
30import logging
31logger = logging.getLogger('menulibre')
32
33old_psutil_format = isinstance(psutil.Process.username, property)
34
35
36def enum(**enums):
37    """Add enumarations to Python."""
38    return type('Enum', (), enums)
39
40
41MenuItemTypes = enum(
42    SEPARATOR=-1,
43    APPLICATION=0,
44    LINK=1,
45    DIRECTORY=2
46)
47
48
49MenuItemKeys = (
50    # Key, Type, Required, Types (MenuItemType)
51    ("Version", str, False, (0, 1, 2)),
52    ("Type", str, True, (0, 1, 2)),
53    ("Name", str, True, (0, 1, 2)),
54    ("GenericName", str, False, (0, 1, 2)),
55    ("NoDisplay", bool, False, (0, 1, 2)),
56    ("Comment", str, False, (0, 1, 2)),
57    ("Icon", str, False, (0, 1, 2)),
58    ("Hidden", bool, False, (0, 1, 2)),
59    ("OnlyShowIn", list, False, (0, 1, 2)),
60    ("NotShowIn", list, False, (0, 1, 2)),
61    ("DBusActivatable", bool, False, (0,)),
62    ("PrefersNonDefaultGPU", bool, False, (0,)),
63    ("X-GNOME-UsesNotifications", bool, False, (0,)),
64    ("TryExec", str, False, (0,)),
65    ("Exec", str, True, (0,)),
66    ("Path", str, False, (0,)),
67    ("Terminal", bool, False, (0,)),
68    ("Actions", list, False, (0,)),
69    ("MimeType", list, False, (0,)),
70    ("Categories", list, False, (0,)),
71    ("Implements", list, False, (0,)),
72    ("Keywords", list, False, (0,)),
73    ("StartupNotify", bool, False, (0,)),
74    ("StartupWMClass", str, False, (0,)),
75    ("URL", str, True, (1,))
76)
77
78
79def getRelatedKeys(menu_item_type, key_only=False):
80    if isinstance(menu_item_type, str):
81        if menu_item_type == "Application":
82            menu_item_type = MenuItemTypes.APPLICATION
83        elif menu_item_type == "Link":
84            menu_item_type = MenuItemTypes.LINK
85        elif menu_item_type == "Directory":
86            menu_item_type = MenuItemTypes.DIRECTORY
87
88    results = []
89    for tup in MenuItemKeys:
90        if menu_item_type in tup[3]:
91            if key_only:
92                results.append(tup[0])
93            else:
94                results.append((tup[0], tup[1], tup[2]))
95    return results
96
97
98def escapeText(text):
99    if text is None:
100        return ""
101    return GLib.markup_escape_text(text)
102
103
104def getProcessUsername(process):
105    """Get the username of the process owner. Return None if fail."""
106    username = None
107
108    try:
109        if old_psutil_format:
110            username = process.username
111        else:
112            username = process.username()
113    except:  # noqa
114        pass
115
116    return username
117
118
119def getProcessName(process):
120    """Get the process name. Return None if fail."""
121    p_name = None
122
123    try:
124        if old_psutil_format:
125            p_name = process.name
126        else:
127            p_name = process.name()
128    except:  # noqa
129        pass
130
131    return p_name
132
133
134def getProcessList():
135    """Return a list of unique process names for the current user."""
136    username = getpass.getuser()
137    try:
138        pids = psutil.get_pid_list()
139    except AttributeError:
140        pids = psutil.pids()
141    processes = []
142    for pid in pids:
143        try:
144            process = psutil.Process(pid)
145            p_user = getProcessUsername(process)
146            if p_user == username:
147                p_name = getProcessName(process)
148                if p_name is not None and p_name not in processes:
149                    processes.append(p_name)
150        except:  # noqa
151            pass
152    processes.sort()
153    return processes
154
155
156def getBasename(filename):
157    if filename.endswith('.desktop'):
158        basename = filename.split('/applications/', 1)[1]
159    elif filename.endswith('.directory'):
160        basename = filename.split('/desktop-directories/', 1)[1]
161    return basename
162
163
164def getCurrentDesktop():
165    current_desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
166    current_desktop = current_desktop.lower()
167    for desktop in ["budgie", "pantheon", "gnome"]:
168        if desktop in current_desktop:
169            return desktop
170    if "kde" in current_desktop:
171        kde_version = int(os.environ.get("KDE_SESSION_VERSION", "4"))
172        if kde_version >= 5:
173            return "plasma"
174        return "kde"
175    return current_desktop
176
177
178def getDefaultMenuPrefix(): # noqa
179    """Return the default menu prefix."""
180    prefix = os.environ.get('XDG_MENU_PREFIX', '')
181
182    # Cinnamon and MATE don't set this variable
183    if prefix == "":
184        if 'cinnamon' in os.environ.get('DESKTOP_SESSION', ''):
185            prefix = 'cinnamon-'
186        elif 'mate' in os.environ.get('DESKTOP_SESSION', ''):
187            prefix = 'mate-'
188
189    if prefix == "":
190        desktop = getCurrentDesktop()
191        if desktop == 'plasma':
192            prefix = 'kf5-'
193        elif desktop == 'kde':
194            prefix = 'kde4-'
195
196    if prefix == "":
197        processes = getProcessList()
198        if 'xfce4-panel' in processes:
199            prefix = 'xfce-'
200        elif 'mate-panel' in processes:
201            prefix = 'mate-'
202
203    if len(prefix) == 0:
204        logger.warning("No menu prefix found, MenuLibre will not function "
205                       "properly.")
206
207    return prefix
208
209
210def getMenuDiagnostics():
211    diagnostics = {}
212    keys = [
213        "XDG_CURRENT_DESKTOP",
214        "XDG_MENU_PREFIX",
215        "DESKTOP_SESSION",
216        "KDE_SESSION_VERSION"
217    ]
218    for k in keys:
219        diagnostics[k] = os.environ.get(k, "None")
220
221    menu_dirs = [
222        getUserMenuPath()
223    ]
224    for path in GLib.get_system_config_dirs():
225        menu_dirs.append(os.path.join(path, 'menus'))
226    menus = []
227    for menu_dir in menu_dirs:
228        try:
229            for filename in os.listdir(menu_dir):
230                if filename.endswith(".menu"):
231                    menus.append(os.path.join(menu_dir, filename))
232        except FileNotFoundError:
233            pass
234    menus.sort()
235
236    diagnostics["MENUS"] = ", ".join(menus)
237
238    return diagnostics
239
240
241def getItemPath(file_id):
242    """Return the path to the system-installed .desktop file."""
243    for path in GLib.get_system_data_dirs():
244        file_path = os.path.join(path, 'applications', file_id)
245        if os.path.isfile(file_path):
246            return file_path
247    return None
248
249
250def getUserItemPath():
251    """Return the path to the user applications directory."""
252    item_dir = os.path.join(GLib.get_user_data_dir(), 'applications')
253    if not os.path.isdir(item_dir):
254        os.makedirs(item_dir)
255    return item_dir
256
257
258def getDirectoryPath(file_id):
259    """Return the path to the system-installed .directory file."""
260    for path in GLib.get_system_data_dirs():
261        file_path = os.path.join(path, 'desktop-directories', file_id)
262        if os.path.isfile(file_path):
263            return file_path
264    return None
265
266
267def getUserDirectoryPath():
268    """Return the path to the user desktop-directories directory."""
269    menu_dir = os.path.join(GLib.get_user_data_dir(), 'desktop-directories')
270    if not os.path.isdir(menu_dir):
271        os.makedirs(menu_dir)
272    return menu_dir
273
274
275def getUserMenuPath():
276    """Return the path to the user menus directory."""
277    menu_dir = os.path.join(GLib.get_user_config_dir(), 'menus')
278    if not os.path.isdir(menu_dir):
279        os.makedirs(menu_dir)
280    return menu_dir
281
282
283def getUserLauncherPath(basename):
284    """Return the user-installed path to a .desktop or .directory file."""
285    if basename.endswith('.desktop'):
286        check_dir = "applications"
287    else:
288        check_dir = "desktop-directories"
289    path = os.path.join(GLib.get_user_data_dir(), check_dir)
290    filename = os.path.join(path, basename)
291    if os.path.isfile(filename):
292        return filename
293    return None
294
295
296def getSystemMenuPath(file_id):
297    """Return the path to the system-installed menu file."""
298    for path in GLib.get_system_config_dirs():
299        file_path = os.path.join(path, 'menus', file_id)
300        if os.path.isfile(file_path):
301            return file_path
302    return None
303
304
305def getSystemLauncherPath(basename):
306    """Return the system-installed path to a .desktop or .directory file."""
307    if basename.endswith('.desktop'):
308        check_dir = "applications"
309    else:
310        check_dir = "desktop-directories"
311    for path in GLib.get_system_data_dirs():
312        path = os.path.join(path, check_dir)
313        filename = os.path.join(path, basename)
314        if os.path.isfile(filename):
315            return filename
316    return None
317
318
319def getDirectoryName(directory_str):  # noqa
320    """Return the directory name to be used in the XML file."""
321
322    # Note: When adding new logic here, please see if
323    # getDirectoryNameFromCategory should also be updated
324
325    # Get the menu prefix
326    prefix = getDefaultMenuPrefix()
327    has_prefix = False
328
329    basename = getBasename(directory_str)
330    name, ext = os.path.splitext(basename)
331
332    # Handle directories like xfce-development
333    if name.startswith(prefix):
334        name = name[len(prefix):]
335        name = name.title()
336        has_prefix = True
337
338    # Handle X-GNOME, X-XFCE
339    if name.startswith("X-"):
340        # Handle X-GNOME, X-XFCE
341        condensed = name.split('-', 2)[-1]
342        non_camel = re.sub('(?!^)([A-Z]+)', r' \1', condensed)
343        return non_camel
344
345    # Cleanup ArcadeGames and others as per the norm.
346    if name.endswith('Games') and name != 'Games':
347        condensed = name[:-5]
348        non_camel = re.sub('(?!^)([A-Z]+)', r' \1', condensed)
349        return non_camel
350
351    # GNOME...
352    if name == 'AudioVideo' or name == 'Audio-Video':
353        return 'Multimedia'
354
355    if name == 'Game':
356        return 'Games'
357
358    if name == 'Network' and prefix != 'xfce-':
359        return 'Internet'
360
361    if name == 'Utility':
362        return 'Accessories'
363
364    if name == 'System-Tools':
365        if prefix == 'lxde-':
366            return 'Administration'
367        else:
368            return 'System'
369
370    if name == 'Settings':
371        if prefix == 'lxde-':
372            return 'DesktopSettings'
373        elif has_prefix and prefix == 'xfce-':
374            return name
375        else:
376            return 'Preferences'
377
378    if name == 'Settings-System':
379        return 'Administration'
380
381    if name == 'GnomeScience':
382        return 'Science'
383
384    if name == 'Utility-Accessibility':
385        return 'Universal Access'
386
387    # We tried, just return the name.
388    return name
389
390
391def getDirectoryNameFromCategory(name):  # noqa
392    """Guess at the directory name a category should cause its launcher to
393    appear in. This is used to add launchers to or remove from the right
394    directories after category addition without having to restart menulibre."""
395
396    # Note: When adding new logic here, please see if
397    # getDirectoryName should also be updated
398
399    # I don't want to overload the use of getDirectoryName, so have spun out
400    # this similar function
401
402    # Only interested in generic categories here, so no need to handle
403    # categories named after desktop environments
404    prefix = getDefaultMenuPrefix()
405
406    # Cleanup ArcadeGames and others as per the norm.
407    if name.endswith('Games') and name != 'Games':
408        condensed = name[:-5]
409        non_camel = re.sub('(?!^)([A-Z]+)', r' \1', condensed)
410        return non_camel
411
412    # GNOME...
413    if name == 'AudioVideo' or name == 'Audio-Video':
414        return 'Multimedia'
415
416    if name == 'Game':
417        return 'Games'
418
419    if name == 'Network':
420        return 'Internet'
421
422    if name == 'Utility':
423        return 'Accessories'
424
425    if name == 'System-Tools':
426        if prefix == 'lxde-':
427            return 'Administration'
428        else:
429            return 'System'
430
431    if name == 'Settings':
432        if prefix == 'lxde-':
433            return 'DesktopSettings'
434        elif prefix == 'xfce-':
435            return name
436        else:
437            return 'Preferences'
438
439    if name == 'Settings-System':
440        return 'Administration'
441
442    if name == 'GnomeScience':
443        return 'Science'
444
445    if name == 'Utility-Accessibility':
446        return 'Universal Access'
447
448    # We tried, just return the name.
449    return name
450
451
452def getRequiredCategories(directory):
453    """Return the list of required categories for a directory string."""
454    prefix = getDefaultMenuPrefix()
455    if directory is not None:
456        basename = getBasename(directory)
457        name, ext = os.path.splitext(basename)
458
459        # Handle directories like xfce-development
460        if name.startswith(prefix):
461            name = name[len(prefix):]
462            name = name.title()
463
464        if name == 'Accessories':
465            return ['Utility']
466
467        if name == 'Games':
468            return ['Game']
469
470        if name == 'Multimedia':
471            return ['AudioVideo']
472
473        else:
474            return [name]
475    else:
476        # Get The Toplevel item if necessary...
477        if prefix == 'xfce-':
478            return ['X-XFCE', 'X-Xfce-Toplevel']
479    return []
480
481
482def getSaveFilename(name, filename, item_type, force_update=False):  # noqa
483    """Determime the filename to be used to store the launcher.
484
485    Return the filename to be used."""
486    # Check if the filename is writeable. If not, generate a new one.
487    unique = filename is None or len(filename) == 0
488
489    if unique or not os.access(filename, os.W_OK):
490        # No filename, make one from the launcher name.
491        if unique:
492            basename = "menulibre-" + name.lower().replace(' ', '-')
493
494        # Use the current filename as a base.
495        else:
496            basename = getBasename(filename)
497
498        # Split the basename into filename and extension.
499        name, ext = os.path.splitext(basename)
500
501        # Get the save location of the launcher base on type.
502        if item_type == 'Application':
503            path = getUserItemPath()
504            ext = '.desktop'
505        elif item_type == 'Directory':
506            path = getUserDirectoryPath()
507            ext = '.directory'
508
509        basedir = os.path.dirname(os.path.join(path, basename))
510        if not os.path.exists(basedir):
511            os.makedirs(basedir)
512
513        # Index for unique filenames.
514        count = 1
515
516        # Be sure to not overwrite system launchers if new.
517        if unique:
518            # Check for the system version of the launcher.
519            if getSystemLauncherPath("%s%s" % (name, ext)) is not None:
520                # If found, check for any additional ones.
521                while getSystemLauncherPath("%s%i%s" % (name, count, ext)) \
522                        is not None:
523                    count += 1
524
525                # Now be sure to not overwrite locally installed ones.
526                filename = os.path.join(path, name)
527                filename = "%s%i%s" % (filename, count, ext)
528
529                # Append numbers as necessary to make the filename unique.
530                while os.path.exists(filename):
531                    new_basename = "%s%i%s" % (name, count, ext)
532                    filename = os.path.join(path, new_basename)
533                    count += 1
534
535            else:
536                # Create the new base filename.
537                filename = os.path.join(path, name)
538                filename = "%s%s" % (filename, ext)
539
540                # Append numbers as necessary to make the filename unique.
541                while os.path.exists(filename):
542                    new_basename = "%s%i%s" % (name, count, ext)
543                    filename = os.path.join(path, new_basename)
544                    count += 1
545
546        else:
547            # Create the new base filename.
548            filename = os.path.join(path, basename)
549
550            if force_update:
551                return filename
552
553            # Append numbers as necessary to make the filename unique.
554            while os.path.exists(filename):
555                new_basename = "%s%i%s" % (name, count, ext)
556                filename = os.path.join(path, new_basename)
557                count += 1
558
559    return filename
560
561
562def check_keypress(event, keys):  # noqa
563    """Compare keypress events with desired keys and return True if matched."""
564    if 'Control' in keys:
565        if not bool(event.get_state() & Gdk.ModifierType.CONTROL_MASK):
566            return False
567    if 'Alt' in keys:
568        if not bool(event.get_state() & Gdk.ModifierType.MOD1_MASK):
569            return False
570    if 'Shift' in keys:
571        if not bool(event.get_state() & Gdk.ModifierType.SHIFT_MASK):
572            return False
573    if 'Super' in keys:
574        if not bool(event.get_state() & Gdk.ModifierType.SUPER_MASK):
575            return False
576    if 'Escape' in keys:
577        keys[keys.index('Escape')] = 'escape'
578    if Gdk.keyval_name(event.get_keyval()[1]).lower() not in keys:
579        return False
580
581    return True
582
583
584def determine_bad_desktop_files():
585    """Run the gmenu-invalid-desktop-files script to get at the GMenu library's
586    debug output, which lists files that failed to load, and return these as a
587    sorted list."""
588
589    # Run the helper script with normal binary lookup via the shell, capturing
590    # stderr, sensitive to errors
591    try:
592        result = subprocess.run(['menulibre-menu-validate'],
593                                stderr=subprocess.PIPE, shell=True, check=True)
594    except subprocess.CalledProcessError:
595        return []
596
597    # stderr is returned as bytes, so converting it to the line-buffered output
598    # I actually want
599    bad_desktop_files = []
600    for line in result.stderr.decode('UTF-8').split('\n'):
601        matches = re.match(r'^Failed to load "(.+\.desktop)"$', line)
602        if matches:
603            desktop_file = matches.groups()[0]
604            if validate_desktop_file(desktop_file):
605                bad_desktop_files.append(matches.groups()[0])
606
607    # Alphabetical sort on bad desktop file paths
608    bad_desktop_files.sort()
609
610    return bad_desktop_files
611
612
613def find_program(program):
614    program = program.strip()
615    if len(program) == 0:
616        return None
617
618    params = list(GLib.shell_parse_argv(program)[1])
619    executable = params[0]
620
621    if os.path.exists(executable):
622        return executable
623
624    path = GLib.find_program_in_path(executable)
625    if path is not None:
626        return path
627
628    return None
629
630def validate_desktop_file(desktop_file):  # noqa
631    """Validate a known-bad desktop file in the same way GMenu/glib does, to
632    give a user real information about why certain files are broken."""
633
634    # This is a reimplementation of the validation logic in glib2's
635    # gio/gdesktopappinfo.c:g_desktop_app_info_load_from_keyfile.
636    # gnome-menus appears also to try to do its own validation in
637    # libmenu/desktop-entries.c:desktop_entry_load, however
638    # g_desktop_app_info_new_from_filename will not return a valid
639    # GDesktopAppInfo in the first place if something is wrong with the
640    # desktop file
641
642    try:
643
644        # Looks like load_from_file is not a class method??
645        keyfile = GLib.KeyFile()
646        keyfile.load_from_file(desktop_file, GLib.KeyFileFlags.NONE)
647
648    except Exception as e:
649        # Translators: This error is displayed when a desktop file cannot
650        # be correctly read by MenuLibre. A (possibly untranslated) error
651        # code is displayed.
652        return _('Unable to load desktop file due to the following error:'
653                 ' %s') % e
654
655    # File is at least a valid keyfile, so can start the real desktop
656    # validation
657    # Start group validation
658    try:
659        start_group = keyfile.get_start_group()
660    except GLib.Error:
661        start_group = None
662
663    if start_group != GLib.KEY_FILE_DESKTOP_GROUP:
664        # Translators: This error is displayed when the first group in a
665        # failing desktop file is incorrect. "Start group" can be safely
666        # translated.
667        return (_('Start group is invalid - currently \'%s\', should be '
668                  '\'%s\'') % (start_group, GLib.KEY_FILE_DESKTOP_GROUP))
669
670    # Type validation
671    try:
672        type_key = keyfile.get_string(start_group,
673                                      GLib.KEY_FILE_DESKTOP_KEY_TYPE)
674    except GLib.Error:
675        # Translators: This error is displayed when a required key is
676        # missing in a failing desktop file.
677        return _('%s key not found') % 'Type'
678
679    valid_type_keys = [
680        GLib.KEY_FILE_DESKTOP_TYPE_APPLICATION,
681        GLib.KEY_FILE_DESKTOP_TYPE_LINK,
682        GLib.KEY_FILE_DESKTOP_TYPE_DIRECTORY,
683        # KDE-specific types
684        # https://specifications.freedesktop.org/desktop-entry-spec/latest/apb.html
685        "ServiceType", "Service", "FSDevice"
686    ]
687    if type_key not in valid_type_keys:
688        # Translators: This error is displayed when a failing desktop file
689        # has an invalid value for the provided key.
690        return (_('%s value is invalid - currently \'%s\', should be \'%s\'')
691                % ('Type', type_key, GLib.KEY_FILE_DESKTOP_TYPE_APPLICATION))
692
693    # Validating 'try exec' if its present
694    # Invalid TryExec is a valid state. "If the file is not present or if it
695    # is not executable, the entry may be ignored (not be used in menus, for
696    # example)."
697    try:
698        try_exec = keyfile.get_string(start_group,
699                                      GLib.KEY_FILE_DESKTOP_KEY_TRY_EXEC)
700    except GLib.Error:
701        pass
702
703    else:
704        try:
705            if len(try_exec) > 0 and find_program(try_exec) is None:
706                return False
707        except Exception as e:
708            return False
709
710    # Validating executable
711    try:
712        exec_key = keyfile.get_string(start_group,
713                                      GLib.KEY_FILE_DESKTOP_KEY_EXEC)
714    except GLib.Error:
715        return False # LP: #1788814, Exec key is not required
716
717    try:
718        if find_program(exec_key) is None:
719            return (_('%s program \'%s\' has not been found in the PATH')
720                    % ('Exec', exec_key))
721    except Exception as e:
722        return (_('%s program \'%s\' is not a valid shell command '
723                  'according to GLib.shell_parse_argv, error: %s')
724                % ('Exec', exec_key, e))
725
726    if type_key == "Service":
727        return False # KDE services are not displayed in the menu
728
729    # Translators: This error is displayed for a failing desktop file where
730    # errors were detected but the file seems otherwise valid.
731    return _('Unknown error. Desktop file appears to be valid.')
732