From 4549eee5bb32e1f0f04ff948311bbe5a90e86d4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:55:36 +0000 Subject: [PATCH 1/3] chore(deps): bump github/codeql-action from 3 to 4.37.3 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.37.3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4.37.3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.3 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aadc93e..a589d60 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,14 +24,14 @@ jobs: - uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4.37.3 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4.37.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4.37.3 with: category: "/language:${{ matrix.language }}" -- 2.43.0 From 55fc3fba21ce4cdf9050810c89072a31cc8d393b Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 1 Aug 2026 19:23:56 +0545 Subject: [PATCH 2/3] fix: drop notification header icon, defer spotlight paste quit, use hyprctl send_shortcut --- src/utils/functions.py | 9 ++++- src/window/notification/notification.py | 46 ++++--------------------- src/window/spotlight/app.py | 5 ++- 3 files changed, 19 insertions(+), 41 deletions(-) diff --git a/src/utils/functions.py b/src/utils/functions.py index a487e7a..4288ca2 100644 --- a/src/utils/functions.py +++ b/src/utils/functions.py @@ -198,7 +198,14 @@ def read_json_file(file_path: str) -> dict | None: def trigger_paste_shortcut(delay_ms: int = 50) -> bool: time.sleep(delay_ms / 1000) - result = run_command(["wtype", "-M", "ctrl", "v", "-m", "ctrl"], timeout=1) + result = run_command( + [ + "hyprctl", + "dispatch", + 'hl.dsp.send_shortcut({ mods = "CTRL", key = "V" })', + ], + timeout=1, + ) return result.returncode == 0 diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py index acad904..f4423c8 100644 --- a/src/window/notification/notification.py +++ b/src/window/notification/notification.py @@ -22,7 +22,7 @@ from shared.widgets.clipping_box import ClippingBox from shared.widgets.close_button_revealer import CloseButtonRevealerMixin from shared.widgets.custom_image import CustomImage from shared.widgets.customrevealer import SlideRevealer -from utils.gtk_utils import setup_cursor_hover, svg_file +from utils.gtk_utils import setup_cursor_hover from utils.functions import escape_markup_text from utils.roam import modus_service from utils.icon_resolver import IconResolver @@ -531,44 +531,12 @@ class NotificationWidget(Box): self.show_all() def create_header(self, notification): - """Create notification header with app icon + app name + time""" - try: - cached_app_icon_pixbuf = cache_notification_icon( - notification.app_icon or notification.app_name, (64, 64) - ) - if cached_app_icon_pixbuf: - header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple( - 16, 16, GdkPixbuf.InterpType.BILINEAR - ) - app_icon = ClippingBox( - name="notification-icon", - children=Image(pixbuf=header_icon_pixbuf), - ) - else: - app_icon = ClippingBox( - name="notification-icon", - children=svg_file("notifications/notification-active.svg", size=16), - ) - except Exception as e: - logger.warning(f"Failed to load cached header icon: {e}") - app_icon = ClippingBox( - name="notification-icon", - children=svg_file("notifications/notification-active.svg", size=16), - ) - - return Box( - name="notification-header", - orientation="h", - spacing=4, - children=[ - app_icon, - Label( - notification.app_name or "", - name="notification-app-name", - h_align="start", - ellipsization="end", - ), - ], + """Create notification header with app name + time""" + return Label( + notification.app_name or "", + name="notification-app-name", + h_align="start", + ellipsization="end", ) def create_content(self, notification): diff --git a/src/window/spotlight/app.py b/src/window/spotlight/app.py index bcf1b4d..fa522dd 100644 --- a/src/window/spotlight/app.py +++ b/src/window/spotlight/app.py @@ -128,7 +128,10 @@ def main(command: str = "", text: str = "", external: bool = False): def _close_and_quit(): _orig_close() - app.quit() + # Plugins schedule the paste via GLib.timeout_add (emoji/clipboard), + # which only fires while the main loop runs. Quit a tick later so a + # deferred paste is not killed before it can inject the shortcut. + GLib.timeout_add(100, app.quit) spotlight.close_spotlight = _close_and_quit -- 2.43.0 From d8696b9198de8174692a0120fca96e9f9a04f094 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 1 Aug 2026 19:43:10 +0545 Subject: [PATCH 3/3] fix: add destroy() and signal cleanup across widgets, fix settings window + menu regressions P0 leak fixes: context/action menus destroyed on deactivate, config callbacks unregistered in settings widgets, recorder and server signal IDs stored and disconnected, bluetooth client closed and unsubscribed, rfkill timer removed, seek timers cancelled, dropdown and options menus freed, app name cache LRU-bounded. Fix desktop 'Edit Widgets' and settings menu regressions by deferring destroy out of Gtk signal handlers via GLib.idle_add. Fix settings entry fields by connecting the activate signal explicitly (Enter saves the value). --- src/services/bluetooth.py | 54 ++++++++++++------- src/shared/window/battery_widget.py | 9 ++++ src/shared/window/dropdown.py | 4 ++ src/utils/app_name_resolver.py | 32 ++++++----- src/window/controlcenter/bluetooth.py | 6 +++ src/window/controlcenter/expanded_player.py | 6 +++ src/window/desktop/main.py | 9 +++- src/window/notification/notification.py | 23 +++++++- .../notification/notification_center.py | 4 ++ src/window/panel/components/indicators.py | 4 ++ src/window/screencapture/main.py | 23 ++++++-- src/window/screencapture/options_menu.py | 4 ++ src/window/settings/main.py | 46 +++++++++++++--- 13 files changed, 180 insertions(+), 44 deletions(-) diff --git a/src/services/bluetooth.py b/src/services/bluetooth.py index 0285924..ce1b21e 100644 --- a/src/services/bluetooth.py +++ b/src/services/bluetooth.py @@ -677,32 +677,46 @@ class BluetoothClient(Service): None, ) - self._bus.signal_subscribe( - BLUEZ_SERVICE, - DBUS_OM_IFACE, - "InterfacesAdded", - None, - None, - Gio.DBusSignalFlags.NONE, - self._on_interfaces_added, - None, - ) - self._bus.signal_subscribe( - BLUEZ_SERVICE, - DBUS_OM_IFACE, - "InterfacesRemoved", - None, - None, - Gio.DBusSignalFlags.NONE, - self._on_interfaces_removed, - None, - ) + self._bus_sub_ids = [ + self._bus.signal_subscribe( + BLUEZ_SERVICE, + DBUS_OM_IFACE, + "InterfacesAdded", + None, + None, + Gio.DBusSignalFlags.NONE, + self._on_interfaces_added, + None, + ), + self._bus.signal_subscribe( + BLUEZ_SERVICE, + DBUS_OM_IFACE, + "InterfacesRemoved", + None, + None, + Gio.DBusSignalFlags.NONE, + self._on_interfaces_removed, + None, + ), + ] self._cached_rfkill_blocked = _rfkill_soft_blocked() self._rfkill_timeout_id = GLib.timeout_add_seconds(10, self._check_rfkill) self._populate_from_object_manager() + def close(self): + """Release the rfkill timer and unsubscribe from the DBus bus.""" + if self._rfkill_timeout_id: + GLib.source_remove(self._rfkill_timeout_id) + self._rfkill_timeout_id = 0 + for sub_id in self._bus_sub_ids: + self._bus.signal_unsubscribe(sub_id) + self._bus_sub_ids.clear() + for adapter in list(self._adapters.values()): + adapter.close() + self._adapters.clear() + def _check_rfkill(self) -> bool: blocked = _rfkill_soft_blocked() if blocked != self._cached_rfkill_blocked: diff --git a/src/shared/window/battery_widget.py b/src/shared/window/battery_widget.py index c6756ee..f364b81 100644 --- a/src/shared/window/battery_widget.py +++ b/src/shared/window/battery_widget.py @@ -241,6 +241,15 @@ class GameModeButton(Box): else: self.game_icon.remove_style_class("connected") + def destroy(self): + try: + self.gamemode_service.disconnect_by_func(self.update_state) + except Exception as e: + logger.warning( + f"[battery_widget] disconnect gamemode update_state failed: {e}" + ) + super().destroy() + class BatteryControl(Box): def __init__(self, parent, **kwargs): diff --git a/src/shared/window/dropdown.py b/src/shared/window/dropdown.py index a18b0bc..b2a3265 100644 --- a/src/shared/window/dropdown.py +++ b/src/shared/window/dropdown.py @@ -106,6 +106,10 @@ class ModusDropdown: def popup(self, widget, source_button=None): self.popup_at_widget(widget, source_button) + def destroy(self): + """Destroy the Gtk.Menu and its children.""" + self._menu.destroy() + def dropdown_option(label, accel=None, command=None, callback=None, sensitive=True): """Create a dropdown menu option dict. diff --git a/src/utils/app_name_resolver.py b/src/utils/app_name_resolver.py index 5057b04..493ce5b 100644 --- a/src/utils/app_name_resolver.py +++ b/src/utils/app_name_resolver.py @@ -1,14 +1,18 @@ import os +from collections import OrderedDict class AppName: + _MAX_CACHE = 128 + def __init__(self, path="/usr/share/applications"): self.files = os.listdir(path) self.path = path - self._cache: dict[str, str | None] = {} + self._cache: OrderedDict[str, str | None] = OrderedDict() def get_app_name(self, wmclass, _format_=False): if wmclass in self._cache: + self._cache.move_to_end(wmclass) return self._cache[wmclass] desktop_file = "" @@ -17,18 +21,22 @@ class AppName: desktop_file = f if desktop_file == "": - self._cache[wmclass] = None - return None + result = None + else: + desktop_app_name = wmclass + with open(os.path.join(self.path, desktop_file)) as f: + lines = f.readlines() + for line in lines: + if line.startswith("Name="): + desktop_app_name = line.split("=")[1].strip() + break + result = desktop_app_name - desktop_app_name = wmclass - with open(os.path.join(self.path, desktop_file)) as f: - lines = f.readlines() - for line in lines: - if line.startswith("Name="): - desktop_app_name = line.split("=")[1].strip() - break - self._cache[wmclass] = desktop_app_name - return desktop_app_name + self._cache[wmclass] = result + self._cache.move_to_end(wmclass) + if len(self._cache) > self._MAX_CACHE: + self._cache.popitem(last=False) + return result def format_app_name(self, title, wmclass, update=False): # Handle case when both title and wmclass are empty (no active window) diff --git a/src/window/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py index bfa984b..7e549e4 100644 --- a/src/window/controlcenter/bluetooth.py +++ b/src/window/controlcenter/bluetooth.py @@ -284,6 +284,7 @@ class BluetoothDeviceSlot(Box): menu.append(item_remove) menu.show_all() + menu.connect("deactivate", lambda m: GLib.idle_add(m.destroy)) menu.popup_at_pointer(event) return True @@ -661,6 +662,11 @@ class BluetoothConnections(PullToRefreshMixin, Box): f"[Bluetooth] Failed to disconnect client signal {sig_id}: {e}" ) self._client_signal_ids.clear() + if self.client: + try: + self.client.close() + except Exception as e: + logger.error(f"[Bluetooth] Failed to close client: {e}") def close_bluetooth(self): """Called when Bluetooth panel is being closed""" diff --git a/src/window/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py index 7120dc0..15ebd2e 100644 --- a/src/window/controlcenter/expanded_player.py +++ b/src/window/controlcenter/expanded_player.py @@ -460,6 +460,12 @@ class PlayerBox(Box): def destroy(self): self.exit = True self.suspend() + if self._seek_timeout: + GLib.source_remove(self._seek_timeout) + self._seek_timeout = None + if self._debounce_seek: + GLib.source_remove(self._debounce_seek) + self._debounce_seek = None for obj, handler_id in self._signal_connections: try: obj.disconnect(handler_id) diff --git a/src/window/desktop/main.py b/src/window/desktop/main.py index 24455a2..7a7a6bf 100644 --- a/src/window/desktop/main.py +++ b/src/window/desktop/main.py @@ -164,6 +164,7 @@ class DesktopWidgetWindow(WaylandWindow): self._dragging_eb: Gtk.EventBox | None = None self._drag_drop_success: bool = False self._input_rebuild_pending: bool = False + self._context_menu: Gtk.Menu | None = None self._root = Box(h_expand=True, v_expand=True) self._root.add(self._fixed) @@ -296,7 +297,7 @@ class DesktopWidgetWindow(WaylandWindow): return False def _show_context_menu(self, event: Gdk.EventButton) -> None: - menu = Gtk.Menu() + menu = self._context_menu = Gtk.Menu() label_text = "Done Edit" if self._edit_mode else "Edit Widgets" edit_item = Gtk.CheckMenuItem(label=label_text) @@ -304,9 +305,15 @@ class DesktopWidgetWindow(WaylandWindow): edit_item.connect("toggled", self._on_edit_toggled) menu.append(edit_item) + menu.connect("deactivate", self._on_context_menu_deactivate) menu.show_all() menu.popup_at_pointer(event) + def _on_context_menu_deactivate(self, menu) -> None: + if self._context_menu is menu: + self._context_menu = None + GLib.idle_add(menu.destroy) + # edit mode def _on_edit_toggled(self, item) -> None: diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py index f4423c8..62c9587 100644 --- a/src/window/notification/notification.py +++ b/src/window/notification/notification.py @@ -16,7 +16,7 @@ from fabric.widgets.revealer import Revealer from fabric.widgets.wayland import WaylandWindow as Window import shared.data as data -from services.config import get_config, on_config_change +from services.config import get_config, off_config_change, on_config_change from services.modus import notification_service from shared.widgets.clipping_box import ClippingBox from shared.widgets.close_button_revealer import CloseButtonRevealerMixin @@ -1158,7 +1158,9 @@ class ModusNoti(Window): self.TRANSITION_DELAY = 100 # Smoother transition timing self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications - self._server.connect("notification-added", self.on_new_notification) + self._notification_added_handler_id = self._server.connect( + "notification-added", self.on_new_notification + ) Window.__init__( self, anchor="top right", @@ -1438,3 +1440,20 @@ class ModusNoti(Window): self.update_config(changes) except Exception as e: logger.error(f"[ModusNoti] Error handling config change: {e}") + + def destroy(self): + off_config_change(self._on_config_changed) + if self._transition_timer_id: + GLib.source_remove(self._transition_timer_id) + self._transition_timer_id = None + if self._debounce_timer_id: + GLib.source_remove(self._debounce_timer_id) + self._debounce_timer_id = None + if self._notification_added_handler_id: + try: + self._server.disconnect(self._notification_added_handler_id) + except Exception as e: + logger.error(f"[ModusNoti] Failed to disconnect server: {e}") + self._notification_added_handler_id = None + self.clear_notification_queue() + super().destroy() diff --git a/src/window/notification/notification_center.py b/src/window/notification/notification_center.py index fcdc381..918a05d 100644 --- a/src/window/notification/notification_center.py +++ b/src/window/notification/notification_center.py @@ -662,6 +662,7 @@ class NotificationCenterWidget(CloseButtonRevealerMixin, NotificationWidget): menu.append(close_item) menu.show_all() + self._action_menu = menu def show_menu(btn): # Show the menu under the button @@ -712,6 +713,9 @@ class NotificationCenterWidget(CloseButtonRevealerMixin, NotificationWidget): def destroy(self): self._cancel_close_button_hide() + if hasattr(self, "_action_menu") and self._action_menu is not None: + self._action_menu.destroy() + self._action_menu = None super().destroy() diff --git a/src/window/panel/components/indicators.py b/src/window/panel/components/indicators.py index db72eed..12e9925 100644 --- a/src/window/panel/components/indicators.py +++ b/src/window/panel/components/indicators.py @@ -498,6 +498,10 @@ def add_destroy_to_indicators(): self.bluetooth.disconnect_by_func(self.on_bluetooth_direct_changed) self.bluetooth.disconnect_by_func(self.on_device_added) self.bluetooth.disconnect_by_func(self.on_device_removed) + try: + self.bluetooth.close() + except Exception as e: + logger.error(f"Error closing bluetooth indicator client: {e}") except Exception as e: logger.error(f"Error disconnecting bluetooth indicator signals: {e}") try: diff --git a/src/window/screencapture/main.py b/src/window/screencapture/main.py index aebabea..58e9aac 100644 --- a/src/window/screencapture/main.py +++ b/src/window/screencapture/main.py @@ -101,11 +101,28 @@ class ScreenCaptureWindow(Window): self._tool_group.select_id("screenshot-screen") self._tool_group.on_change(self._on_tool_changed) - screen_recorder_service.connect("started", self._on_recording_started) - screen_recorder_service.connect("stopped", self._on_recording_stopped) - self.connect("key-press-event", self._on_key_press) + self._recorder_handler_ids = [ + screen_recorder_service.connect("started", self._on_recording_started), + screen_recorder_service.connect("stopped", self._on_recording_stopped), + ] + + def destroy(self): + for hid in self._recorder_handler_ids: + try: + screen_recorder_service.disconnect(hid) + except Exception as e: + logger.warning(f"[main] disconnect recorder signal failed: {e}") + self._recorder_handler_ids = [] + if self._delay_timeout_id is not None: + GLib.source_remove(self._delay_timeout_id) + self._delay_timeout_id = None + if self._options_menu is not None: + self._options_menu.destroy() + self._options_menu = None + super().destroy() + def do_focus(self, direction): """ Disable GTK's built-in Tab/Shift+Tab focus traversal so those keys diff --git a/src/window/screencapture/options_menu.py b/src/window/screencapture/options_menu.py index 9438964..c960408 100644 --- a/src/window/screencapture/options_menu.py +++ b/src/window/screencapture/options_menu.py @@ -213,6 +213,10 @@ class OptionsMenu: None, ) + def destroy(self): + """Destroy the persistent Gtk.Menu and its children.""" + self._menu.destroy() + def get_timer_delay(self) -> int: return [0, 5, 10][self._timer_idx] diff --git a/src/window/settings/main.py b/src/window/settings/main.py index 41a1d04..61b6e51 100644 --- a/src/window/settings/main.py +++ b/src/window/settings/main.py @@ -1,4 +1,4 @@ -from fabric.utils import Gdk, Gtk +from fabric.utils import Gdk, GLib, Gtk from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox @@ -7,7 +7,7 @@ from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.stack import Stack -from services.config import config, on_config_change +from services.config import config, off_config_change, on_config_change from shared.widgets.smooth_switch import SmoothSwitch from utils.gtk_utils import setup_cursor_hover, svg_file @@ -66,6 +66,10 @@ class SettingsSwitch(SmoothSwitch): config().set(self.config_key, state) config().save() + def destroy(self): + off_config_change(self._on_config_change) + super().destroy() + class SettingsEntry(Entry): def __init__(self, config_key: str, **kwargs): @@ -75,9 +79,9 @@ class SettingsEntry(Entry): super().__init__( name="settings-entry", text=initial_value, - on_activate=self.on_change, **kwargs, ) + self.connect("activate", self.on_change) # Sync with external config changes on_config_change(self._on_config_change) @@ -101,6 +105,10 @@ class SettingsEntry(Entry): config().set(self.config_key, value) config().save() + def destroy(self): + off_config_change(self._on_config_change) + super().destroy() + class SettingsComboBox(Gtk.ComboBoxText): def __init__(self, config_key: str, options: list, **kwargs): @@ -134,6 +142,10 @@ class SettingsComboBox(Gtk.ComboBoxText): config().set(self.config_key, value) config().save() + def destroy(self): + off_config_change(self._on_config_change) + super().destroy() + class SettingsList(Box): """A list of tags that can be added or removed""" @@ -146,8 +158,8 @@ class SettingsList(Box): self.entry = Entry( name="settings-list-entry", placeholder="Add new item...", - on_activate=self.on_add, ) + self.entry.connect("activate", self.on_add) self.add(self.list_box) self.add(self.entry) @@ -206,6 +218,10 @@ class SettingsList(Box): config().save() self._update_list() + def destroy(self): + off_config_change(self._on_config_change) + super().destroy() + class SettingsPage(ScrolledWindow): def __init__(self, title: str, rows: list, **kwargs): @@ -246,8 +262,11 @@ class SettingsWindow(Gtk.Window): self.set_position(Gtk.WindowPosition.CENTER) self.set_visible(False) - # Hide instead of destroy so the __main__ reference stays valid - self.connect("delete-event", lambda *_: self.hide() or True) + # Destroy on close so the widget tree is freed; get_settings_window + # recreates it on next open. Destruction is deferred out of the + # delete-event emission to avoid tearing the window down mid-signal. + self.connect("delete-event", self._on_delete_event) + self.connect("destroy", self._on_window_destroyed) self.stack = Stack( name="settings-stack", @@ -508,6 +527,11 @@ class SettingsWindow(Gtk.Window): ) def toggle(self): + if getattr(self, "_destroyed", False): + # A stale reference (e.g. __main__.settings) outlived this window. + get_settings_window().toggle() + return + if not self.get_visible(): self.show_all() self.present() @@ -522,6 +546,16 @@ class SettingsWindow(Gtk.Window): else: self.hide() + def _on_delete_event(self, *_): + GLib.idle_add(self.destroy) + return True + + def _on_window_destroyed(self, *_): + global _settings_window + self._destroyed = True + if _settings_window is self: + _settings_window = None + _settings_window = None -- 2.43.0