Skip to content

Commit 646e631

Browse files
Reidondclaude
andauthored
Add theme-aware tray icons with live XDG portal color-scheme support (#5)
* refactor: use theme-aware symbolic tray icons with portal-based dark/light detection Replace the hardcoded grey (#5a5a5a) SVG tray icons with two theme-variant icon sets: white icons for dark panels and dark icons for light panels. The active variant is selected at startup by reading the color-scheme from the XDG desktop portal (org.freedesktop.appearance), falling back to a GTK theme-name heuristic when the portal is unavailable. A D-Bus signal subscription on SettingChanged provides live icon switching whenever the user toggles between dark and light mode. https://claude.ai/code/session_01QQf7jwTUQUMVL5R7h9BiNA * refactor: use freedesktop symbolic icon names instead of embedded SVGs Replace the generated SVG icons and theme detection machinery with standard freedesktop symbolic icon names (system-lock-screen-symbolic, security-high-symbolic, network-offline-symbolic). The -symbolic suffix tells the desktop environment to recolor the icons automatically for the current panel theme, eliminating the need for temp directories, portal watchers, and manual dark/light detection. https://claude.ai/code/session_01QQf7jwTUQUMVL5R7h9BiNA * style: apply ruff formatting to tray.py https://claude.ai/code/session_01QQf7jwTUQUMVL5R7h9BiNA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dcb7380 commit 646e631

File tree

2 files changed

+30
-117
lines changed

2 files changed

+30
-117
lines changed

src/bwssh/tray.py

Lines changed: 18 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import logging
1414
import shutil
1515
import subprocess
16-
import tempfile
1716
from pathlib import Path
1817
from typing import Any
1918

@@ -172,73 +171,13 @@ def _appindicator_install_hint() -> str:
172171

173172
_POLL_INTERVAL_SECONDS = 5
174173

175-
# Fallback themed icons used for notifications (these don't appear in panel)
176-
_NOTIFY_ICON_LOCKED = "system-lock-screen-symbolic"
177-
_NOTIFY_ICON_UNLOCKED = "security-high-symbolic"
178-
_NOTIFY_ICON_DISCONNECTED = "network-offline-symbolic"
179-
180-
# ---------------------------------------------------------------------------
181-
# Icon generation — SVG icons using absolute paths
182-
# ---------------------------------------------------------------------------
183-
184-
# We write SVG files to a temp directory and pass their *absolute paths*
185-
# (without extension) as icon names to AppIndicator3. This is the most
186-
# reliable approach across desktop environments — it avoids icon theme
187-
# lookup issues where the tray (a separate process) cannot find the icons.
188-
#
189-
# The SVGs use a medium grey (#5a5a5a) fill which is visible on both dark
190-
# and light panels. On dark panels it appears as a mid-tone; on light
191-
# panels it's clearly dark enough to see.
192-
193-
_LOCKED_SVG = """\
194-
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
195-
viewBox="0 0 24 24">
196-
<g fill="none" stroke="#5a5a5a" stroke-width="1.8"
197-
stroke-linecap="round" stroke-linejoin="round">
198-
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
199-
<rect x="5" y="11" width="14" height="10" rx="2"
200-
fill="#5a5a5a" stroke="none"/>
201-
</g>
202-
</svg>"""
203-
204-
_UNLOCKED_SVG = """\
205-
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
206-
viewBox="0 0 24 24">
207-
<g fill="none" stroke="#5a5a5a" stroke-width="1.8"
208-
stroke-linecap="round" stroke-linejoin="round">
209-
<path d="M5 12l1.2 7.5A2 2 0 0 0 8.2 21h7.6a2 2 0 0 0 2-1.5L19 12z"/>
210-
<path d="M5 12L12 3l7 9"/>
211-
<path d="M9.5 15.5l2 2 3.5-4.5"/>
212-
</g>
213-
</svg>"""
214-
215-
_DISCONNECTED_SVG = """\
216-
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
217-
<g fill="none" stroke="#5a5a5a" stroke-width="1.8" stroke-linecap="round">
218-
<circle cx="12" cy="12" r="9"/>
219-
<line x1="5.6" y1="5.6" x2="18.4" y2="18.4"/>
220-
</g>
221-
</svg>"""
222-
223-
224-
def _create_icon_dir() -> tuple[Path, str, str, str]:
225-
"""Create a temp directory with SVG icons and return absolute paths.
226-
227-
Returns ``(icon_dir, locked_path, unlocked_path, disconnected_path)``
228-
where each ``*_path`` is an absolute path **without** the ``.svg``
229-
extension, suitable for passing directly to ``set_icon_full``.
230-
"""
231-
icon_dir = Path(tempfile.mkdtemp(prefix="bwssh-icons-"))
232-
233-
locked = icon_dir / "bwssh-locked"
234-
unlocked = icon_dir / "bwssh-unlocked"
235-
disconnected = icon_dir / "bwssh-disconnected"
236-
237-
locked.with_suffix(".svg").write_text(_LOCKED_SVG)
238-
unlocked.with_suffix(".svg").write_text(_UNLOCKED_SVG)
239-
disconnected.with_suffix(".svg").write_text(_DISCONNECTED_SVG)
240-
241-
return icon_dir, str(locked), str(unlocked), str(disconnected)
174+
# Symbolic icon names from the freedesktop icon theme. The ``-symbolic``
175+
# suffix tells the desktop environment to recolor them automatically for
176+
# the current panel theme (dark or light) — no manual theme detection
177+
# or embedded SVGs needed.
178+
_ICON_LOCKED = "system-lock-screen-symbolic"
179+
_ICON_UNLOCKED = "security-high-symbolic"
180+
_ICON_DISCONNECTED = "network-offline-symbolic"
242181

243182

244183
# ---------------------------------------------------------------------------
@@ -280,19 +219,11 @@ def __init__(self, socket_path: Path) -> None:
280219
except Exception:
281220
logger.debug("Failed to initialise libnotify", exc_info=True)
282221

283-
# Generate SVG icons and get their absolute paths (without extension)
284-
(
285-
self._icon_dir,
286-
self._icon_locked,
287-
self._icon_unlocked,
288-
self._icon_disconnected,
289-
) = _create_icon_dir()
290-
291-
# Build the indicator — use absolute path so AppIndicator finds the
292-
# icon immediately without needing an icon theme lookup.
222+
# Build the indicator using a symbolic icon name — the desktop
223+
# environment recolors -symbolic icons for the panel theme.
293224
self._indicator = AppIndicator3.Indicator.new(
294225
"bwssh",
295-
self._icon_disconnected,
226+
_ICON_DISCONNECTED,
296227
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
297228
)
298229
self._indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
@@ -362,13 +293,13 @@ def _notify_state_change(
362293
if not prev_connected and self._connected:
363294
if self._locked:
364295
self._send_notification(
365-
"Agent Connected", "Vault is locked", _NOTIFY_ICON_LOCKED
296+
"Agent Connected", "Vault is locked", _ICON_LOCKED
366297
)
367298
else:
368299
self._send_notification(
369300
"Agent Connected",
370301
f"Vault is unlocked ({self._key_count} keys)",
371-
_NOTIFY_ICON_UNLOCKED,
302+
_ICON_UNLOCKED,
372303
)
373304
return
374305

@@ -377,7 +308,7 @@ def _notify_state_change(
377308
self._send_notification(
378309
"Agent Disconnected",
379310
"Daemon is not running",
380-
_NOTIFY_ICON_DISCONNECTED,
311+
_ICON_DISCONNECTED,
381312
)
382313
return
383314

@@ -386,15 +317,13 @@ def _notify_state_change(
386317
self._send_notification(
387318
"Vault Unlocked",
388319
f"{self._key_count} SSH key(s) loaded",
389-
_NOTIFY_ICON_UNLOCKED,
320+
_ICON_UNLOCKED,
390321
)
391322
return
392323

393324
# Unlocked -> Locked
394325
if self._connected and prev_locked is False and self._locked is True:
395-
self._send_notification(
396-
"Vault Locked", "SSH keys cleared", _NOTIFY_ICON_LOCKED
397-
)
326+
self._send_notification("Vault Locked", "SSH keys cleared", _ICON_LOCKED)
398327

399328
def _send_notification(self, summary: str, body: str, icon: str) -> None:
400329
"""Show a desktop notification via libnotify."""
@@ -409,11 +338,11 @@ def _send_notification(self, summary: str, body: str, icon: str) -> None:
409338
def _update_icon(self) -> None:
410339
"""Set the tray icon based on current state."""
411340
if not self._connected:
412-
icon = self._icon_disconnected
341+
icon = _ICON_DISCONNECTED
413342
elif self._locked:
414-
icon = self._icon_locked
343+
icon = _ICON_LOCKED
415344
else:
416-
icon = self._icon_unlocked
345+
icon = _ICON_UNLOCKED
417346

418347
self._indicator.set_icon_full(icon, self._status_text())
419348

@@ -510,6 +439,4 @@ def _on_quit(self, _item: Any) -> None:
510439
"""Exit the tray application."""
511440
if self._notifications_enabled:
512441
_Notify.uninit()
513-
# Clean up generated icon files
514-
shutil.rmtree(self._icon_dir, ignore_errors=True)
515442
Gtk.main_quit()

tests/test_tray.py

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@
1717
from bwssh.control import ControlError
1818

1919
# The module must always be importable even when AppIndicator3 is missing.
20-
from bwssh.tray import TRAY_AVAILABLE, TrayIcon, _install_hint_for_os_release
20+
from bwssh.tray import (
21+
_ICON_DISCONNECTED,
22+
_ICON_LOCKED,
23+
_ICON_UNLOCKED,
24+
TRAY_AVAILABLE,
25+
TrayIcon,
26+
_install_hint_for_os_release,
27+
)
2128

2229

2330
class TestAppIndicatorInstallHint:
@@ -164,17 +171,8 @@ def gi_mocks() -> dict[str, MagicMock]:
164171
@pytest.fixture
165172
def gi_patched(
166173
gi_mocks: dict[str, MagicMock],
167-
tmp_path: Path,
168174
) -> Generator[dict[str, MagicMock]]:
169175
"""Patch gi libraries on the _tray module for the full test duration."""
170-
icon_dir = tmp_path / "icons"
171-
icon_dir.mkdir()
172-
icon_result = (
173-
icon_dir,
174-
str(icon_dir / "bwssh-locked"),
175-
str(icon_dir / "bwssh-unlocked"),
176-
str(icon_dir / "bwssh-disconnected"),
177-
)
178176
with (
179177
patch("bwssh.tray.TRAY_AVAILABLE", True),
180178
patch(
@@ -186,7 +184,6 @@ def gi_patched(
186184
patch("bwssh.tray.GLib", gi_mocks["GLib"], create=True),
187185
patch("bwssh.tray._NOTIFY_AVAILABLE", True),
188186
patch("bwssh.tray._Notify", gi_mocks["Notify"], create=True),
189-
patch("bwssh.tray._create_icon_dir", return_value=icon_result),
190187
):
191188
yield gi_mocks
192189

@@ -327,7 +324,7 @@ def test_icon_disconnected(
327324
tray._connected = False
328325
tray._update_icon()
329326
gi_patched["indicator"].set_icon_full.assert_called_with(
330-
tray._icon_disconnected, "bwssh: Daemon not running"
327+
_ICON_DISCONNECTED, "bwssh: Daemon not running"
331328
)
332329

333330
def test_icon_locked(
@@ -339,7 +336,7 @@ def test_icon_locked(
339336
tray._locked = True
340337
tray._update_icon()
341338
gi_patched["indicator"].set_icon_full.assert_called_with(
342-
tray._icon_locked, "bwssh: Locked"
339+
_ICON_LOCKED, "bwssh: Locked"
343340
)
344341

345342
def test_icon_unlocked(
@@ -352,7 +349,7 @@ def test_icon_unlocked(
352349
tray._key_count = 5
353350
tray._update_icon()
354351
gi_patched["indicator"].set_icon_full.assert_called_with(
355-
tray._icon_unlocked, "bwssh: Unlocked (5 keys)"
352+
_ICON_UNLOCKED, "bwssh: Unlocked (5 keys)"
356353
)
357354

358355

@@ -730,18 +727,7 @@ def test_no_notification_when_notify_unavailable(
730727
gi_patched: dict[str, MagicMock],
731728
) -> None:
732729
"""Notifications are silently skipped when libnotify is absent."""
733-
icon_dir = tmp_path / "icons2"
734-
icon_dir.mkdir()
735-
icon_result = (
736-
icon_dir,
737-
str(icon_dir / "bwssh-locked"),
738-
str(icon_dir / "bwssh-unlocked"),
739-
str(icon_dir / "bwssh-disconnected"),
740-
)
741-
with (
742-
patch("bwssh.tray._NOTIFY_AVAILABLE", False),
743-
patch("bwssh.tray._create_icon_dir", return_value=icon_result),
744-
):
730+
with patch("bwssh.tray._NOTIFY_AVAILABLE", False):
745731
t = TrayIcon(tmp_path / "control.sock")
746732

747733
assert t._notifications_enabled is False

0 commit comments

Comments
 (0)