|
| 1 | +""" |
| 2 | +Handles FreeDesktop.org compliance and desktop integration features. |
| 3 | +
|
| 4 | +This includes creating desktop entries for application launchers, managing startup |
| 5 | +notifications according to the FreeDesktop Startup Notification specification, |
| 6 | +and ensuring proper integration with Linux desktop environments. |
| 7 | +
|
| 8 | +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator |
| 9 | +
|
| 10 | +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas <amilcar.lucas@iav.de> |
| 11 | +
|
| 12 | +SPDX-License-Identifier: GPL-3.0-or-later |
| 13 | +""" |
| 14 | + |
| 15 | +import re |
| 16 | +import subprocess |
| 17 | +import tkinter as tk |
| 18 | +from logging import debug as logging_debug |
| 19 | +from logging import error as logging_error |
| 20 | +from os import chmod as os_chmod |
| 21 | +from os import environ as os_environ |
| 22 | +from os import makedirs as os_makedirs |
| 23 | +from os import name as os_name |
| 24 | +from os import path as os_path |
| 25 | +from shutil import which as shutil_which |
| 26 | +from sys import platform as sys_platform |
| 27 | +from typing import Optional, Union |
| 28 | + |
| 29 | +from ardupilot_methodic_configurator.backend_filesystem_program_settings import ProgramSettings |
| 30 | + |
| 31 | + |
| 32 | +class FreeDesktop: |
| 33 | + """ |
| 34 | + A class responsible for FreeDesktop.org compliance and desktop integration. |
| 35 | +
|
| 36 | + This includes creating desktop entries for application launchers, managing startup |
| 37 | + notifications according to the FreeDesktop Startup Notification specification, |
| 38 | + and ensuring proper integration with Linux desktop environments. |
| 39 | + """ |
| 40 | + |
| 41 | + def __init__(self) -> None: |
| 42 | + pass |
| 43 | + |
| 44 | + @staticmethod |
| 45 | + def _is_linux_system() -> bool: |
| 46 | + """Check if running on a Linux system.""" |
| 47 | + return os_name == "posix" and sys_platform.startswith("linux") |
| 48 | + |
| 49 | + @staticmethod |
| 50 | + def _get_desktop_file_path() -> str: |
| 51 | + """Get the path where the desktop file should be created.""" |
| 52 | + return os_path.expanduser("~/.local/share/applications/ardupilot_methodic_configurator.desktop") |
| 53 | + |
| 54 | + @staticmethod |
| 55 | + def _desktop_icon_exists(desktop_file_path: str) -> bool: |
| 56 | + """Check if the desktop icon already exists.""" |
| 57 | + return os_path.exists(desktop_file_path) |
| 58 | + |
| 59 | + @staticmethod |
| 60 | + def _get_virtual_env_path() -> Optional[str]: |
| 61 | + """Get the virtual environment path from environment variables.""" |
| 62 | + return os_environ.get("VIRTUAL_ENV") |
| 63 | + |
| 64 | + @staticmethod |
| 65 | + def _create_desktop_entry_content(venv_path: str, icon_path: str) -> str: |
| 66 | + """Create the desktop entry file content.""" |
| 67 | + # Try to use python executable directly for better compatibility |
| 68 | + python_exe = os_path.join(venv_path, "bin", "python") |
| 69 | + if os_path.exists(python_exe): |
| 70 | + # Use python executable directly |
| 71 | + exec_cmd = f"{python_exe} -m ardupilot_methodic_configurator" |
| 72 | + else: |
| 73 | + # Fallback to bash -c method |
| 74 | + bash_path = shutil_which("bash") or "/bin/bash" |
| 75 | + activate_cmd = f"source {venv_path}/bin/activate && ardupilot_methodic_configurator" |
| 76 | + exec_cmd = f'{bash_path} -c "{activate_cmd}"' |
| 77 | + |
| 78 | + return f"""[Desktop Entry] |
| 79 | +Version=1.0 |
| 80 | +Name=ArduPilot Methodic Configurator |
| 81 | +Comment=A clear ArduPilot configuration sequence |
| 82 | +Exec={exec_cmd} |
| 83 | +Icon={icon_path} |
| 84 | +Terminal=true |
| 85 | +Type=Application |
| 86 | +Categories=Development; |
| 87 | +Keywords=ardupilot;arducopter;drone;parameters;configuration;scm |
| 88 | +StartupWMClass=ArduPilotMethodicConfigurator |
| 89 | +StartupNotify=true |
| 90 | +""" |
| 91 | + |
| 92 | + @staticmethod |
| 93 | + def _ensure_applications_dir_exists(desktop_file_path: str) -> str: |
| 94 | + """Ensure the applications directory exists and return it.""" |
| 95 | + apps_dir = os_path.dirname(desktop_file_path) |
| 96 | + os_makedirs(apps_dir, exist_ok=True) |
| 97 | + return apps_dir |
| 98 | + |
| 99 | + @staticmethod |
| 100 | + def _write_desktop_file(desktop_file_path: str, content: str) -> None: |
| 101 | + """Write the desktop file content to disk.""" |
| 102 | + with open(desktop_file_path, "w", encoding="utf-8") as f: |
| 103 | + f.write(content) |
| 104 | + |
| 105 | + @staticmethod |
| 106 | + def _set_desktop_file_permissions(desktop_file_path: str) -> None: |
| 107 | + """Set appropriate permissions on the desktop file.""" |
| 108 | + os_chmod(desktop_file_path, 0o644) |
| 109 | + |
| 110 | + @staticmethod |
| 111 | + def _update_desktop_database(apps_dir: str) -> None: |
| 112 | + """Update the desktop database if the command is available.""" |
| 113 | + update_desktop_db_cmd = shutil_which("update-desktop-database") |
| 114 | + if update_desktop_db_cmd: |
| 115 | + subprocess.run([update_desktop_db_cmd, apps_dir], check=False, capture_output=True) # noqa: S603 |
| 116 | + |
| 117 | + @staticmethod |
| 118 | + def create_desktop_icon_if_needed() -> None: |
| 119 | + """ |
| 120 | + Create a desktop icon for the application if running in a virtual environment and icon doesn't exist. |
| 121 | +
|
| 122 | + This function detects if we're running in a virtual environment and creates a desktop |
| 123 | + entry that activates the venv and runs the application with the correct icon. |
| 124 | + """ |
| 125 | + # Only create desktop icon on Linux systems |
| 126 | + if not FreeDesktop._is_linux_system(): |
| 127 | + return |
| 128 | + |
| 129 | + # Check if desktop icon already exists |
| 130 | + desktop_file_path = FreeDesktop._get_desktop_file_path() |
| 131 | + if FreeDesktop._desktop_icon_exists(desktop_file_path): |
| 132 | + return |
| 133 | + |
| 134 | + # Check if we're in a virtual environment |
| 135 | + venv_path = FreeDesktop._get_virtual_env_path() |
| 136 | + if not venv_path: |
| 137 | + return |
| 138 | + |
| 139 | + # Find the icon path |
| 140 | + icon_path = ProgramSettings.application_icon_filepath() |
| 141 | + if not icon_path: |
| 142 | + return |
| 143 | + |
| 144 | + # Create the desktop entry content |
| 145 | + desktop_entry = FreeDesktop._create_desktop_entry_content(venv_path, icon_path) |
| 146 | + |
| 147 | + # Ensure the applications directory exists |
| 148 | + apps_dir = FreeDesktop._ensure_applications_dir_exists(desktop_file_path) |
| 149 | + |
| 150 | + # Write the desktop file |
| 151 | + try: |
| 152 | + FreeDesktop._write_desktop_file(desktop_file_path, desktop_entry) |
| 153 | + FreeDesktop._set_desktop_file_permissions(desktop_file_path) |
| 154 | + FreeDesktop._update_desktop_database(apps_dir) |
| 155 | + |
| 156 | + except (OSError, subprocess.SubprocessError): |
| 157 | + logging_error("Failed to create application launch desktop icon") |
| 158 | + |
| 159 | + @staticmethod |
| 160 | + def _get_desktop_startup_id() -> Union[str, None]: |
| 161 | + """ |
| 162 | + Get the DESKTOP_STARTUP_ID environment variable. |
| 163 | +
|
| 164 | + Returns: |
| 165 | + The startup ID string if set, None otherwise. |
| 166 | +
|
| 167 | + """ |
| 168 | + return os_environ.get("DESKTOP_STARTUP_ID") |
| 169 | + |
| 170 | + @staticmethod |
| 171 | + def _send_startup_notification_complete(startup_id: str) -> None: |
| 172 | + """ |
| 173 | + Send the startup notification "remove" message to indicate the application has started. |
| 174 | +
|
| 175 | + This implements the freedesktop.org startup notification protocol. |
| 176 | +
|
| 177 | + Args: |
| 178 | + startup_id: The DESKTOP_STARTUP_ID that was passed to the application |
| 179 | +
|
| 180 | + """ |
| 181 | + if not startup_id: |
| 182 | + return |
| 183 | + |
| 184 | + # Validate startup_id to prevent shell injection (should only contain alphanumeric chars, hyphens, underscores) |
| 185 | + if not re.match(r"^[a-zA-Z0-9_-]+$", startup_id): |
| 186 | + logging_debug("Invalid startup_id format: %s", startup_id) |
| 187 | + return |
| 188 | + |
| 189 | + try: |
| 190 | + # Find the full path to xdg-startup-notify for security |
| 191 | + xdg_notify_path = shutil_which("xdg-startup-notify") |
| 192 | + if xdg_notify_path: |
| 193 | + # Try to use xdg-startup-notify if available (part of xdg-utils) |
| 194 | + result = subprocess.run( # noqa: S603 |
| 195 | + [xdg_notify_path, "remove", startup_id], capture_output=True, timeout=1.0, check=False |
| 196 | + ) |
| 197 | + if result.returncode == 0: |
| 198 | + logging_debug("Sent startup notification completion for ID: %s", startup_id) |
| 199 | + else: |
| 200 | + logging_debug("xdg-startup-notify failed: %s", result.stderr.decode().strip()) |
| 201 | + else: |
| 202 | + logging_debug("xdg-startup-notify not found in PATH") |
| 203 | + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): |
| 204 | + # If xdg-startup-notify is not available or fails, try manual X11 approach |
| 205 | + FreeDesktop._send_startup_notification_x11(startup_id) |
| 206 | + |
| 207 | + @staticmethod |
| 208 | + def _send_startup_notification_x11(startup_id: str) -> None: |
| 209 | + """ |
| 210 | + Send startup notification completion using direct X11 ClientMessage. |
| 211 | +
|
| 212 | + Args: |
| 213 | + startup_id: The DESKTOP_STARTUP_ID that was passed to the application |
| 214 | +
|
| 215 | + """ |
| 216 | + if not tk: |
| 217 | + return |
| 218 | + |
| 219 | + try: |
| 220 | + # Create a temporary Tk instance to access X11 if we don't have one yet |
| 221 | + temp_root = tk.Tk() |
| 222 | + temp_root.withdraw() # Hide the window |
| 223 | + |
| 224 | + # Try to send the message using Tk's send command |
| 225 | + # Format: "remove: ID=<startup_id>" |
| 226 | + message = f"remove: ID={startup_id}" |
| 227 | + |
| 228 | + # Use Tk's send command to broadcast to the root window |
| 229 | + # This is a bit of a hack, but Tk doesn't expose X11 messaging directly |
| 230 | + try: |
| 231 | + temp_root.eval(f"send -async . {{event generate . <<StartupComplete>> -data {{{message}}}}}") |
| 232 | + |
| 233 | + # Also try to use the X11 atoms if available |
| 234 | + # _NET_STARTUP_INFO is the atom we need to send |
| 235 | + temp_root.eval(f"send -async . {{wm command . _NET_STARTUP_INFO {{{message}}}}}") |
| 236 | + |
| 237 | + except Exception: # pylint: disable=broad-exception-caught |
| 238 | + # If all else fails, just log that we tried |
| 239 | + logging_debug("Could not send X11 startup notification message") |
| 240 | + |
| 241 | + temp_root.destroy() |
| 242 | + |
| 243 | + except Exception as e: # pylint: disable=broad-exception-caught |
| 244 | + logging_debug("Failed to send X11 startup notification: %s", e) |
| 245 | + |
| 246 | + @staticmethod |
| 247 | + def setup_startup_notification(main_window: tk.Tk) -> None: |
| 248 | + """ |
| 249 | + Set up startup notification for the application. |
| 250 | +
|
| 251 | + Checks for DESKTOP_STARTUP_ID and sends the completion message when the window is ready. |
| 252 | +
|
| 253 | + Args: |
| 254 | + main_window: The main Tkinter window |
| 255 | +
|
| 256 | + """ |
| 257 | + if not FreeDesktop._is_linux_system(): |
| 258 | + return |
| 259 | + startup_id = FreeDesktop._get_desktop_startup_id() or "" |
| 260 | + if startup_id: |
| 261 | + logging_debug("Startup notification ID: %s", startup_id) |
| 262 | + |
| 263 | + # Send the completion message after the window is mapped |
| 264 | + def on_map(event: tk.Event) -> None: |
| 265 | + if event and event.widget == main_window: |
| 266 | + FreeDesktop._send_startup_notification_complete(startup_id) |
| 267 | + # Remove the binding after first map |
| 268 | + main_window.unbind("<Map>", on_map_handler) |
| 269 | + |
| 270 | + # Bind to the Map event to know when the window is first shown |
| 271 | + on_map_handler = main_window.bind("<Map>", on_map) |
| 272 | + |
| 273 | + # Also try to send immediately in case the window is already mapped |
| 274 | + if main_window.winfo_viewable(): |
| 275 | + FreeDesktop._send_startup_notification_complete(startup_id) |
0 commit comments