refactor: clean up service implementations and remove dead code

This commit is contained in:
sankalp committed 2026-07-08 12:24:39 +05:45
1 parent 9a1f512a28
commit 06be28535a
22 files changed
+2067 -756

No files matched your search

+148 -12
View File
@@ -4,10 +4,11 @@ Supports:
- com.canonical.dbusmenu (KDE / Unity / XFCE)
- GTK appmenu-module DBusMenu
- GTK GMenuModel fallback (best-effort)
- Incremental updates via DBusMenu signals
"""
import threading
from typing import List, Optional
from typing import Callable, Dict, List, Optional, Tuple
from fabric.utils import Any, Gio, GLib, logger
@@ -25,7 +26,6 @@ def _get_bus() -> Gio.DBusConnection:
return _SESSION_BUS
# DATA MODEL — __slots__ cuts ~200 bytes per instance
class DBusMenuItem:
__slots__ = (
"id",
@@ -39,6 +39,7 @@ class DBusMenuItem:
"children",
"action_name",
"action_target",
"parent_id",
)
def __init__(
@@ -54,6 +55,7 @@ class DBusMenuItem:
children: Optional[List["DBusMenuItem"]] = None,
action_name: str = "",
action_target: Optional[Any] = None,
parent_id: int = -1,
):
self.id = id
self.label = label
@@ -66,9 +68,9 @@ class DBusMenuItem:
self.children: List["DBusMenuItem"] = children if children is not None else []
self.action_name = action_name
self.action_target = action_target
self.parent_id = parent_id
# MAIN CLIENT
class DBusMenuClient:
__slots__ = (
"service_name",
@@ -78,6 +80,11 @@ class DBusMenuClient:
"_hash_cache",
"_lock",
"_fetching",
"_revision",
"_signal_ids",
"_on_layout_updated_cb",
"_on_items_updated_cb",
"_on_item_activated_cb",
)
def __init__(self, service_name: str, object_path: str):
@@ -87,11 +94,15 @@ class DBusMenuClient:
self._cache: Optional[List[DBusMenuItem]] = None
self._cache_valid = False
self._hash_cache: Optional[str] = None
self._revision: int = 0
self._lock = threading.Lock()
self._fetching = False
self._signal_ids: List[int] = []
self._on_layout_updated_cb: Optional[Callable] = None
self._on_items_updated_cb: Optional[Callable] = None
self._on_item_activated_cb: Optional[Callable] = None
# DBUS CALL — uses shared session bus
def _call(self, method, params, reply_type=None):
try:
return _get_bus().call_sync(
@@ -108,7 +119,6 @@ class DBusMenuClient:
except Exception:
return None
# PUBLIC ENTRY
def get_layout(self, force_refresh=False):
if self._cache and self._cache_valid and not force_refresh:
return self._cache
@@ -124,7 +134,6 @@ class DBusMenuClient:
with self._lock:
self._fetching = False
# CORE FETCH LOGIC
def _fetch(self):
params = GLib.Variant("(iias)", (0, -1, []))
res = self._call("GetLayout", params)
@@ -133,6 +142,9 @@ class DBusMenuClient:
return []
try:
revision = res.get_child_value(0).get_uint32()
self._revision = revision
layout = res.get_child_value(1)
while layout.is_of_type(GLib.VariantType("v")):
layout = layout.get_variant()
@@ -152,7 +164,6 @@ class DBusMenuClient:
logger.error(f"[Menu] parse error: {e}")
return []
# HASH DIFF SYSTEM
def _hash(self, items):
flat: List[str] = []
@@ -164,8 +175,7 @@ class DBusMenuClient:
walk(items)
return str(hash("".join(flat)))
# DBUSMENU PARSER
def _parse_dbusmenu(self, node, _depth: int = 0):
def _parse_dbusmenu(self, node, _depth: int = 0, _parent_id: int = -1):
if _depth > 20:
return DBusMenuItem(id=0)
item = DBusMenuItem(id=0)
@@ -175,6 +185,7 @@ class DBusMenuClient:
return item
item.id = node.get_child_value(0).get_int32()
item.parent_id = _parent_id
try:
props = node.get_child_value(1).unpack()
@@ -215,7 +226,7 @@ class DBusMenuClient:
c = children.get_child_value(i)
if c.is_of_type(GLib.VariantType("v")):
c = c.get_variant()
item.children.append(self._parse_dbusmenu(c, _depth + 1))
item.children.append(self._parse_dbusmenu(c, _depth + 1, item.id))
if item.has_submenu and not item.children:
try:
@@ -228,7 +239,6 @@ class DBusMenuClient:
return item
# ACTIONS
def about_to_show(self, item_id):
try:
res = self._call(
@@ -250,7 +260,6 @@ class DBusMenuClient:
except Exception:
pass
# CACHE CONTROL
def invalidate(self):
self._cache_valid = False
@@ -263,3 +272,130 @@ class DBusMenuClient:
def on_focus(self):
self.prefetch_async()
def get_revision(self) -> int:
return self._revision
def connect_signals(
self,
on_layout_updated: Optional[Callable] = None,
on_items_updated: Optional[Callable] = None,
on_item_activated: Optional[Callable] = None,
):
self._on_layout_updated_cb = on_layout_updated
self._on_items_updated_cb = on_items_updated
self._on_item_activated_cb = on_item_activated
bus = _get_bus()
def handle_layout_updated(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
parent_id, revision, properties = params.unpack()
self._revision = revision
if self._on_layout_updated_cb:
self._on_layout_updated_cb(parent_id, revision, properties)
except Exception as e:
logger.debug(f"[DBusMenuClient] LayoutUpdated handler error: {e}")
def handle_items_properties_updated(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
updated_props, removed_ids = params.unpack()
if self._on_items_updated_cb:
self._on_items_updated_cb(updated_props, removed_ids)
self._cache_valid = False
except Exception as e:
logger.debug(f"[DBusMenuClient] ItemsPropertiesUpdated error: {e}")
def handle_item_activation_requested(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
item_id, timestamp = params.unpack()
if self._on_item_activated_cb:
self._on_item_activated_cb(item_id, timestamp)
except Exception as e:
logger.debug(f"[DBusMenuClient] ItemActivationRequested error: {e}")
for sig_name, handler in [
("LayoutUpdated", handle_layout_updated),
("ItemsPropertiesUpdated", handle_items_properties_updated),
("ItemActivationRequested", handle_item_activation_requested),
]:
sid = bus.signal_subscribe(
self.service_name,
"com.canonical.dbusmenu",
sig_name,
self.object_path,
None,
Gio.DBusCallFlags.NONE,
handler,
None,
)
if sid:
self._signal_ids.append(sid)
def disconnect_signals(self):
if not self._signal_ids:
return
bus = _get_bus()
for sid in self._signal_ids:
try:
bus.signal_unsubscribe(sid)
except Exception:
pass
self._signal_ids.clear()
def update_layout(
self, parent_id: int, revision: int
) -> Optional[List[DBusMenuItem]]:
self._revision = revision
params = GLib.Variant("(iias)", (parent_id, revision, []))
res = self._call("GetLayout", params)
if not res:
return None
try:
layout = res.get_child_value(1)
while layout.is_of_type(GLib.VariantType("v")):
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
return parsed
except Exception as e:
logger.error(f"[Menu] update_layout error: {e}")
return None
def apply_items_update(
self, updated_props: List[Tuple[int, Dict]], removed_ids: List[int]
):
if not self._cache:
return
removed_set = set(removed_ids)
def walk(items):
result = []
for item in items:
if item.id in removed_set:
continue
for uid, props in updated_props:
if item.id == uid:
if "label" in props:
item.label = str(props["label"]).replace("_", "")
if "enabled" in props:
item.enabled = bool(props["enabled"])
if "visible" in props:
item.visible = bool(props["visible"])
if "type" in props:
item.type = str(props["type"])
item.children = walk(item.children)
result.append(item)
return result
self._cache = walk(self._cache)
self._hash_cache = None
+51 -16
View File
@@ -7,7 +7,6 @@ import threading
from typing import Dict, NamedTuple, Optional, TypeVar
from fabric.utils import (
exec_shell_command,
exec_shell_command_async,
logger,
)
@@ -187,28 +186,64 @@ def escape_markup_text(text: str) -> str:
return html.escape(text.replace("\n", " "))
# Function to toggle a shell command
def toggle_command(command: str, full_command: str):
if is_app_running(command):
kill_process(command)
else:
subprocess.Popen(
full_command.split(" "),
stdin=subprocess.DEVNULL, # No input stream
stdout=subprocess.DEVNULL, # Optionally discard the output
stderr=subprocess.DEVNULL, # Optionally discard the error output
start_new_session=True, # This prevents the process from being killed
)
# --- Process management ---
def spawn_detached(args: list[str]) -> subprocess.Popen:
return subprocess.Popen(
args,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
# Function to execute a shell command asynchronously
def kill_process(process_name: str):
exec_shell_command_async(f"pkill {process_name}", lambda *_: None)
# Function to check if an app is running
def is_process_running(process_name: str) -> bool:
try:
result = subprocess.run(["pidof", process_name], capture_output=True, text=True)
return bool(result.stdout.strip())
except Exception:
return False
def is_app_running(app_name: str) -> bool:
return len(exec_shell_command(f"pidof {app_name}")) != 0
return is_process_running(app_name)
def find_process_pid(process_name: str, timeout: float | None = None) -> list[str]:
try:
result = subprocess.run(
["pgrep", "-f", process_name],
capture_output=True,
text=True,
timeout=timeout,
)
return [pid for pid in result.stdout.strip().split() if pid]
except Exception:
return []
def toggle_command(command: str, full_command: str):
if is_process_running(command):
kill_process(command)
else:
spawn_detached(full_command.split(" "))
# --- Binary lookup ---
def find_binary(name: str) -> str | None:
try:
result = subprocess.run(["which", name], capture_output=True, text=True)
return result.stdout.strip() or None
except Exception:
return None
# General utilities
+366 -34
View File
@@ -1,24 +1,43 @@
import re
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
from fabric.utils import Gio, GLib, logger
from utils.dbusmenu import DBusMenuItem, _get_bus
_SUBMENU_RE = re.compile(r"submenu")
class _ActionInfo:
__slots__ = ("enabled", "parameter_type", "state", "state_type")
def __init__(
self,
enabled: bool = True,
parameter_type: str = "",
state: Optional[Any] = None,
state_type: str = "",
):
self.enabled = enabled
self.parameter_type = parameter_type
self.state = state
self.state_type = state_type
class GtkMenuClient:
__slots__ = ("service_name", "object_path", "_action_path", "_cached_items")
__slots__ = (
"service_name",
"object_path",
"_action_path",
"_cached_items",
"_action_cache",
)
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
# Actions live at the parent of /menus/...
self._action_path = (
object_path.split("/menus/")[0] if "/menus/" in object_path else object_path
)
self._cached_items: List[DBusMenuItem] = []
self._action_cache: Dict[str, _ActionInfo] = {}
def _call(self, interface, method, params, reply_type=None, path=None):
try:
@@ -37,10 +56,74 @@ class GtkMenuClient:
logger.debug(f"[GtkMenuClient] {method} failed: {e}")
return None
def get_layout(self, parent_id: int = 0) -> List[DBusMenuItem]:
items = self._fetch_menu(parent_id)
self._cached_items = items
return items
def get_layout(
self, parent_id: int = 0, force_refresh: bool = False
) -> List[DBusMenuItem]:
self._refresh_action_cache()
if force_refresh or not self._cached_items:
items = self._fetch_menu(parent_id)
self._cached_items = items
return self._cached_items
def _refresh_action_cache(self):
try:
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
path=self._action_path,
)
if not res:
return
data = res.get_child_value(0).unpack()
self._action_cache.clear()
for name, (enabled, param_type_v, state_v) in data.items():
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack() if state_v and state_v.n_children() > 0 else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
self._action_cache[name] = _ActionInfo(
enabled=enabled,
parameter_type=param_type,
state=state,
state_type=state_type,
)
except Exception as e:
logger.debug(f"[GtkMenuClient] DescribeAll cache error: {e}")
def _describe_action(self, action_name: str) -> Optional[_ActionInfo]:
cached = self._action_cache.get(action_name)
if cached is not None:
return cached
try:
res = self._call(
"org.gtk.Actions",
"Describe",
GLib.Variant("(s)", (action_name,)),
GLib.VariantType("(bgav)"),
path=self._action_path,
)
if res:
enabled, param_type_v, state_v = res.unpack()
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack() if state_v and state_v.n_children() > 0 else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
info = _ActionInfo(enabled, param_type, state, state_type)
self._action_cache[action_name] = info
return info
except Exception as e:
logger.debug(f"[GtkMenuClient] Describe error for {action_name}: {e}")
return None
def _find_item(
self, item_id: int, items: Optional[List[DBusMenuItem]] = None
@@ -153,20 +236,52 @@ class GtkMenuClient:
action_target = item.action_target
if not action_name:
return
# Strip the unity. prefix added by appmenu-gtk-module
if action_name.startswith("unity."):
action_name = action_name[6:]
action_info = self._describe_action(action_name)
use_change_state = False
if action_info and action_info.state is not None:
if action_info.state_type == "b":
use_change_state = True
elif action_info.parameter_type:
use_change_state = True
try:
params_arr = [action_target] if action_target is not None else []
# Try menu path first (Thunar), fall back to action path (LibreOffice)
for act_path in (self.object_path, self._action_path):
try:
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
path=act_path,
)
if (
use_change_state
and action_info
and action_info.state is not None
):
new_state = (
not action_info.state
if action_info.state_type == "b"
else action_info.state
)
self._call(
"org.gtk.Actions",
"ChangeState",
GLib.Variant(
"(sv)",
(
action_name,
GLib.Variant(action_info.state_type, new_state),
),
),
path=act_path,
)
else:
params_arr = (
[action_target] if action_target is not None else []
)
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
path=act_path,
)
return
except Exception:
continue
@@ -175,21 +290,28 @@ class GtkMenuClient:
class ActionMenuClient:
"""Flat menu from org.gtk.Actions when org.gtk.Menus is unavailable."""
__slots__ = ("service_name", "object_path", "_cached_items", "_action_map")
__slots__ = (
"service_name",
"object_path",
"_action_path",
"_cached_items",
"_action_map",
"_action_cache",
)
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
self._action_path = object_path
self._cached_items: List[DBusMenuItem] = []
self._action_map: Dict[int, str] = {}
self._action_cache: Dict[str, _ActionInfo] = {}
def _call(self, interface, method, params, reply_type=None):
def _call(self, interface, method, params, reply_type=None, path=None):
try:
return _get_bus().call_sync(
self.service_name,
self.object_path,
path or self.object_path,
interface,
method,
params,
@@ -201,9 +323,160 @@ class ActionMenuClient:
except Exception:
return None
def get_layout(self, parent_id: int = 0) -> List[DBusMenuItem]:
def _refresh_action_cache(self):
try:
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
)
if not res:
return
data = res.get_child_value(0).unpack()
self._action_cache.clear()
for name, (enabled, param_type_v, state_v) in data.items():
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack() if state_v and state_v.n_children() > 0 else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
self._action_cache[name] = _ActionInfo(
enabled=enabled,
parameter_type=param_type,
state=state,
state_type=state_type,
)
except Exception as e:
logger.debug(f"[ActionMenuClient] DescribeAll cache error: {e}")
def _describe_action(self, action_name: str) -> Optional[_ActionInfo]:
cached = self._action_cache.get(action_name)
if cached is not None:
return cached
try:
res = self._call(
"org.gtk.Actions",
"Describe",
GLib.Variant("(s)", (action_name,)),
GLib.VariantType("(bgav)"),
)
if res:
enabled, param_type_v, state_v = res.unpack()
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack() if state_v and state_v.n_children() > 0 else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
info = _ActionInfo(enabled, param_type, state, state_type)
self._action_cache[action_name] = info
return info
except Exception as e:
logger.debug(f"[ActionMenuClient] Describe error for {action_name}: {e}")
return None
def _fetch_menu(
self, menu_id: int, _depth: int = 0, _visited: Optional[set] = None
) -> List[DBusMenuItem]:
if _depth > 10:
return []
if _visited is None:
_visited = set()
if menu_id in _visited:
return []
_visited.add(menu_id)
res = self._call(
"org.gtk.Menus",
"Start",
GLib.Variant("(au)", ([menu_id],)),
GLib.VariantType("(a(uuaa{sv}))"),
)
if not res:
return []
try:
data = res.get_child_value(0).unpack()
if not data:
return []
entry_map = {}
for entry in data:
gid, mid, sections = entry
entry_map[(gid, mid)] = sections
items: List[DBusMenuItem] = []
def resolve(sections, visited=None):
if visited is None:
visited = set()
for props in sections:
if not isinstance(props, dict):
continue
if ":section" in props:
ref = props[":section"]
if isinstance(ref, (list, tuple)) and len(ref) == 2:
key = (int(ref[0]), int(ref[1]))
if key not in visited:
visited.add(key)
child = entry_map.get(key)
if child is not None:
resolve(child, visited)
continue
if not props.get("label") and not any(
k in props for k in ("action", ":submenu", "submenu")
):
continue
item = DBusMenuItem(id=hash((menu_id, len(items))))
item.label = props.get("label", "").replace("_", "")
item.enabled = props.get("enabled", True)
item.visible = props.get("visible", True)
sub_ref = props.get(":submenu") or props.get("submenu")
if (
sub_ref
and isinstance(sub_ref, (list, tuple))
and len(sub_ref) == 2
):
item.has_submenu = True
item.children = self._fetch_menu(
int(sub_ref[0]), _depth + 1, _visited
)
if "action" in props:
item.action_name = props["action"]
if "target" in props:
try:
item.action_target = GLib.Variant.new_variant(
GLib.Variant("v", props["target"])
)
except Exception:
pass
items.append(item)
if data:
resolve(data[0][2])
return items
except Exception as e:
logger.error(f"[ActionMenuClient] Parse error: {e}")
return []
def get_layout(
self, parent_id: int = 0, force_refresh: bool = False
) -> List[DBusMenuItem]:
if parent_id != 0:
return []
self._refresh_action_cache()
menu_items = self._fetch_menu(0)
if menu_items:
self._cached_items = menu_items
return menu_items
res = self._call(
"org.gtk.Actions",
"DescribeAll",
@@ -217,14 +490,30 @@ class ActionMenuClient:
data = res.get_child_value(0).unpack()
items: List[DBusMenuItem] = []
self._action_map = {}
for i, (name, (enabled, param_type, state)) in enumerate(data.items()):
for i, (name, (enabled, param_type_v, state_v)) in enumerate(data.items()):
label = name.replace("-", " ").replace("_", " ").strip().title()
param_type = str(param_type_v) if param_type_v else ""
state = None
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
try:
state = st.get_value()
except Exception:
state = state_v.unpack() if state_v else None
self._action_cache[name] = _ActionInfo(
enabled, param_type, state, state_type
)
item = DBusMenuItem(id=hash((0, i)))
item.action_name = name
# Hide actions that need parameters — they can't work in a flat menu
item.visible = not bool(param_type)
item.enabled = enabled if not param_type else False
item.visible = not bool(param_type) or param_type == "b"
can_activate = not bool(param_type)
item.enabled = enabled if can_activate else False
item.label = label
if param_type == "b":
item.type = "toggle"
item.enabled = enabled
self._action_map[item.id] = name
items.append(item)
self._cached_items = items
@@ -233,25 +522,68 @@ class ActionMenuClient:
logger.error(f"[ActionMenuClient] parse error: {e}")
return self._cached_items
def _find_item(
self, item_id: int, items: Optional[List[DBusMenuItem]] = None
) -> Optional[DBusMenuItem]:
if items is None:
items = self._cached_items
for item in items:
if item.id == item_id:
return item
if item.children:
found = self._find_item(item_id, item.children)
if found:
return found
return None
def about_to_show(self, item_id: int) -> bool:
return False
def invalidate(self):
self._cached_items = []
self._action_map = {}
self._action_cache.clear()
def click_item(
self, item_id: int, action_name: str = "", action_target=None
) -> None:
if not action_name:
action_name = self._action_map.get(item_id, "")
item = self._find_item(item_id)
if item and item.action_name:
action_name = item.action_name
action_target = item.action_target
if not action_name:
return
action_info = self._describe_action(action_name)
use_change_state = False
if action_info and action_info.state is not None:
if action_info.state_type == "b":
use_change_state = True
elif action_info.parameter_type:
use_change_state = True
try:
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, [], {})),
)
if use_change_state and action_info and action_info.state is not None:
new_state = (
not action_info.state
if action_info.state_type == "b"
else action_info.state
)
self._call(
"org.gtk.Actions",
"ChangeState",
GLib.Variant(
"(sv)",
(action_name, GLib.Variant(action_info.state_type, new_state)),
),
)
else:
params_arr = [action_target] if action_target is not None else []
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
)
except Exception as e:
logger.error(f"[ActionMenuClient] click failed: {e}")
+6
View File
@@ -5,6 +5,7 @@ from fabric.utils import GLib, Gtk, logger, os
import shared.data as data
ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json"
ICON_DICT_MAX = 500
if not os.path.exists(data.CACHE_DIR):
os.makedirs(data.CACHE_DIR)
@@ -23,6 +24,7 @@ class IconResolver:
else:
self._icon_dict = {}
self._icon_dict_order: list[str] = []
self.default_applicaiton_icon = default_applicaiton_icon
def get_icon_name(self, app_id: str):
@@ -58,6 +60,10 @@ class IconResolver:
def _store_new_icon(self, app_id: str, icon: str):
self._icon_dict[app_id] = icon
self._icon_dict_order.append(app_id)
while len(self._icon_dict) > ICON_DICT_MAX:
oldest = self._icon_dict_order.pop(0)
self._icon_dict.pop(oldest, None)
with open(ICON_CACHE_FILE, "w") as f:
json.dump(self._icon_dict, f)
+280
View File
@@ -0,0 +1,280 @@
import os
import gc
import tracemalloc
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_snapshot_counter = 0
_initial_snapshot = None
def _is_project_module(filename: str) -> bool:
return filename.startswith(PROJECT_ROOT) and ".venv" not in filename
def start_tracing() -> None:
"""Start tracemalloc + file-triggered dumps.
Only activates if MODUS_MEMTRACE env var is set.
Every 2s checks for /tmp/modus_dump. Touch that file to trigger a dump.
MODUS_MEMTRACE=1 uv run start
touch /tmp/modus_dump # triggers dump
"""
if not os.environ.get("MODUS_MEMTRACE"):
return
try:
tracemalloc.start(10)
global _initial_snapshot
_initial_snapshot = tracemalloc.take_snapshot()
from fabric.utils import GLib
GLib.timeout_add(2000, _poll_dump_file)
print("[memory_tracker] active — touch /tmp/modus_dump to dump", flush=True)
except Exception as e:
_log_error(f"start_tracing failed: {e}")
print(
f"[memory_tracker] FAILED: {e}",
file=__import__("sys").stderr,
flush=True,
)
def _log_error(msg: str) -> None:
try:
with open("/tmp/modus_memtrace_err.log", "a") as f:
f.write(f"{msg}\n")
except Exception:
pass
def _poll_dump_file() -> bool:
if os.path.exists("/tmp/modus_dump"):
try:
os.unlink("/tmp/modus_dump")
dump_memory_usage()
except Exception as e:
_log_error(f"dump failed: {e}")
return True
def _get_process_rss() -> int:
"""Return RSS in bytes from /proc/self/status."""
try:
with open("/proc/self/status") as f:
for line in f:
if line.startswith("VmRSS:"):
return int(line.split()[1]) * 1024
except Exception:
pass
return 0
def _estimate_obj_bytes(obj) -> int:
"""Best-effort byte estimate for a live object.
Uses special handling for known types, falls back to sys.getsizeof.
"""
import sys
try:
tname = type(obj).__name__
tmod = getattr(type(obj), "__module__", "")
# -- known C types with accessible pixel data --
if "Pixbuf" in tname:
try:
return obj.get_width() * obj.get_height() * obj.get_n_channels()
except Exception:
pass
if "Surface" in tname and "cairo" in tmod:
try:
return obj.get_width() * obj.get_height() * 4
except Exception:
pass
# -- strings (include internal buffer) --
if isinstance(obj, str):
return sys.getsizeof(obj)
# -- bytes / bytearray --
if isinstance(obj, (bytes, bytearray)):
return sys.getsizeof(obj)
# -- dicts / lists / tuples (shallow — contents counted separately) --
if isinstance(obj, (dict, list, tuple, set, frozenset)):
return sys.getsizeof(obj)
# -- GObject wrappers: return Python wrapper size (C struct not included) --
return sys.getsizeof(obj)
except Exception:
return 0
def _scan_all_objects() -> dict:
"""Scan ALL live Python objects, group by type, show counts + size estimates."""
by_type: dict[str, dict] = {}
total_count = 0
total_bytes = 0
for obj in gc.get_objects():
try:
tname = type(obj).__name__
tmod = getattr(type(obj), "__module__", "")
key = f"{tname}"
if tmod and tmod != "builtins":
key = f"{tmod}.{tname}"
if key not in by_type:
by_type[key] = {"count": 0, "bytes": 0}
by_type[key]["count"] += 1
b = _estimate_obj_bytes(obj)
by_type[key]["bytes"] += b
total_count += 1
total_bytes += b
except Exception:
pass
sorted_types = sorted(by_type.items(), key=lambda x: -x[1]["bytes"])[:40]
lines = []
lines.append("--- ALL LIVE OBJECTS BY TYPE (top 40) ---")
lines.append(f"{'Type':<55} {'Count':>8} {'Est. MB':>8} {'%':>6}")
lines.append("-" * 79)
for key, data in sorted_types:
mb = data["bytes"] / 1024 / 1024
pct = (data["bytes"] / total_bytes * 100) if total_bytes else 0
lines.append(f"{key:<55} {data['count']:>8} {mb:>8.2f} {pct:>5.1f}%")
# summarise 3rd-party C / external memory gap
rss = _get_process_rss()
gap_mb = (rss - total_bytes) / 1024 / 1024
if gap_mb > 0:
lines.append("")
lines.append(
f"--- C / non-Python memory (RSS − Python estimate): {gap_mb:.0f} MB ---"
)
lines.append(
" (GtkWidget C structs, GLib internals, stacks, mmap'd data, etc.)"
)
return {
"lines": lines,
"by_type": dict(sorted_types),
"total_count": total_count,
"total_bytes": total_bytes,
"gap_mb": gap_mb if gap_mb > 0 else 0,
}
def _get_module_label(filename: str) -> str:
if not _is_project_module(filename):
return "<external>"
rel = os.path.relpath(filename, PROJECT_ROOT)
return rel.replace(".py", "").replace("/", ".")
def dump_memory_usage() -> dict:
global _snapshot_counter
_snapshot_counter += 1
gc.collect()
rss = _get_process_rss()
snapshot = tracemalloc.take_snapshot()
growth = (
snapshot.compare_to(_initial_snapshot, "lineno") if _initial_snapshot else None
)
stats = snapshot.statistics("lineno")
module_totals: dict[str, dict] = {}
external_stats = {"count": 0, "size": 0}
for stat in stats:
filename = stat.traceback[0].filename
label = _get_module_label(filename)
if label != "<external>":
if label not in module_totals:
module_totals[label] = {"size": 0, "count": 0, "growth": 0}
module_totals[label]["size"] += stat.size
module_totals[label]["count"] += stat.count
else:
external_stats["size"] += stat.size
external_stats["count"] += stat.count
if growth:
for g in growth:
filename = g.traceback[0].filename
label = _get_module_label(filename)
if label in module_totals:
module_totals[label]["growth"] += g.size_diff
sorted_modules = sorted(
module_totals.items(), key=lambda x: x[1]["size"], reverse=True
)
total_size = sum(m["size"] for m in module_totals.values()) + external_stats["size"]
total_count = (
sum(m["count"] for m in module_totals.values()) + external_stats["count"]
)
lines = []
def w(s=""):
lines.append(s)
# === Process memory ===
w(f"=== Process RSS: {rss / 1024 / 1024:.1f} MB ===")
w("")
# === ALL live objects by type ===
scan = _scan_all_objects()
for line in scan["lines"]:
w(line)
w("")
# === Python allocations ===
w("--- Python Allocations (tracemalloc) ---")
w(f"{'Module':<50} {'Count':>8} {'Size (MB)':>10} {'Growth (KB)':>12} {'%':>6}")
w("-" * 88)
for label, data in sorted_modules:
size_mb = data["size"] / 1024 / 1024
growth_kb = data.get("growth", 0) / 1024
pct = (data["size"] / total_size * 100) if total_size else 0
w(
f"{label:<50} {data['count']:>8} {size_mb:>10.2f} {growth_kb:>+12.1f} {pct:>5.1f}%"
)
if external_stats["count"]:
w(
f"{'<external>':<50} {external_stats['count']:>8} {external_stats['size'] / 1024 / 1024:>10.2f}"
)
w("-" * 88)
w(f"{'TOTAL Python':<50} {total_count:>8} {total_size / 1024 / 1024:>10.2f}")
out = "\n".join(lines)
dump_path = f"/tmp/modus_memdump_{_snapshot_counter}.txt"
with open(dump_path, "w") as f:
f.write(out + "\n")
print(out, flush=True)
return {
"rss": rss,
"modules": dict(module_totals),
"external_count": external_stats["count"],
"external_size": external_stats["size"],
"total_size": total_size,
"total_count": total_count,
}
if __name__ == "__main__":
print("Usage: MODUS_MEMTRACE=1 uv run start")
print("Then: touch /tmp/modus_dump")
+8 -132
View File
@@ -1,141 +1,17 @@
from fabric.utils import logger
import json
import subprocess
import shared.data as data
from services.modus import (
check_occlusion as _check_occlusion,
get_screen_dimensions as _get_screen_dimensions,
get_active_workspace_id,
)
def get_current_workspace():
"""
Get the current workspace ID using hyprctl.
"""
try:
result = subprocess.run(
["hyprctl", "activeworkspace"], capture_output=True, text=True
)
# Assume the output similar to: "ID <number>"
# Extracting the number from the output
parts = result.stdout.split()
for i, part in enumerate(parts):
if part == "ID" and i + 1 < len(parts):
return int(parts[i + 1])
except Exception as e:
logger.error(f"Error getting current workspace: {e}")
return -1
return get_active_workspace_id()
def get_screen_dimensions():
"""
Get screen dimensions from hyprctl.
Returns:
tuple: (width, height) of the monitor containing the current workspace
"""
try:
# Get current workspace
workspace_id = get_current_workspace()
# Get monitor information
result = subprocess.run(
["hyprctl", "-j", "monitors"], capture_output=True, text=True
)
monitors = json.loads(result.stdout)
# Find the monitor containing our workspace
for monitor in monitors:
if monitor.get("activeWorkspace", {}).get("id") == workspace_id:
return monitor.get("width", data.CURRENT_WIDTH), monitor.get(
"height", data.CURRENT_HEIGHT
)
# Fallback to first monitor
if monitors:
return monitors[0].get("width", data.CURRENT_WIDTH), monitors[0].get(
"height", data.CURRENT_HEIGHT
)
except Exception as e:
logger.error(f"Error getting screen dimensions: {e}")
# Default fallback values
return data.CURRENT_WIDTH, data.CURRENT_HEIGHT
return _get_screen_dimensions()
def check_occlusion(occlusion_region, workspace=None):
"""
Check if a region is occupied by any window on a given workspace.
Parameters:
occlusion_region: Can be one of:
- tuple (side, size): where side is "top", "bottom", "left", or "right"
and size is the pixel width of the region
- tuple (x, y, width, height): The full region coordinates (legacy format)
workspace (int, optional): The workspace ID to check. If None, the current workspace is used.
Returns:
bool: True if any window overlaps with the occlusion region, False otherwise.
"""
if workspace is None:
workspace = get_current_workspace()
# Handle simplified side-based format
if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2:
side, size = occlusion_region
if isinstance(side, str):
# Convert side-based format to coordinates
screen_width, screen_height = get_screen_dimensions()
if side.lower() == "bottom":
occlusion_region = (0, screen_height - size, screen_width, size)
elif side.lower() == "top":
occlusion_region = (0, 0, screen_width, size)
elif side.lower() == "left":
occlusion_region = (0, 0, size, screen_height)
elif side.lower() == "right":
occlusion_region = (screen_width - size, 0, size, screen_height)
# Ensure occlusion_region is in the correct format (x, y, width, height)
if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:
print(f"Invalid occlusion region format: {occlusion_region}")
return False
try:
result = subprocess.run(
["hyprctl", "-j", "clients"], capture_output=True, text=True
)
clients = json.loads(result.stdout)
except Exception as e:
logger.error(f"Error retrieving client windows: {e}")
return False
occ_x, occ_y, occ_width, occ_height = occlusion_region
occ_x2 = occ_x + occ_width
occ_y2 = occ_y + occ_height
for client in clients:
# Check if client is mapped
if not client.get("mapped", False):
continue
# Ensure client has proper workspace information and matches the workspace
client_workspace = client.get("workspace", {})
if client_workspace.get("id") != workspace:
continue
# Ensure client has position and size info
position = client.get("at")
size = client.get("size")
if not position or not size:
continue
x, y = position
width, height = size
win_x1, win_y1 = x, y
win_x2, win_y2 = x + width, y + height
# Check for intersection between the window and occlusion region
if not (
win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2
):
return True # Occlusion region is occupied
return False # No window overlaps the occlusion region
return _check_occlusion(occlusion_region, workspace)
+15 -40
View File
@@ -1,44 +1,19 @@
from fabric.audio import Audio
from fabric.utils import logger
_services_mod = None
# ruff: noqa: I001
from services.modus import (
ModusService,
notification_service as notification_service_instance,
)
global modus_service
try:
modus_service = ModusService()
except Exception as e:
logger.error(f"[Main] Failed to create ModusService: {e}")
modus_service = None
def _mod():
global _services_mod
if _services_mod is None:
import services.modus as _services_mod # noqa: F811
return _services_mod
if modus_service is None:
logger.warning(
"[Main] ModusService was not initialized. Functionality may be limited."
)
global notification_service
try:
notification_service = notification_service_instance
except Exception as e:
logger.error(f"[Main] Failed to create NotificationService: {e}")
notification_service = None
if notification_service is None:
logger.warning(
"[Main] NotificationService was not initialized. Notifications may not work."
)
global audio_service
try:
audio_service = Audio()
except Exception as e:
logger.error(f"[Main] Failed to create AudioService: {e}")
audio_service = None
if audio_service is None:
logger.warning(
"[Main] AudioService was not initialized. Audio features may not work."
)
def __getattr__(name):
if name in (
"modus_service",
"notification_service",
"audio_service",
"screen_capture_service",
):
return getattr(_mod(), name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")