linux observer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Observer1 system tray app

Port the system tray prototype into solstone-linux as a StatusNotifierItem app that talks to the Observer1 D-Bus interface.

Add the SNI, dbusmenu, and tray modules, installable SVG tray icons, a solstone-tray entry point, and tray-focused tests covering menu and status behavior.

+1343
+12
contrib/icons/hicolor/index.theme
··· 1 + [Icon Theme] 2 + Name=solstone 3 + Comment=solstone observer tray icons 4 + Inherits=hicolor 5 + Directories=scalable/status 6 + 7 + [scalable/status] 8 + Size=32 9 + Type=Scalable 10 + MinSize=16 11 + MaxSize=256 12 + Context=Status
+17
contrib/icons/hicolor/scalable/status/solstone-error.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="2.5 2.5 27 27" 2 + role="img" aria-label="Solstone icon error state"> 3 + <title>Solstone icon -- error</title> 4 + <defs> 5 + <mask id="ixko" maskUnits="userSpaceOnUse"> 6 + <rect x="2.5" y="2.5" width="27.0" height="27.0" fill="white"/> 7 + <path d="M7 7 L25 25 M25 7 L7 25" fill="none" stroke="black" 8 + stroke-width="5.5" stroke-linecap="round"/> 9 + </mask> 10 + </defs> 11 + <g mask="url(#ixko)"> 12 + <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/> 13 + <circle cx="16.0" cy="16.0" r="6.5" fill="none" stroke="#E8923A" stroke-width="1.7"/> 14 + </g> 15 + <path d="M7 7 L25 25 M25 7 L7 25" fill="none" stroke="#E8923A" 16 + stroke-width="2.5" stroke-linecap="round"/> 17 + </svg>
+17
contrib/icons/hicolor/scalable/status/solstone-paused.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="2.5 2.5 27 27" 2 + role="img" aria-label="Solstone icon paused state"> 3 + <title>Solstone icon -- paused</title> 4 + <defs> 5 + <mask id="icko" maskUnits="userSpaceOnUse"> 6 + <rect x="2.5" y="2.5" width="27.0" height="27.0" fill="white"/> 7 + <path d="M 8.50 21.50 A 4.5 4.5 0 0 1 12.02 18.00 A 5.2 5.2 0 0 1 21.35 13.65 A 4.0 4.0 0 0 1 26.91 18.89 C 28 22, 27.5 25.5, 23 25.5 C 20 28.8, 16 28.8, 13 25.5 C 10 26.5, 8 23.5, 8.50 21.50 Z" fill="black" stroke="black" 8 + stroke-width="4.4" stroke-linejoin="round"/> 9 + </mask> 10 + </defs> 11 + <g mask="url(#icko)"> 12 + <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/> 13 + <circle cx="16.0" cy="16.0" r="6.5" fill="none" stroke="#E8923A" stroke-width="1.7"/> 14 + </g> 15 + <path d="M 8.50 21.50 A 4.5 4.5 0 0 1 12.02 18.00 A 5.2 5.2 0 0 1 21.35 13.65 A 4.0 4.0 0 0 1 26.91 18.89 C 28 22, 27.5 25.5, 23 25.5 C 20 28.8, 16 28.8, 13 25.5 C 10 26.5, 8 23.5, 8.50 21.50 Z" fill="none" stroke="#999" 16 + stroke-width="1.4" stroke-linejoin="round"/> 17 + </svg>
+7
contrib/icons/hicolor/scalable/status/solstone-recording.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="2.5 2.5 27 27" role="img" aria-label="Solstone sun"> 2 + <title>Solstone sun</title> 3 + <!-- Sun rays: 10 floating wedges with curved inner arc matching the ring --> 4 + <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/> 5 + <!-- Sun ring: open annulus --> 6 + <circle cx="16" cy="16" r="6.5" fill="none" stroke="#E8923A" stroke-width="1.7"/> 7 + </svg>
+16
contrib/icons/hicolor/scalable/status/solstone-syncing.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="2.5 2.5 27 27" 2 + role="img" aria-label="Solstone icon partial state"> 3 + <title>Solstone icon -- partial</title> 4 + <!-- Top 5 rays only (no clip path needed) --> 5 + <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z"/> 6 + <path fill="#F5C740" d="M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z"/> 7 + <path fill="#F5C740" d="M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z"/> 8 + <path fill="#F5C740" d="M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z"/> 9 + <path fill="#F5C740" d="M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/> 10 + <!-- Top arc of the ring --> 11 + <path fill="none" stroke="#E8923A" stroke-width="1.7" 12 + d="M 9.5 16.0 A 6.5 6.5 0 0 1 22.5 16.0"/> 13 + <!-- Horizon line --> 14 + <line x1="4.5" y1="16.0" x2="27.5" y2="16.0" 15 + stroke="#E8923A" stroke-width="1.7" stroke-linecap="round"/> 16 + </svg>
+1
pyproject.toml
··· 16 16 17 17 [project.scripts] 18 18 solstone-linux = "solstone_linux.cli:main" 19 + solstone-tray = "solstone_linux.tray:main" 19 20 20 21 [dependency-groups] 21 22 dev = [
+232
src/solstone_linux/dbusmenu.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + # ruff: noqa: F722, F821 4 + """com.canonical.dbusmenu implementation over dbus-next. 5 + 6 + This implements the D-Bus menu protocol used by StatusNotifierItem 7 + to export application menus to the desktop environment's tray host. 8 + Both KDE Plasma and GNOME's AppIndicator extension consume this. 9 + 10 + Reference: https://github.com/AyatanaIndicators/libdbusmenu/blob/master/libdbusmenu-glib/dbus-menu.xml 11 + """ 12 + 13 + import logging 14 + 15 + from dbus_next import PropertyAccess, Variant 16 + from dbus_next.service import ( 17 + ServiceInterface, 18 + dbus_property, 19 + method, 20 + signal as dbus_signal, 21 + ) 22 + 23 + log = logging.getLogger(__name__) 24 + 25 + 26 + class MenuItem: 27 + """A menu item in the dbusmenu tree.""" 28 + 29 + _next_id = 1 30 + 31 + def __init__( 32 + self, 33 + label="", 34 + icon_name="", 35 + enabled=True, 36 + visible=True, 37 + toggle_type="", 38 + toggle_state=-1, 39 + item_type="", 40 + children_display="", 41 + shortcut=None, 42 + callback=None, 43 + ): 44 + self.id = MenuItem._next_id 45 + MenuItem._next_id += 1 46 + self.label = label 47 + self.icon_name = icon_name 48 + self.enabled = enabled 49 + self.visible = visible 50 + self.toggle_type = toggle_type # "", "checkmark", "radio" 51 + self.toggle_state = toggle_state # -1 = none, 0 = off, 1 = on 52 + self.item_type = item_type # "" = standard, "separator" 53 + self.children_display = children_display # "" or "submenu" 54 + self.shortcut = shortcut 55 + self.callback = callback 56 + self.children: list["MenuItem"] = [] 57 + 58 + def get_properties(self) -> dict: 59 + """Return non-default properties as a dict of Variants.""" 60 + props = {} 61 + if self.label: 62 + props["label"] = Variant("s", self.label) 63 + if self.icon_name: 64 + props["icon-name"] = Variant("s", self.icon_name) 65 + if not self.enabled: 66 + props["enabled"] = Variant("b", False) 67 + if not self.visible: 68 + props["visible"] = Variant("b", False) 69 + if self.toggle_type: 70 + props["toggle-type"] = Variant("s", self.toggle_type) 71 + props["toggle-state"] = Variant("i", self.toggle_state) 72 + if self.item_type: 73 + props["type"] = Variant("s", self.item_type) 74 + if self.children_display: 75 + props["children-display"] = Variant("s", self.children_display) 76 + return props 77 + 78 + 79 + def _separator(): 80 + """Create a separator menu item.""" 81 + item = MenuItem(item_type="separator") 82 + return item 83 + 84 + 85 + class DBusMenu(ServiceInterface): 86 + """com.canonical.dbusmenu service interface.""" 87 + 88 + def __init__(self): 89 + super().__init__("com.canonical.dbusmenu") 90 + self._revision = 1 91 + self._root = MenuItem() # id 0 is root 92 + self._root.id = 0 93 + self._root.children_display = "submenu" 94 + self._items: dict[int, MenuItem] = {0: self._root} 95 + MenuItem._next_id = 1 96 + 97 + def set_menu(self, items: list[MenuItem]): 98 + """Replace the entire menu tree.""" 99 + self._root.children = items 100 + self._items = {0: self._root} 101 + self._register_items(items) 102 + self._revision += 1 103 + self.LayoutUpdated(self._revision, 0) 104 + 105 + def update_item(self, item: MenuItem): 106 + """Signal that a menu item's properties changed. 107 + 108 + We emit LayoutUpdated rather than ItemsPropertiesUpdated 109 + because it's simpler and universally supported. The tray 110 + host will re-read the layout on next menu open. 111 + """ 112 + self._revision += 1 113 + self.LayoutUpdated(self._revision, 0) 114 + 115 + def _register_items(self, items: list[MenuItem]): 116 + for item in items: 117 + self._items[item.id] = item 118 + if item.children: 119 + self._register_items(item.children) 120 + 121 + def _build_layout(self, item: MenuItem, depth: int, props: list[str]): 122 + """Build the (ia{sv}av) layout tuple for GetLayout.""" 123 + item_props = item.get_properties() 124 + if props: 125 + item_props = {k: v for k, v in item_props.items() if k in props} 126 + 127 + children_variants = [] 128 + if depth != 0 and item.children: 129 + for child in item.children: 130 + child_layout = self._build_layout( 131 + child, 132 + depth - 1 if depth > 0 else -1, 133 + props, 134 + ) 135 + children_variants.append(Variant("(ia{sv}av)", child_layout)) 136 + 137 + return [item.id, item_props, children_variants] 138 + 139 + # ── D-Bus Methods ── 140 + 141 + @method() 142 + def GetLayout( 143 + self, parent_id: "i", recursion_depth: "i", property_names: "as" 144 + ) -> "u(ia{sv}av)": 145 + parent = self._items.get(parent_id, self._root) 146 + layout = self._build_layout(parent, recursion_depth, property_names) 147 + return [self._revision, layout] 148 + 149 + @method() 150 + def GetGroupProperties(self, ids: "ai", property_names: "as") -> "a(ia{sv})": 151 + result = [] 152 + for item_id in ids: 153 + item = self._items.get(item_id) 154 + if item: 155 + props = item.get_properties() 156 + if property_names: 157 + props = {k: v for k, v in props.items() if k in property_names} 158 + result.append([item_id, props]) 159 + return result 160 + 161 + @method() 162 + def GetProperty(self, item_id: "i", name: "s") -> "v": 163 + item = self._items.get(item_id) 164 + if item: 165 + props = item.get_properties() 166 + if name in props: 167 + return props[name] 168 + return Variant("s", "") 169 + 170 + @method() 171 + def Event(self, item_id: "i", event_id: "s", data: "v", timestamp: "u"): 172 + item = self._items.get(item_id) 173 + if item and event_id == "clicked" and item.callback: 174 + log.info(f"Menu item clicked: {item.label!r} (id={item_id})") 175 + item.callback() 176 + elif item: 177 + log.debug(f"Menu event: {event_id} on {item.label!r} (id={item_id})") 178 + 179 + @method() 180 + def EventGroup(self, events: "a(isvu)") -> "ai": 181 + errors = [] 182 + for item_id, event_id, data, timestamp in events: 183 + item = self._items.get(item_id) 184 + if item and event_id == "clicked" and item.callback: 185 + log.info(f"Menu item clicked: {item.label!r} (id={item_id})") 186 + item.callback() 187 + return errors 188 + 189 + @method() 190 + def AboutToShow(self, item_id: "i") -> "b": 191 + return False # no layout update needed 192 + 193 + @method() 194 + def AboutToShowGroup(self, ids: "ai") -> "aiai": 195 + return [[], []] # no updates, no errors 196 + 197 + # ── D-Bus Properties ── 198 + 199 + @dbus_property(access=PropertyAccess.READ) 200 + def Version(self) -> "u": 201 + return 3 202 + 203 + @dbus_property(access=PropertyAccess.READ) 204 + def TextDirection(self) -> "s": 205 + return "ltr" 206 + 207 + @dbus_property(access=PropertyAccess.READ) 208 + def Status(self) -> "s": 209 + return "normal" 210 + 211 + @dbus_property(access=PropertyAccess.READ) 212 + def IconThemePath(self) -> "as": 213 + return [] 214 + 215 + # ── D-Bus Signals ── 216 + 217 + @dbus_signal() 218 + def ItemsPropertiesUpdated(self, updated_props, removed_props) -> "a(ia{sv})a(ias)": 219 + return [updated_props, removed_props] 220 + 221 + @dbus_signal() 222 + def LayoutUpdated(self, revision, parent) -> "ui": 223 + return [revision, parent] 224 + 225 + @dbus_signal() 226 + def ItemActivationRequested(self, item_id, timestamp) -> "iu": 227 + return [item_id, timestamp] 228 + 229 + 230 + def separator(): 231 + """Create a separator menu item.""" 232 + return _separator()
+230
src/solstone_linux/sni.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + # ruff: noqa: F722, F821 4 + """StatusNotifierItem (SNI) implementation over dbus-next. 5 + 6 + Implements the org.kde.StatusNotifierItem D-Bus interface for 7 + registering a tray icon with KDE Plasma's system tray or GNOME's 8 + AppIndicator extension. Both speak the same protocol. 9 + 10 + The tray icon, menu, and tooltip are all rendered by the DE's 11 + tray host — this code just exposes the data over D-Bus. 12 + """ 13 + 14 + import logging 15 + 16 + from dbus_next import PropertyAccess 17 + from dbus_next.aio import MessageBus 18 + from dbus_next.service import ( 19 + ServiceInterface, 20 + dbus_property, 21 + method, 22 + signal as dbus_signal, 23 + ) 24 + 25 + log = logging.getLogger(__name__) 26 + 27 + 28 + class StatusNotifierItem(ServiceInterface): 29 + """org.kde.StatusNotifierItem D-Bus interface.""" 30 + 31 + def __init__(self, app_id: str = "solstone-observer"): 32 + super().__init__("org.kde.StatusNotifierItem") 33 + self._id = app_id 34 + self._category = "ApplicationStatus" 35 + self._status = "Active" # Passive, Active, NeedsAttention 36 + self._title = "solstone observer" 37 + self._icon_name = "solstone-recording" 38 + self._attention_icon_name = "" 39 + self._overlay_icon_name = "" 40 + self._tooltip_icon = "" 41 + self._tooltip_title = "solstone observer" 42 + self._tooltip_body = "recording" 43 + self._icon_theme_path = "" 44 + self._menu_path = "/MenuBar" 45 + self._item_is_menu = True 46 + 47 + # Callbacks 48 + self.on_activate = None 49 + self.on_secondary_activate = None 50 + self.on_scroll = None 51 + 52 + # ── Setters that emit change signals ── 53 + 54 + def set_icon(self, icon_name: str): 55 + if self._icon_name != icon_name: 56 + self._icon_name = icon_name 57 + self.NewIcon() 58 + 59 + def set_status(self, status: str): 60 + """Set Active, Passive, or NeedsAttention.""" 61 + if self._status != status: 62 + self._status = status 63 + self.NewStatus(status) 64 + 65 + def set_tooltip(self, title: str, body: str, icon: str = ""): 66 + self._tooltip_title = title 67 + self._tooltip_body = body 68 + if icon: 69 + self._tooltip_icon = icon 70 + self.NewToolTip() 71 + 72 + def set_title(self, title: str): 73 + if self._title != title: 74 + self._title = title 75 + self.NewTitle() 76 + 77 + def set_attention_icon(self, icon_name: str): 78 + self._attention_icon_name = icon_name 79 + self.NewAttentionIcon() 80 + 81 + def set_overlay_icon(self, icon_name: str): 82 + self._overlay_icon_name = icon_name 83 + self.NewOverlayIcon() 84 + 85 + # ── D-Bus Properties ── 86 + 87 + @dbus_property(access=PropertyAccess.READ) 88 + def Category(self) -> "s": 89 + return self._category 90 + 91 + @dbus_property(access=PropertyAccess.READ) 92 + def Id(self) -> "s": 93 + return self._id 94 + 95 + @dbus_property(access=PropertyAccess.READ) 96 + def Title(self) -> "s": 97 + return self._title 98 + 99 + @dbus_property(access=PropertyAccess.READ) 100 + def Status(self) -> "s": 101 + return self._status 102 + 103 + @dbus_property(access=PropertyAccess.READ) 104 + def WindowId(self) -> "i": 105 + return 0 106 + 107 + @dbus_property(access=PropertyAccess.READ) 108 + def IconName(self) -> "s": 109 + return self._icon_name 110 + 111 + @dbus_property(access=PropertyAccess.READ) 112 + def IconPixmap(self) -> "a(iiay)": 113 + return [] 114 + 115 + @dbus_property(access=PropertyAccess.READ) 116 + def OverlayIconName(self) -> "s": 117 + return self._overlay_icon_name 118 + 119 + @dbus_property(access=PropertyAccess.READ) 120 + def OverlayIconPixmap(self) -> "a(iiay)": 121 + return [] 122 + 123 + @dbus_property(access=PropertyAccess.READ) 124 + def AttentionIconName(self) -> "s": 125 + return self._attention_icon_name 126 + 127 + @dbus_property(access=PropertyAccess.READ) 128 + def AttentionIconPixmap(self) -> "a(iiay)": 129 + return [] 130 + 131 + @dbus_property(access=PropertyAccess.READ) 132 + def AttentionMovieName(self) -> "s": 133 + return "" 134 + 135 + @dbus_property(access=PropertyAccess.READ) 136 + def ToolTip(self) -> "(sa(iiay)ss)": 137 + return [ 138 + self._tooltip_icon, # icon name 139 + [], # icon pixmaps 140 + self._tooltip_title, # title 141 + self._tooltip_body, # body (supports HTML on KDE) 142 + ] 143 + 144 + @dbus_property(access=PropertyAccess.READ) 145 + def IconThemePath(self) -> "s": 146 + return self._icon_theme_path 147 + 148 + @dbus_property(access=PropertyAccess.READ) 149 + def Menu(self) -> "o": 150 + return self._menu_path 151 + 152 + @dbus_property(access=PropertyAccess.READ) 153 + def ItemIsMenu(self) -> "b": 154 + return self._item_is_menu 155 + 156 + # ── D-Bus Methods ── 157 + 158 + @method() 159 + def ContextMenu(self, x: "i", y: "i"): 160 + log.debug(f"ContextMenu at ({x}, {y})") 161 + 162 + @method() 163 + def Activate(self, x: "i", y: "i"): 164 + log.debug(f"Activate at ({x}, {y})") 165 + if self.on_activate: 166 + self.on_activate() 167 + 168 + @method() 169 + def SecondaryActivate(self, x: "i", y: "i"): 170 + log.debug(f"SecondaryActivate at ({x}, {y})") 171 + if self.on_secondary_activate: 172 + self.on_secondary_activate() 173 + 174 + @method() 175 + def Scroll(self, delta: "i", orientation: "s"): 176 + log.debug(f"Scroll delta={delta} orientation={orientation}") 177 + if self.on_scroll: 178 + self.on_scroll(delta, orientation) 179 + 180 + @method() 181 + def ProvideXdgActivationToken(self, token: "s"): 182 + log.debug(f"XDG activation token: {token}") 183 + 184 + # ── D-Bus Signals ── 185 + 186 + @dbus_signal() 187 + def NewTitle(self): 188 + pass 189 + 190 + @dbus_signal() 191 + def NewIcon(self): 192 + pass 193 + 194 + @dbus_signal() 195 + def NewAttentionIcon(self): 196 + pass 197 + 198 + @dbus_signal() 199 + def NewOverlayIcon(self): 200 + pass 201 + 202 + @dbus_signal() 203 + def NewToolTip(self): 204 + pass 205 + 206 + @dbus_signal() 207 + def NewStatus(self, status) -> "s": 208 + return status 209 + 210 + 211 + async def register_with_watcher(bus: MessageBus, bus_name: str): 212 + """Register our SNI with the StatusNotifierWatcher.""" 213 + try: 214 + introspection = await bus.introspect( 215 + "org.kde.StatusNotifierWatcher", 216 + "/StatusNotifierWatcher", 217 + ) 218 + proxy = bus.get_proxy_object( 219 + "org.kde.StatusNotifierWatcher", 220 + "/StatusNotifierWatcher", 221 + introspection, 222 + ) 223 + watcher = proxy.get_interface("org.kde.StatusNotifierWatcher") 224 + await watcher.call_register_status_notifier_item(bus_name) 225 + log.info(f"Registered with StatusNotifierWatcher as {bus_name}") 226 + return True 227 + except Exception as e: 228 + log.warning(f"Failed to register with StatusNotifierWatcher: {e}") 229 + log.warning("Is KDE Plasma running, or the AppIndicator extension enabled?") 230 + return False
+594
src/solstone_linux/tray.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + """solstone tray app — pure D-Bus SNI implementation. 4 + 5 + Connects to the observer backend over D-Bus and displays a system 6 + tray icon with status, menus, and tooltip. No GUI toolkit dependency. 7 + """ 8 + 9 + import asyncio 10 + import logging 11 + import os 12 + import signal 13 + import subprocess 14 + import sys 15 + from pathlib import Path 16 + 17 + from dbus_next.aio import MessageBus 18 + 19 + from .config import load_config 20 + from .dbusmenu import DBusMenu, MenuItem, separator 21 + from .sni import StatusNotifierItem, register_with_watcher 22 + 23 + log = logging.getLogger(__name__) 24 + 25 + BACKEND_BUS = "org.solpbc.solstone.Observer1" 26 + BACKEND_PATH = "/org/solpbc/solstone/Observer1" 27 + 28 + # Icon names — these reference SVGs in our icon theme 29 + ICONS = { 30 + "recording": "solstone-recording", 31 + "paused": "solstone-paused", 32 + "idle": "solstone-paused", 33 + "stopped": "solstone-error", 34 + "syncing": "solstone-syncing", 35 + "error": "solstone-error", 36 + } 37 + 38 + # Agent instructions template copied to clipboard 39 + AGENT_INSTRUCTIONS = """solstone observer (Linux) 40 + Source: {source_dir} 41 + Read INSTALL.md in the source directory for setup and architecture. 42 + Config: {config_path} 43 + Captures: {captures_dir} 44 + Logs: journalctl --user -u solstone-linux -f 45 + Service: systemctl --user status solstone-linux""" 46 + 47 + SOURCE_DIR = str(Path(__file__).resolve().parent) 48 + 49 + 50 + class TrayApp: 51 + """Main tray application coordinating SNI, menu, and backend.""" 52 + 53 + def __init__(self): 54 + self.config = load_config() 55 + self.bus: MessageBus = None 56 + self.sni = StatusNotifierItem("solstone-observer") 57 + self.menu = DBusMenu() 58 + self.backend = None 59 + self.backend_props = None 60 + 61 + # State cache 62 + self.status = "recording" 63 + self.sync_status = "synced" 64 + self.sync_progress = "" 65 + self.error = "" 66 + self.paused_remaining = 0 67 + self.stats = {} 68 + 69 + # Menu item references for dynamic updates 70 + self._status_item: MenuItem = None 71 + self._sync_item: MenuItem = None 72 + self._segment_item: MenuItem = None 73 + self._cache_item: MenuItem = None 74 + self._captures_item: MenuItem = None 75 + self._uptime_item: MenuItem = None 76 + self._pause_submenu: MenuItem = None 77 + self._resume_item: MenuItem = None 78 + 79 + async def start(self): 80 + self.bus = await MessageBus().connect() 81 + 82 + pid = os.getpid() 83 + bus_name = f"org.kde.StatusNotifierItem-{pid}-1" 84 + await self.bus.request_name(bus_name) 85 + 86 + # Export interfaces 87 + self.bus.export("/StatusNotifierItem", self.sni) 88 + self.bus.export("/MenuBar", self.menu) 89 + 90 + # Resolve icon theme: installed location, then dev/contrib fallback 91 + installed_icon = ( 92 + Path.home() 93 + / ".local/share/icons/hicolor/scalable/status/solstone-recording.svg" 94 + ) 95 + if installed_icon.exists(): 96 + self.sni._icon_theme_path = str(Path.home() / ".local/share/icons") 97 + else: 98 + contrib = ( 99 + Path(__file__).resolve().parent.parent.parent / "contrib" / "icons" 100 + ) 101 + if (contrib / "hicolor").is_dir(): 102 + self.sni._icon_theme_path = str(contrib) 103 + if self.sni._icon_theme_path: 104 + log.info(f"Icon theme path: {self.sni._icon_theme_path}") 105 + 106 + # Set initial icon 107 + self.sni.set_icon(ICONS["recording"]) 108 + self.sni.set_tooltip("solstone observer", "starting...") 109 + 110 + # Build menu 111 + self._build_menu() 112 + 113 + # Register with watcher (with retries) 114 + registered = False 115 + for attempt in range(6): 116 + registered = await register_with_watcher(self.bus, bus_name) 117 + if registered: 118 + break 119 + if attempt < 5: 120 + await asyncio.sleep(2) 121 + log.info(f"Retry {attempt + 1}/5...") 122 + 123 + if not registered: 124 + log.error("Could not register with StatusNotifierWatcher.") 125 + return False 126 + 127 + # Connect to backend 128 + await self._connect_backend() 129 + 130 + # Start background tasks 131 + asyncio.create_task(self._poll_backend()) 132 + 133 + return True 134 + 135 + def _build_menu(self): 136 + """Build the full tray menu structure.""" 137 + 138 + # ── Status submenu (live data) ── 139 + self._status_item = MenuItem(label="recording", enabled=False) 140 + self._sync_item = MenuItem(label="sync: up to date", enabled=False) 141 + self._segment_item = MenuItem(label="segment: --:--", enabled=False) 142 + self._cache_item = MenuItem(label="cache: --", enabled=False) 143 + self._captures_item = MenuItem(label="captures today: --", enabled=False) 144 + self._uptime_item = MenuItem(label="uptime: --", enabled=False) 145 + 146 + status_submenu = MenuItem( 147 + label="Status", 148 + children_display="submenu", 149 + ) 150 + status_submenu.children = [ 151 + self._status_item, 152 + self._sync_item, 153 + separator(), 154 + self._segment_item, 155 + self._cache_item, 156 + self._captures_item, 157 + self._uptime_item, 158 + ] 159 + 160 + # ── Pause / Resume ── 161 + pause_15m = MenuItem(label="15 minutes", callback=lambda: self._pause(900)) 162 + pause_30m = MenuItem(label="30 minutes", callback=lambda: self._pause(1800)) 163 + pause_1h = MenuItem(label="1 hour", callback=lambda: self._pause(3600)) 164 + pause_indef = MenuItem(label="Until I resume", callback=lambda: self._pause(0)) 165 + 166 + self._pause_submenu = MenuItem( 167 + label="Pause", 168 + children_display="submenu", 169 + ) 170 + self._pause_submenu.children = [pause_15m, pause_30m, pause_1h, pause_indef] 171 + 172 + self._resume_item = MenuItem( 173 + label="Resume", 174 + visible=False, 175 + callback=self._resume, 176 + ) 177 + 178 + # ── Open journal / Show captures ── 179 + open_journal = MenuItem( 180 + label="Open journal", 181 + callback=self._open_journal, 182 + ) 183 + 184 + open_captures = MenuItem( 185 + label="Show captures", 186 + callback=self._open_captures, 187 + ) 188 + 189 + # ── Settings submenu ── 190 + settings_open_config = MenuItem( 191 + label="Open config.json", 192 + callback=self._open_config, 193 + ) 194 + settings_copy_agent = MenuItem( 195 + label="Copy coding agent instructions", 196 + callback=self._copy_agent_instructions, 197 + ) 198 + 199 + settings_submenu = MenuItem( 200 + label="Settings", 201 + children_display="submenu", 202 + ) 203 + settings_submenu.children = [ 204 + settings_open_config, 205 + settings_copy_agent, 206 + ] 207 + 208 + # ── About submenu ── 209 + about_observers = MenuItem( 210 + label="solstone.app/observers", 211 + callback=lambda: self._open_url("https://solstone.app/observers"), 212 + ) 213 + about_privacy = MenuItem( 214 + label="Privacy policy", 215 + callback=lambda: self._open_url("https://solpbc.org/privacy"), 216 + ) 217 + about_copyright = MenuItem( 218 + label="\u00a9 sol pbc", 219 + enabled=False, 220 + ) 221 + 222 + about_submenu = MenuItem( 223 + label="About", 224 + children_display="submenu", 225 + ) 226 + about_submenu.children = [ 227 + about_observers, 228 + about_privacy, 229 + separator(), 230 + about_copyright, 231 + ] 232 + 233 + # ── Quit ── 234 + quit_item = MenuItem( 235 + label="Quit solstone observer", 236 + callback=self._quit, 237 + ) 238 + 239 + # ── Assemble full menu ── 240 + self.menu.set_menu( 241 + [ 242 + status_submenu, 243 + separator(), 244 + self._pause_submenu, 245 + self._resume_item, 246 + separator(), 247 + open_journal, 248 + open_captures, 249 + separator(), 250 + settings_submenu, 251 + about_submenu, 252 + separator(), 253 + quit_item, 254 + ] 255 + ) 256 + 257 + async def _connect_backend(self): 258 + """Connect to the observer's D-Bus interface.""" 259 + try: 260 + introspection = await self.bus.introspect(BACKEND_BUS, BACKEND_PATH) 261 + proxy = self.bus.get_proxy_object(BACKEND_BUS, BACKEND_PATH, introspection) 262 + self.backend = proxy.get_interface("org.solpbc.solstone.Observer1") 263 + self.backend_props = proxy.get_interface("org.freedesktop.DBus.Properties") 264 + 265 + # Subscribe to signals 266 + self.backend.on_status_changed(self._on_status_changed) 267 + self.backend.on_sync_progress_changed(self._on_sync_progress_changed) 268 + self.backend.on_error_occurred(self._on_error_occurred) 269 + 270 + log.info("Connected to observer backend") 271 + except Exception as e: 272 + log.warning(f"Backend not available: {e}") 273 + self._update_status("stopped") 274 + 275 + async def _poll_backend(self): 276 + """Poll backend for state updates every 5 seconds.""" 277 + while True: 278 + await asyncio.sleep(5) 279 + try: 280 + if self.backend is None: 281 + await self._connect_backend() 282 + continue 283 + 284 + status = await self.backend.get_status() 285 + sync_status = await self.backend.get_sync_status() 286 + sync_progress = await self.backend.get_sync_progress() 287 + error = await self.backend.get_error() 288 + pause_remaining = await self.backend.get_pause_remaining() 289 + segment_timer = await self.backend.get_segment_timer() 290 + 291 + # Get stats 292 + try: 293 + stats = await self.backend.call_get_stats() 294 + self.stats = {k: v.value for k, v in stats.items()} 295 + except Exception: 296 + pass 297 + 298 + self._update_status(status) 299 + self._update_sync(sync_status, sync_progress) 300 + self._update_live_stats(segment_timer, pause_remaining) 301 + self.paused_remaining = pause_remaining 302 + 303 + if error and error != self.error: 304 + self.error = error 305 + log.info(f"Error: {error}") 306 + elif not error and self.error: 307 + self.error = "" 308 + log.info("Error cleared") 309 + 310 + except Exception as e: 311 + log.warning(f"Poll failed: {e}") 312 + self.backend = None 313 + self.backend_props = None 314 + self._update_status("stopped") 315 + 316 + def _update_status(self, status: str): 317 + """Update tray icon and menu for observer status.""" 318 + if status == self.status: 319 + return 320 + self.status = status 321 + 322 + # Pick icon 323 + if self.error: 324 + icon = ICONS["error"] 325 + elif self.sync_status in ("syncing", "uploading", "retrying"): 326 + icon = ICONS["syncing"] 327 + else: 328 + icon = ICONS.get(status, ICONS["recording"]) 329 + self.sni.set_icon(icon) 330 + 331 + # Update tooltip 332 + self.sni.set_tooltip("solstone observer", self._build_tooltip()) 333 + 334 + # Update status submenu item 335 + labels = { 336 + "recording": "recording", 337 + "paused": "paused", 338 + "idle": "idle (screen inactive)", 339 + "stopped": "not running", 340 + } 341 + self._status_item.label = labels.get(status, status) 342 + self.menu.update_item(self._status_item) 343 + 344 + # Toggle pause/resume 345 + is_paused = status == "paused" 346 + self._pause_submenu.visible = not is_paused 347 + self._resume_item.visible = is_paused 348 + if is_paused and self.paused_remaining > 0: 349 + mins = self.paused_remaining // 60 350 + self._resume_item.label = f"Resume ({mins}m remaining)" 351 + else: 352 + self._resume_item.label = "Resume" 353 + self.menu.update_item(self._pause_submenu) 354 + self.menu.update_item(self._resume_item) 355 + 356 + # SNI status 357 + if status == "stopped" or self.error: 358 + self.sni.set_status("NeedsAttention") 359 + else: 360 + self.sni.set_status("Active") 361 + 362 + log.info(f"Status \u2192 {status} (icon: {icon})") 363 + 364 + def _update_sync(self, sync_status: str, progress: str): 365 + """Update sync status display.""" 366 + if sync_status == self.sync_status and progress == self.sync_progress: 367 + return 368 + self.sync_status = sync_status 369 + self.sync_progress = progress 370 + 371 + labels = { 372 + "synced": "sync: up to date", 373 + "syncing": f"sync: {progress}" if progress else "sync: checking...", 374 + "uploading": f"sync: {progress}" if progress else "sync: uploading...", 375 + "retrying": f"sync: {progress}" if progress else "sync: retrying...", 376 + "offline": "sync: offline", 377 + } 378 + self._sync_item.label = labels.get(sync_status, f"sync: {sync_status}") 379 + self.menu.update_item(self._sync_item) 380 + 381 + # Update icon — syncing state gets the half icon 382 + if not self.error: 383 + if sync_status in ("syncing", "uploading", "retrying"): 384 + self.sni.set_icon(ICONS["syncing"]) 385 + else: 386 + self.sni.set_icon(ICONS.get(self.status, ICONS["recording"])) 387 + 388 + self.sni.set_tooltip("solstone observer", self._build_tooltip()) 389 + 390 + def _update_live_stats(self, segment_timer: int, pause_remaining: int): 391 + """Update the live stats in the status submenu.""" 392 + # Segment timer 393 + mins = segment_timer // 60 394 + secs = segment_timer % 60 395 + self._segment_item.label = f"segment: {mins}:{secs:02d} remaining" 396 + self.menu.update_item(self._segment_item) 397 + 398 + # Stats from GetStats 399 + if self.stats: 400 + captures = self.stats.get("captures_today", 0) 401 + size_mb = self.stats.get("total_size_mb", 0) 402 + synced_days = self.stats.get("synced_days", 0) 403 + uptime = self.stats.get("uptime_seconds", 0) 404 + 405 + self._cache_item.label = f"cache: {size_mb} MB ({synced_days} days synced)" 406 + self._captures_item.label = f"captures today: {captures} segments" 407 + 408 + hours = uptime // 3600 409 + mins_up = (uptime % 3600) // 60 410 + self._uptime_item.label = f"uptime: {hours}h {mins_up}m" 411 + 412 + self.menu.update_item(self._cache_item) 413 + self.menu.update_item(self._captures_item) 414 + self.menu.update_item(self._uptime_item) 415 + 416 + # Update pause remaining in resume button 417 + if self.status == "paused" and pause_remaining > 0: 418 + pr_mins = pause_remaining // 60 419 + self._resume_item.label = f"Resume ({pr_mins}m remaining)" 420 + self.menu.update_item(self._resume_item) 421 + 422 + def _build_tooltip(self) -> str: 423 + """Build rich tooltip body (HTML on KDE).""" 424 + parts = [] 425 + 426 + status_html = { 427 + "recording": "<b>Recording</b>", 428 + "paused": "<b>Paused</b>", 429 + "idle": "Idle (screen inactive)", 430 + "stopped": "<font color='#cc3333'>Not running</font>", 431 + } 432 + parts.append(status_html.get(self.status, self.status)) 433 + 434 + if self.sync_status == "synced": 435 + parts.append("All segments synced") 436 + elif self.sync_progress: 437 + parts.append(f"Sync: {self.sync_progress}") 438 + else: 439 + parts.append(f"Sync: {self.sync_status}") 440 + 441 + if self.error: 442 + parts.append(f"<font color='#cc3333'>{self.error}</font>") 443 + 444 + return "<br>".join(parts) 445 + 446 + # ── Signal handlers ── 447 + 448 + def _on_status_changed(self, status: str): 449 + self._update_status(status) 450 + 451 + def _on_sync_progress_changed(self, progress: str): 452 + if ":" in progress: 453 + sync_status, sync_progress = progress.split(":", 1) 454 + self._update_sync(sync_status, sync_progress) 455 + 456 + def _on_error_occurred(self, message: str): 457 + self.error = message 458 + if message: 459 + self.sni.set_status("NeedsAttention") 460 + self.sni.set_icon(ICONS["error"]) 461 + else: 462 + self.sni.set_status("Active") 463 + self._update_status(self.status) 464 + 465 + # ── Menu callbacks ── 466 + 467 + def _pause(self, seconds: int): 468 + log.info(f"Pause: {seconds}s") 469 + if self.backend: 470 + asyncio.create_task(self._do_pause(seconds)) 471 + 472 + async def _do_pause(self, seconds: int): 473 + try: 474 + await self.backend.call_pause(seconds) 475 + except Exception as e: 476 + log.error(f"Pause failed: {e}") 477 + 478 + def _resume(self): 479 + log.info("Resume") 480 + if self.backend: 481 + asyncio.create_task(self._do_resume()) 482 + 483 + async def _do_resume(self): 484 + try: 485 + await self.backend.call_resume() 486 + except Exception as e: 487 + log.error(f"Resume failed: {e}") 488 + 489 + def _open_journal(self): 490 + log.info("Opening journal") 491 + self._open_url(self.config.server_url or "https://journal.solstone.app") 492 + 493 + def _open_captures(self): 494 + capture_dir = str(self.config.captures_dir) 495 + log.info(f"Opening captures: {capture_dir}") 496 + try: 497 + subprocess.Popen( 498 + ["xdg-open", capture_dir], 499 + stdout=subprocess.DEVNULL, 500 + stderr=subprocess.DEVNULL, 501 + ) 502 + except Exception as e: 503 + log.error(f"Failed to open file manager: {e}") 504 + 505 + def _open_config(self): 506 + config_path = str(self.config.config_path) 507 + log.info(f"Opening config: {config_path}") 508 + try: 509 + subprocess.Popen( 510 + ["xdg-open", config_path], 511 + stdout=subprocess.DEVNULL, 512 + stderr=subprocess.DEVNULL, 513 + ) 514 + except Exception as e: 515 + log.error(f"Failed to open config: {e}") 516 + 517 + def _copy_agent_instructions(self): 518 + """Copy coding agent instructions to clipboard.""" 519 + text = AGENT_INSTRUCTIONS.format( 520 + source_dir=SOURCE_DIR, 521 + config_path=str(self.config.config_path), 522 + captures_dir=str(self.config.captures_dir), 523 + ) 524 + log.info("Copying agent instructions to clipboard") 525 + try: 526 + # wl-copy for Wayland, xclip for X11 527 + session_type = os.environ.get("XDG_SESSION_TYPE", "") 528 + if session_type == "wayland" or os.environ.get("WAYLAND_DISPLAY"): 529 + proc = subprocess.Popen(["wl-copy"], stdin=subprocess.PIPE) 530 + else: 531 + proc = subprocess.Popen( 532 + ["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE 533 + ) 534 + proc.communicate(text.encode()) 535 + log.info("Copied to clipboard") 536 + except FileNotFoundError: 537 + # Fallback: try xdg-open or xsel 538 + try: 539 + proc = subprocess.Popen( 540 + ["xsel", "--clipboard", "--input"], stdin=subprocess.PIPE 541 + ) 542 + proc.communicate(text.encode()) 543 + log.info("Copied to clipboard (xsel)") 544 + except FileNotFoundError: 545 + log.error("No clipboard tool found (wl-copy, xclip, or xsel)") 546 + 547 + def _open_url(self, url: str): 548 + log.info(f"Opening: {url}") 549 + try: 550 + subprocess.Popen( 551 + ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL 552 + ) 553 + except Exception as e: 554 + log.error(f"Failed to open URL: {e}") 555 + 556 + def _quit(self): 557 + log.info("Quit requested") 558 + asyncio.get_event_loop().stop() 559 + 560 + async def stop(self): 561 + if self.bus: 562 + self.bus.disconnect() 563 + 564 + 565 + async def _async_main(): 566 + logging.basicConfig( 567 + level=logging.INFO, 568 + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", 569 + datefmt="%H:%M:%S", 570 + ) 571 + 572 + app = TrayApp() 573 + 574 + loop = asyncio.get_event_loop() 575 + stop = loop.create_future() 576 + for sig in (signal.SIGINT, signal.SIGTERM): 577 + loop.add_signal_handler(sig, stop.set_result, None) 578 + 579 + started = await app.start() 580 + if not started: 581 + sys.exit(1) 582 + 583 + log.info("Tray app running. Ctrl+C to stop.") 584 + await stop 585 + await app.stop() 586 + log.info("Stopped.") 587 + 588 + 589 + def main(): 590 + asyncio.run(_async_main()) 591 + 592 + 593 + if __name__ == "__main__": 594 + main()
+217
tests/test_tray.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from unittest.mock import MagicMock, patch 5 + 6 + from solstone_linux.config import Config 7 + from solstone_linux.dbusmenu import MenuItem, separator 8 + from solstone_linux.tray import AGENT_INSTRUCTIONS, ICONS, SOURCE_DIR, TrayApp 9 + 10 + 11 + def _make_app(tmp_path=None): 12 + config = Config() 13 + if tmp_path: 14 + config.base_dir = tmp_path 15 + config.server_url = "https://test.example.com" 16 + with patch("solstone_linux.tray.load_config", return_value=config): 17 + app = TrayApp() 18 + return app 19 + 20 + 21 + class TestTrayInit: 22 + def test_make_app_loads_config(self): 23 + app = _make_app() 24 + 25 + assert isinstance(app, TrayApp) 26 + assert app.config.server_url == "https://test.example.com" 27 + assert app.sni is not None 28 + assert app.menu is not None 29 + 30 + 31 + class TestBuildMenu: 32 + def test_build_menu_creates_expected_items(self): 33 + app = _make_app() 34 + 35 + app._build_menu() 36 + 37 + assert isinstance(app._status_item, MenuItem) 38 + assert app._status_item.label == "recording" 39 + assert app._status_item.enabled is False 40 + assert app._sync_item.label == "sync: up to date" 41 + assert app._pause_submenu.children_display == "submenu" 42 + assert len(app._pause_submenu.children) == 4 43 + assert app._resume_item.visible is False 44 + assert app.menu._root.children[1].item_type == separator().item_type 45 + assert len(app.menu._root.children) == 12 46 + 47 + 48 + class TestUpdateStatus: 49 + def test_update_status_paused(self): 50 + app = _make_app() 51 + app._build_menu() 52 + app.menu.update_item = MagicMock() 53 + 54 + app._update_status("paused") 55 + 56 + assert app.status == "paused" 57 + assert app._pause_submenu.visible is False 58 + assert app._resume_item.visible is True 59 + assert app._status_item.label == "paused" 60 + assert app.menu.update_item.call_count == 3 61 + 62 + def test_update_status_idle(self): 63 + app = _make_app() 64 + app._build_menu() 65 + 66 + app._update_status("idle") 67 + 68 + assert app._status_item.label == "idle (screen inactive)" 69 + 70 + def test_update_status_stopped_sets_attention(self): 71 + app = _make_app() 72 + app._build_menu() 73 + 74 + app._update_status("stopped") 75 + 76 + assert app._status_item.label == "not running" 77 + assert app.sni._status == "NeedsAttention" 78 + 79 + def test_update_status_recording_uses_error_icon_when_error_set(self): 80 + app = _make_app() 81 + app._build_menu() 82 + app._update_status("paused") 83 + app.error = "Auth failed" 84 + 85 + app._update_status("recording") 86 + 87 + assert app.sni._icon_name == ICONS["error"] 88 + 89 + 90 + class TestUpdateSync: 91 + def test_update_sync_synced(self): 92 + app = _make_app() 93 + app._build_menu() 94 + 95 + app._update_sync("synced", "") 96 + 97 + assert app._sync_item.label == "sync: up to date" 98 + 99 + def test_update_sync_syncing(self): 100 + app = _make_app() 101 + app._build_menu() 102 + 103 + app._update_sync("syncing", "3/10 segments") 104 + 105 + assert app._sync_item.label == "sync: 3/10 segments" 106 + 107 + def test_update_sync_offline(self): 108 + app = _make_app() 109 + app._build_menu() 110 + 111 + app._update_sync("offline", "") 112 + 113 + assert app._sync_item.label == "sync: offline" 114 + 115 + 116 + class TestUpdateLiveStats: 117 + def test_update_live_stats_updates_labels(self): 118 + app = _make_app() 119 + app._build_menu() 120 + app.stats = { 121 + "captures_today": 5, 122 + "total_size_mb": 42, 123 + "synced_days": 7, 124 + "uptime_seconds": 7260, 125 + } 126 + 127 + app._update_live_stats(245, 0) 128 + 129 + assert app._segment_item.label == "segment: 4:05 remaining" 130 + assert app._cache_item.label == "cache: 42 MB (7 days synced)" 131 + assert app._captures_item.label == "captures today: 5 segments" 132 + assert app._uptime_item.label == "uptime: 2h 1m" 133 + 134 + 135 + class TestBuildTooltip: 136 + def test_build_tooltip_default(self): 137 + app = _make_app() 138 + 139 + tooltip = app._build_tooltip() 140 + 141 + assert "<b>Recording</b>" in tooltip 142 + assert "All segments synced" in tooltip 143 + 144 + def test_build_tooltip_stopped(self): 145 + app = _make_app() 146 + app.status = "stopped" 147 + 148 + tooltip = app._build_tooltip() 149 + 150 + assert "Not running" in tooltip 151 + 152 + def test_build_tooltip_error(self): 153 + app = _make_app() 154 + app.error = "Auth failed" 155 + 156 + tooltip = app._build_tooltip() 157 + 158 + assert "Auth failed" in tooltip 159 + 160 + def test_build_tooltip_sync_progress(self): 161 + app = _make_app() 162 + app.sync_status = "syncing" 163 + app.sync_progress = "2/5" 164 + 165 + tooltip = app._build_tooltip() 166 + 167 + assert "Sync: 2/5" in tooltip 168 + 169 + 170 + class TestSignalHandlers: 171 + def test_on_sync_progress_changed_parses_status_and_progress(self): 172 + app = _make_app() 173 + app._build_menu() 174 + 175 + app._on_sync_progress_changed("uploading:3/10 segments") 176 + 177 + assert app.sync_status == "uploading" 178 + assert app.sync_progress == "3/10 segments" 179 + 180 + def test_on_sync_progress_changed_without_colon_keeps_state(self): 181 + app = _make_app() 182 + app._build_menu() 183 + app._on_sync_progress_changed("uploading:3/10 segments") 184 + 185 + app._on_sync_progress_changed("no-colon") 186 + 187 + assert app.sync_status == "uploading" 188 + assert app.sync_progress == "3/10 segments" 189 + 190 + def test_on_status_changed_updates_status(self): 191 + app = _make_app() 192 + app._build_menu() 193 + 194 + app._on_status_changed("paused") 195 + 196 + assert app.status == "paused" 197 + 198 + 199 + class TestConfigIntegration: 200 + def test_config_paths_use_base_dir(self, tmp_path): 201 + app = _make_app(tmp_path) 202 + 203 + assert str(app.config.captures_dir).startswith(str(tmp_path)) 204 + assert str(app.config.config_path).startswith(str(tmp_path)) 205 + 206 + def test_agent_instructions_template_uses_config_values(self, tmp_path): 207 + app = _make_app(tmp_path) 208 + 209 + text = AGENT_INSTRUCTIONS.format( 210 + source_dir=SOURCE_DIR, 211 + config_path=str(app.config.config_path), 212 + captures_dir=str(app.config.captures_dir), 213 + ) 214 + 215 + assert SOURCE_DIR in text 216 + assert str(app.config.config_path) in text 217 + assert str(app.config.captures_dir) in text