fix: resolve wifi crashes, shell injections, race conditions, and add debug mode

- Remove transient NEED_AUTH/DISCONNECTED error paths from wifi connect
- Guard double-callback in connect_wifi_bssid/connect_wifi_with_password
- Shell-quote user paths in wallpaper, screenshot, screenrecorder services
- Fix OSD hide-timer use-after-free and applet_window stale source ID
- Fix brightness poll timer leak on read failure
- Fix spotlight search stale idle source removal
- Lock dbusmenu _cache mutation under _registry_lock
- Fix entry.revision written outside _registry_lock scope
- Add fabric-cli exec wrapper for lock screen (lock_screen.lock())
- Replace hyprlock with lock screen in power menu and dropdown
- Add debug mode toggle in settings (controls log verbosity)
- Add diagnostic logging to globalmenu click chain
This commit is contained in:
sankalp committed 2026-07-16 11:47:53 +05:45
1 parent 4378959131
commit a47abda55a
26 files changed
+224 -57

No files matched your search

+1 -1
View File
@@ -23,5 +23,5 @@ on-clicked = "python3 -m utils.google_lens_cli"
#
# [[Mods.terminal.options]]
# label = "Terminal but scary"
# on-clicked = "ghostty"
# on-clicked = "zen-browser"
# divider = true
+16
View File
@@ -32,6 +32,7 @@ from shared.data import APP_NAME, load_config
from utils.functions import set_process_name
from window.desktop.widget import Deskwidgets
from window.dock import Dock
from window.lock import LockScreenWrapper
from window.notification.notification import ModusNoti
from window.osd.main import OSDWindow
from window.panel.main import Panel
@@ -54,6 +55,20 @@ def main():
set_process_name(APP_NAME)
load_config()
from services.config import config
_debug = config().get("debug", False)
if not _debug:
logger.disable("modus_plugin_builtin_emoji")
else:
logger.enable("fabric")
logger.enable("services")
logger.enable("window")
logger.enable("utils")
logger.enable("window.globalmenu")
switcher = ApplicationSwitcher()
panel = Panel()
modusnoti = ModusNoti()
@@ -113,6 +128,7 @@ def main():
__main__.dock = dock
__main__.osd = osd
__main__.screencapture = screencapture
__main__.lock_screen = LockScreenWrapper()
__main__.switch_keyboard_layout = KeyboardLayout.switch_keyboard_layout
app.run()
+2 -1
View File
@@ -93,7 +93,8 @@ class Brightness(Service):
return True
except Exception as e:
logger.error(f"Error checking brightness file: {e}")
return True
self._poll_timer_id = None
return False
def _detect_backend(self, backend):
"""Detect appropriate backend for brightness control."""
+9
View File
@@ -59,6 +59,15 @@ class ConfigService:
"""Set a config value (local state only)."""
self._config[key] = value
def save(self) -> None:
"""Persist current in-memory config to disk."""
try:
doc = self._dict_to_toml(self._config)
with open(self._config_file, "w") as f:
toml_dump(doc, f)
except Exception as e:
logger.error(f"[ConfigService] Failed to save config: {e}")
def register_reload_callback(
self, callback: Callable[[Dict[str, Any], Dict[str, Any]], None]
) -> None:
+4 -2
View File
@@ -327,12 +327,14 @@ class PlayerService(Service):
if metadata is None:
try:
metadata = self._player.props.metadata
except Exception:
except Exception as e:
logger.debug(f"[mpris] Failed to read metadata: {e}")
metadata = None
if metadata is not None:
try:
dur = metadata["mpris:length"] / 1_000_000
except Exception:
except Exception as e:
logger.debug(f"[mpris] Failed to read duration: {e}")
dur = 0
# Browser players (YouTube in Firefox/Chromium) populate
# `mpris:artUrl` lazily and don't reliably emit the `metadata`
+32 -13
View File
@@ -1,3 +1,4 @@
import shlex
from types import SimpleNamespace
from typing import List, Literal
@@ -105,7 +106,8 @@ class Wifi(Service):
try:
self._device.request_scan_async(None, _on_scan_finish)
except Exception:
except Exception as e:
logger.warning(f"[network] request_scan_async failed: {e}")
self._scanning = False
def notifier(self, name: str, *args):
@@ -401,26 +403,33 @@ class NetworkClient(Service):
callback(False, "No wifi device")
return
if not bssid:
if callback:
callback(False, "No bssid")
return
device = self.wifi_device._device
handler_id: list[int] = []
_fired = [False]
def _on_state_changed(_dev, new_state, _old_state, reason):
if not callback:
if _fired[0]:
return
state = NM.DeviceState(new_state)
if state == NM.DeviceState.ACTIVATED:
_fired[0] = True
_cleanup()
callback(True, "Connected")
elif state in (NM.DeviceState.FAILED, NM.DeviceState.DISCONNECTED):
if callback:
callback(True, "Connected")
elif state == NM.DeviceState.FAILED:
_fired[0] = True
_cleanup()
reason_str = NM.DeviceStateReason(reason).value_nick
if "secret" in reason_str or "auth" in reason_str:
callback(False, "Wrong password")
else:
callback(False, f"Failed ({reason_str})")
elif state == NM.DeviceState.NEED_AUTH:
_cleanup()
callback(False, "Wrong password")
if callback:
if "secret" in reason_str or "auth" in reason_str:
callback(False, "Wrong password")
else:
callback(False, f"Failed ({reason_str})")
def _cleanup():
if handler_id:
@@ -433,7 +442,7 @@ class NetworkClient(Service):
handler_id.append(device.connect("state-changed", _on_state_changed))
exec_shell_command_async(
f"nmcli device wifi connect {bssid}",
f"nmcli device wifi connect {shlex.quote(bssid)}",
lambda *args: logger.debug(f"connect result: {args}"),
)
@@ -442,15 +451,24 @@ class NetworkClient(Service):
callback(False, "No wifi device")
return
if not bssid:
callback(False, "No bssid")
return
device = self.wifi_device._device
handler_id: list[int] = []
_fired = [False]
def _on_state_changed(_dev, new_state, _old_state, reason):
if _fired[0]:
return
state = NM.DeviceState(new_state)
if state == NM.DeviceState.ACTIVATED:
_fired[0] = True
_cleanup()
callback(True, "Connected")
elif state in (NM.DeviceState.FAILED, NM.DeviceState.DISCONNECTED):
_fired[0] = True
_cleanup()
reason_str = NM.DeviceStateReason(reason).value_nick
if "secret" in reason_str or "auth" in reason_str:
@@ -458,6 +476,7 @@ class NetworkClient(Service):
else:
callback(False, f"Failed ({reason_str})")
elif state == NM.DeviceState.NEED_AUTH:
_fired[0] = True
_cleanup()
callback(False, "Wrong password")
@@ -471,6 +490,6 @@ class NetworkClient(Service):
handler_id.append(device.connect("state-changed", _on_state_changed))
exec_shell_command_async(
f"nmcli device wifi connect {bssid} password {password!r}",
f"nmcli device wifi connect {shlex.quote(bssid)} password {shlex.quote(password)}",
lambda *args: logger.debug(f"connect_with_password result: {args}"),
)
+5 -2
View File
@@ -5,6 +5,7 @@ Recording state is held entirely in memory as Fabric properties -- no
temporary files are used.
"""
import shlex
import time
from datetime import datetime
from pathlib import Path
@@ -261,9 +262,11 @@ class ScreenRecorder(Service):
def _handle_action(self, action: str, path: str) -> None:
match action:
case "files":
exec_shell_command_async(f"xdg-open {self.recordings_dir}")
exec_shell_command_async(
f"xdg-open {shlex.quote(str(self.recordings_dir))}"
)
case "view":
exec_shell_command_async(f"xdg-open {path}")
exec_shell_command_async(f"xdg-open {shlex.quote(path)}")
def _notify_cancelled(self) -> None:
self._notifier.notify(
+6 -3
View File
@@ -6,6 +6,7 @@ into a concrete request, hands it to a :class:`CaptureBackend`, then raises
notifications and signals based on the result.
"""
import shlex
from datetime import datetime
from pathlib import Path
@@ -157,11 +158,13 @@ class Screenshot(Service):
def _handle_action(self, action: str, path: str) -> None:
match action:
case "files":
exec_shell_command_async(f"xdg-open {self.screenshots_dir}")
exec_shell_command_async(
f"xdg-open {shlex.quote(str(self.screenshots_dir))}"
)
case "view":
exec_shell_command_async(f"xdg-open {path}")
exec_shell_command_async(f"xdg-open {shlex.quote(path)}")
case "edit":
exec_shell_command_async(f"satty -f {path}")
exec_shell_command_async(f"satty -f {shlex.quote(path)}")
def _notify_cancelled(self) -> None:
self._notifier.notify(
+20 -8
View File
@@ -1,18 +1,18 @@
import hashlib
import mimetypes
import shlex
from fabric.core import Service, Signal
from fabric.utils import GdkPixbuf as gdk_pixbuf
from fabric.utils import exec_shell_command_async, logger, os
from services.config import on_config_change
from shared.data import (
WALLPAPER_PATH,
WALLPAPER_THUMBS_PATH,
WALLPAPERS_THUMBNAILS_SIZE,
_get_wallpaper_path,
)
os.makedirs(WALLPAPER_PATH, exist_ok=True)
os.makedirs(WALLPAPER_THUMBS_PATH, exist_ok=True)
os.makedirs(_get_wallpaper_path(), exist_ok=True)
def get_thumbnail_filename(image_path: str) -> str:
@@ -64,14 +64,23 @@ class WallpaperService(Service):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._file_cache: list[str] | None = None
on_config_change(self._on_config_change)
def _on_config_change(self, new_config, old_config):
if new_config.get("wallpapers_dir") != old_config.get("wallpapers_dir"):
self._file_cache = None
path = _get_wallpaper_path()
os.makedirs(path, exist_ok=True)
os.makedirs(f"{path}/.thumbnails", exist_ok=True)
def get_all_wallpapers(self, use_cache: bool = True) -> list[str]:
if use_cache and self._file_cache is not None:
return self._file_cache
wallpapers: list[str] = []
wallpaper_path = _get_wallpaper_path()
with os.scandir(WALLPAPER_PATH) as entries:
with os.scandir(wallpaper_path) as entries:
for entry in entries:
if not entry.is_file():
continue
@@ -87,8 +96,9 @@ class WallpaperService(Service):
self._file_cache = None
def get_thumbnail_path(self, image_path: str) -> str:
thumbs_dir = f"{_get_wallpaper_path()}/.thumbnails"
return os.path.join(
WALLPAPER_THUMBS_PATH,
thumbs_dir,
get_thumbnail_filename(image_path),
)
@@ -97,7 +107,7 @@ class WallpaperService(Service):
def set_wallpaper(self, image_path: str) -> None:
exec_shell_command_async(
f'awww img "{image_path}" '
f"awww img {shlex.quote(image_path)} "
"--transition-type none "
"--transition-duration 0 "
"--transition-fps 60"
@@ -112,7 +122,9 @@ class WallpaperService(Service):
os.symlink(os.path.abspath(image_path), link_path)
exec_shell_command_async(f'matugen image "{image_path}" --source-color-index 0')
exec_shell_command_async(
f"matugen image {shlex.quote(image_path)} --source-color-index 0"
)
self.colors_generated(image_path)
+2 -4
View File
@@ -20,13 +20,11 @@ def _get_wallpaper_path() -> str:
custom = get_config("wallpapers_dir")
if custom:
return os.path.expanduser(str(custom))
except Exception:
pass
except Exception as e:
logger.warning(f"[data] Failed to load wallpapers_dir from config: {e}")
return f"{HOME_DIR}/Pictures/Wallpapers"
WALLPAPER_PATH = _get_wallpaper_path()
WALLPAPER_THUMBS_PATH = f"{WALLPAPER_PATH}/.thumbnails"
WALLPAPERS_THUMBNAILS_SIZE = 200
WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/")
+6 -2
View File
@@ -226,8 +226,12 @@ class WiFiPasswordDialog(Window):
def _connection_timeout(self):
if self.is_connecting:
self.is_connecting = False
self.join_button.set_sensitive(True)
self.show_error("Connection timeout. Please try again.")
self.connection_timeout_id = None
try:
self.join_button.set_sensitive(True)
self.show_error("Connection timeout. Please try again.")
except Exception:
pass
return False
def show_dialog(self):
+1
View File
@@ -102,4 +102,5 @@ class AppletWindow(PopupWindow):
self.dismiss_layer.destroy()
if self._hide_timeout_id is not None:
GLib.source_remove(self._hide_timeout_id)
self._hide_timeout_id = None
super().destroy()
+1
View File
@@ -1,4 +1,5 @@
DEFAULT = {
"debug": False,
"dock_enabled": True,
"dock_auto_hide": True,
"dock_always_occluded": False,
+12 -1
View File
@@ -93,6 +93,7 @@ class ModusControlCenter(AppletWindow):
# Initialize network service for WiFi toggle - lazy load
self.network_service = None
self.wifi_service = None
self._network_ready_fired = False
# Initialize flight mode and caffeine states - lazy load
self.caffeine_mode = False
@@ -905,9 +906,19 @@ class ModusControlCenter(AppletWindow):
logger.warning(f"Failed to toggle bluetooth: {e}")
def on_network_ready(self, *_):
if self._network_ready_fired:
return
self._network_ready_fired = True
self.wifi_service = self.network_service.wifi_device
if self.wifi_service:
self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon)
self._signal_connections.append(
(
self.wifi_service,
self.wifi_service.connect(
"notify::wireless-enabled", self.update_wifi_icon
),
)
)
def update_wifi_icon(self, *_):
try:
+1 -3
View File
@@ -574,9 +574,7 @@ class PlayerBox(Box):
current_status = str(self.player.playback_status).lower()
self._last_notified_status = current_status
self.play_pause_icon.dynamic_file(
"player/play.svg"
if current_status == "paused"
else "player/pause.svg"
"player/play.svg" if current_status == "paused" else "player/pause.svg"
)
self._on_metadata(self.player, self.player._player.props.metadata)
self.set_image()
+4
View File
@@ -275,6 +275,7 @@ class WifiConnections(Box):
self._update_in_progress = False # Prevent concurrent updates
self._destroyed = False # Track if widget is destroyed
self._signal_ids = [] # Track all service signal IDs for cleanup
self._network_ready_fired = False # Guard against duplicate device-ready
self._signal_ids.append(
(
@@ -437,6 +438,9 @@ class WifiConnections(Box):
def on_network_ready(self, *_):
"""Called when network service is ready"""
if self._network_ready_fired:
return
self._network_ready_fired = True
self.wifi_service = self.network_service.wifi_device
if self.wifi_service:
self.toggle_button.set_active(self.wifi_service.enabled)
+11
View File
@@ -15,6 +15,7 @@ from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.wayland import WaylandWindow as Window
from services.config import on_config_change
from shared.data import load_config
from utils.gtk_utils import svg_file
from window.desktop.constants import (
@@ -93,6 +94,16 @@ class WeatherService(Service):
# connecting (see Weather.__init__) to avoid a race with subscription.
GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, self._refresh)
on_config_change(self._on_config_change)
def _on_config_change(self, new_config, old_config):
if new_config.get("weather_location") != old_config.get("weather_location"):
self._weather = None
self._location = None
self._coords = None
self._coords_location = None
self._executor.submit(self._fetch)
# -- scheduling -------------------------------------------------------
def refresh(self) -> None:
+2 -1
View File
@@ -132,7 +132,8 @@ class DockCanvas(Gtk.DrawingArea):
if not self._desktop_apps:
try:
self._desktop_apps = get_desktop_applications(include_hidden=False)
except Exception:
except Exception as e:
logger.warning(f"[dock] Failed to load desktop applications: {e}")
self._desktop_apps = []
return self._desktop_apps
+14 -9
View File
@@ -123,10 +123,9 @@ class DBusMenuClient:
return None
def get_layout(self, force_refresh=False):
if self._cache and self._cache_valid and not force_refresh:
return self._cache
with self._lock:
if self._cache and self._cache_valid and not force_refresh:
return self._cache
if self._fetching:
return self._cache or []
self._fetching = True
@@ -261,6 +260,10 @@ class DBusMenuClient:
return False
def click_item(self, item_id, pid=0):
logger.debug(
f"[dbusmenu] click_item: item_id={item_id}, "
f"service={self.service_name}, path={self.object_path}"
)
try:
self._call(
"Event",
@@ -269,7 +272,7 @@ class DBusMenuClient:
self.invalidate()
except Exception as e:
logger.warning(
f"[dbusmenu] self._call( 'Event', GLib.Variant('(isvu)', (item_id, 'cl... failed: {e}"
f"[dbusmenu] click_item: Event call failed for item_id={item_id}: {e}"
)
def invalidate(self):
@@ -369,9 +372,10 @@ class DBusMenuClient:
layout = layout.get_variant()
parsed = self._parse_dbusmenu(layout).children
if parent_id == 0:
self._hash_cache = self._hash(parsed)
self._cache = parsed
self._cache_valid = True
with self._lock:
self._hash_cache = self._hash(parsed)
self._cache = parsed
self._cache_valid = True
return parsed
except Exception as e:
logger.error(f"[Menu] update_layout error: {e}")
@@ -403,5 +407,6 @@ class DBusMenuClient:
result.append(item)
return result
self._cache = walk(self._cache)
self._hash_cache = None
with self._lock:
self._cache = walk(self._cache)
self._hash_cache = None
+25 -1
View File
@@ -372,9 +372,26 @@ class GlobalMenuService(Service):
"""Return the registry entry and PID for the currently active window."""
with self._state_lock:
pid = self._current_pid
if pid == 0:
logger.warning(
"[GlobalMenuService] _resolve_current_entry: _current_pid is 0"
)
return None, 0
with self._registry_lock:
svc = self._pid_to_service.get(pid)
entry = self._service_registry.get(svc) if svc else None
if not svc:
logger.warning(
f"[GlobalMenuService] _resolve_current_entry: no service for pid {pid}"
)
elif not entry:
logger.warning(
f"[GlobalMenuService] _resolve_current_entry: no entry for service '{svc}' (pid {pid})"
)
elif not entry.importer:
logger.warning(
f"[GlobalMenuService] _resolve_current_entry: importer is None for '{svc}' (pid {pid})"
)
return entry, pid
def _register_new_service(
@@ -510,7 +527,7 @@ class GlobalMenuService(Service):
entry = self._service_registry.get(svc_name)
if not entry or not entry.importer:
return
entry.revision = revision
entry.revision = revision
subtree = entry.importer.update_layout(parent_id, revision)
if subtree is None:
return
@@ -1066,8 +1083,15 @@ class GlobalMenuService(Service):
def click_item(self, item_id: int) -> bool:
entry, pid = self._resolve_current_entry()
if entry and entry.importer:
logger.debug(
f"[GlobalMenuService] click_item: item_id={item_id}, "
f"service={entry.service_name}, importer={type(entry.importer).__name__}"
)
entry.importer.click_item(item_id, pid=pid)
return True
logger.warning(
f"[GlobalMenuService] click_item FAILED: item_id={item_id}, entry={entry}, pid={pid}"
)
return False
def about_to_show(self, item_id: int) -> bool:
+18 -2
View File
@@ -2,7 +2,14 @@ import getpass
import pam
from fabric import Application
from fabric.utils import Gdk, GLib, get_relative_path, logger, os
from fabric.utils import (
Gdk,
GLib,
exec_shell_command_async,
get_relative_path,
logger,
os,
)
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.datetime import DateTime
@@ -243,7 +250,16 @@ class LockScreen(Window):
return
self.lock.unlock_and_destroy()
self.destroy()
GLib.idle_add(app.quit) # schedules quit after unlock
_app = globals().get("app")
if _app is not None:
GLib.idle_add(_app.quit)
class LockScreenWrapper:
"""Thin wrapper exposing lock.screen() for fabric-cli exec."""
def lock(self):
exec_shell_command_async(f"python3 {get_relative_path('../../start.py')} lock")
def initialize():
+17 -1
View File
@@ -16,6 +16,7 @@ class BaseOSDContainer(Box):
self.osd = None
self.show_timestamp: float = 0.0
self.watchdog_handler: int = 0
self._hide_timer_id: int | None = None
self._update_in_progress = False
self._is_hovered = False
self._last_trigger_time: float = 0.0
@@ -31,6 +32,7 @@ class BaseOSDContainer(Box):
return False
def destroy(self):
self._cancel_hide_timer()
self.cleanup_all_handlers()
super().destroy()
@@ -67,13 +69,27 @@ class BaseOSDContainer(Box):
self.show_timestamp = 0.0
self._update_in_progress = False
def _cancel_hide_timer(self):
if self._hide_timer_id is not None:
try:
GLib.source_remove(self._hide_timer_id)
except Exception:
pass
self._hide_timer_id = None
def hide_window(self):
self._cancel_hide_timer()
if self.osd and hasattr(self.osd, "revealer"):
self.osd.revealer.set_reveal_child(False)
GLib.timeout_add(150, lambda: self.window.hide() or False)
self._hide_timer_id = GLib.timeout_add(150, self._on_hide_timer_done)
else:
self.window.hide()
def _on_hide_timer_done(self):
self._hide_timer_id = None
self.window.hide()
return False
def watchdog_force_hide(self, *_):
if not self.window.get_visible():
self.cleanup_all_handlers()
+5 -1
View File
@@ -153,7 +153,11 @@ class SystemDropdown(ModusDropdown):
dropdown_option("Restart", "", "systemctl reboot"),
dropdown_option("Shut Down", "", "shutdown now"),
dropdown_divider("---------------------"),
dropdown_option("Lock Screen", "󰘳 L", "hyprlock"),
dropdown_option(
"Lock Screen",
"󰘳 L",
'fabric-cli exec modus "lock_screen.lock()"',
),
],
**kwargs,
)
+5
View File
@@ -334,6 +334,11 @@ class SettingsWindow(Gtk.Window):
return SettingsPage(
"General Settings",
[
SettingsRow(
"Debug Mode",
SettingsSwitch("debug"),
"Enable verbose logging for development",
),
SettingsRow(
"Weather Location",
SettingsEntry("weather_location"),
+4 -1
View File
@@ -78,7 +78,10 @@ class SearchPipeline:
t.join(timeout=0.1)
self._pending_threads.clear()
for source_id in self._pending_idle_ids:
GLib.source_remove(source_id)
try:
GLib.source_remove(source_id)
except Exception:
pass
self._pending_idle_ids.clear()
def _route(self, query: str) -> tuple[PluginEntry | None, str, str]:
+1 -1
View File
@@ -53,7 +53,7 @@ class PowerMenuPlugin(SpotlightPlugin):
"id": "lock",
"name": "Lock Screen",
"icon": "system-lock-screen",
"command": "hyprlock",
"command": 'fabric-cli exec modus "lock_screen.lock()"',
"description": "Lock the screen",
},
]