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