Skip to content

Commit 0e0c54c

Browse files
committed
feat(freedesktop): Implement freedesktop notifications
Refactored all freedesktop.org code to a single file
1 parent 2c68f92 commit 0e0c54c

8 files changed

Lines changed: 698 additions & 516 deletions

ardupilot_methodic_configurator/__main__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from ardupilot_methodic_configurator import _, __version__
3535
from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem
36+
from ardupilot_methodic_configurator.backend_filesystem_freedesktop import FreeDesktop
3637
from ardupilot_methodic_configurator.backend_filesystem_program_settings import ProgramSettings
3738
from ardupilot_methodic_configurator.backend_flightcontroller import FlightController
3839
from ardupilot_methodic_configurator.backend_internet import verify_and_open_url
@@ -136,10 +137,13 @@ def connect_to_fc_and_set_vehicle_type(args: argparse.Namespace) -> tuple[Flight
136137
flight_controller = FlightController(reboot_time=args.reboot_time, baudrate=args.baudrate)
137138

138139
error_str = flight_controller.connect(args.device, log_errors=False)
140+
139141
if error_str:
140142
if args.device and _("No serial ports found") not in error_str:
141143
logging_error(error_str)
142144
conn_sel_window = ConnectionSelectionWindow(flight_controller, error_str, default_baudrate=args.baudrate)
145+
# Set up startup notification for the connection selection window
146+
FreeDesktop.setup_startup_notification(conn_sel_window.root) # type: ignore[arg-type]
143147
conn_sel_window.root.mainloop()
144148

145149
vehicle_type = args.vehicle_type
@@ -210,6 +214,8 @@ def vehicle_directory_selection(state: ApplicationState) -> Union[VehicleProject
210214
)
211215
)
212216
vehicle_dir_window = VehicleProjectOpenerWindow(state.vehicle_project_manager)
217+
# Set up startup notification for the vehicle directory selection window
218+
FreeDesktop.setup_startup_notification(vehicle_dir_window.root) # type: ignore[arg-type]
213219
vehicle_dir_window.root.mainloop()
214220

215221
if state.vehicle_project_manager.reset_fc_parameters_to_their_defaults:
@@ -343,6 +349,9 @@ def component_editor(state: ApplicationState) -> None:
343349
elif should_open_firmware_documentation(state.flight_controller):
344350
open_firmware_documentation(state.flight_controller.info.firmware_type)
345351

352+
# Set up startup notification for the component editor window
353+
FreeDesktop.setup_startup_notification(component_editor_window.root) # type: ignore[arg-type]
354+
346355
# Run the GUI
347356
component_editor_window.root.mainloop()
348357

@@ -499,7 +508,7 @@ def main() -> None:
499508
args = create_argument_parser().parse_args()
500509

501510
# Create desktop icon if needed (only on first run in venv)
502-
ProgramSettings.create_desktop_icon_if_needed()
511+
FreeDesktop.create_desktop_icon_if_needed()
503512

504513
state = ApplicationState(args)
505514

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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

Comments
 (0)