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