feat(tray): system-tray status icon for the host (Windows + Linux)

New crates/punktfunk-tray — a small per-user companion showing the host service
state at a glance (running / stopped / starting / degraded / failed + the live
session in the tooltip) with one-click actions: open web console, approve a
pending pairing request, start/stop/restart, open logs. No more digging through
logs to learn whether the service came back after a reboot or an update.

Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can
never fake Running), then the new loopback-only unauthenticated
GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem
are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth).

Windows: windows_subsystem binary (a console exe in the Run key would flash a
terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single
instance, TaskbarCreated re-add, --quit for the uninstaller; service actions
elevate per click via ShellExecuteW "runas" onto the new
`punktfunk-host service restart` (stop → wait Stopped → start).
Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit),
/etc/xdg/autostart entry whose --autostart self-gates to actual host users.
Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status
dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons.

Live-validated: Linux on the headless KDE session (SNI registration, state
transitions, menu-driven start, dbusmenu layout); Windows on the RTX box
(session-1 launch with no NIM_ADD failure, single instance, --quit, restart
round-trip, summary loopback-200/LAN-401).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 12:09:35 +00:00
parent 01fcb01019
commit 8005b11faf
35 changed files with 2166 additions and 19 deletions
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Generate the punktfunk-tray status icons (committed, like the other branding assets).
Renders the brand mark — the two overlapping circles ("lens") from web's brand-mark.tsx, the
same geometry gen-branding.ps1 uses — with a status dot in the lower-right corner:
running colored mark + green dot
stopped grayscale mark + gray dot
error colored mark + red dot
degraded colored mark + amber dot (starting / running-but-status-unreachable)
streaming colored mark + bright-violet dot
Outputs (all checked in; re-run only when the brand or the palette changes):
packaging/windows/branding/punktfunk-tray-<state>.ico 16/20/24/32/48 px PNG-entry icos
(Vista+ format, same as punktfunk.ico)
packaging/linux/icons/hicolor/{22x22,48x48}/apps/punktfunk-tray[-<state>].png
(running is the unsuffixed base name)
Pure stdlib (zlib PNG writer, analytic 4x-supersampled rasterizer) so it runs on any dev box —
no PIL/ImageMagick/librsvg needed.
"""
import math
import struct
import zlib
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
# Brand-mark geometry in its 1000-unit viewbox (brand-mark.tsx; mirrors gen-branding.ps1).
R = 194.41
C1 = (403.037, 597.262) # light circle, behind
C2 = (597.8075, 402.8525) # deep circle, in front
BB_MIN = (C1[0] - R, C2[1] - R)
BB_MAX = (C2[0] + R, C1[1] + R)
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
MARK_SPAN = BB_MAX[0] - BB_MIN[0] # the bbox is square
COL_LIGHT = (0xA7, 0x9F, 0xF8)
COL_DEEP = (0x6C, 0x5B, 0xF3)
COL_HI = (0xD2, 0xC9, 0xFB)
RING = (0x1C, 0x15, 0x30) # dot outline, the brand tile background
STATES = {
"running": {"dot": (0x2E, 0xCC, 0x71), "gray": False},
"stopped": {"dot": (0x8A, 0x8A, 0x8A), "gray": True},
"error": {"dot": (0xE7, 0x4C, 0x3C), "gray": False},
"degraded": {"dot": (0xF0, 0xA0, 0x30), "gray": False},
"streaming": {"dot": (0xB4, 0x4C, 0xF0), "gray": False},
}
def luma(c):
y = round(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2])
return (y, y, y)
def render(size, dot_rgb, gray, ss=4):
"""RGBA rows, 4x supersampled: mark centered upper-left-ish, dot lower-right."""
n = size * ss
mark_c = (0.44 * n, 0.44 * n)
scale = (0.82 * n) / MARK_SPAN
dot_c = (0.76 * n, 0.76 * n)
dot_r = 0.21 * n
ring_r = dot_r + max(0.055 * n, 1.0 * ss)
c_light = luma(COL_LIGHT) if gray else COL_LIGHT
c_deep = luma(COL_DEEP) if gray else COL_DEEP
c_hi = luma(COL_HI) if gray else COL_HI
c1 = (mark_c[0] + (C1[0] - MARK_CENTER[0]) * scale, mark_c[1] + (C1[1] - MARK_CENTER[1]) * scale)
c2 = (mark_c[0] + (C2[0] - MARK_CENTER[0]) * scale, mark_c[1] + (C2[1] - MARK_CENTER[1]) * scale)
r = R * scale
rows = []
for y in range(size):
row = bytearray()
for x in range(size):
# Premultiplied accumulation over the ss×ss sample grid (no fringe on the rim).
ar = ag = ab = aa = 0.0
for sy in range(ss):
for sx in range(ss):
px = x * ss + sx + 0.5
py = y * ss + sy + 0.5
d1 = math.hypot(px - c1[0], py - c1[1])
d2 = math.hypot(px - c2[0], py - c2[1])
dd = math.hypot(px - dot_c[0], py - dot_c[1])
col = None
if dd < dot_r:
col = dot_rgb
elif dd < ring_r:
col = RING
elif d1 < r and d2 < r:
col = c_hi
elif d2 < r:
col = c_deep
elif d1 < r:
col = c_light
if col is not None:
ar += col[0]
ag += col[1]
ab += col[2]
aa += 255.0
samples = ss * ss
a = aa / samples
if a < 1.0:
row += b"\x00\x00\x00\x00"
else:
row += bytes(
(round(ar / aa * 255), round(ag / aa * 255), round(ab / aa * 255), round(a))
)
rows.append(bytes(row))
return rows
def png_bytes(size, rows):
def chunk(tag, data):
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)
ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
idat = zlib.compress(b"".join(b"\x00" + r for r in rows), 9)
return (
b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
)
def ico_bytes(pngs):
"""PNG-entry .ico (Vista+; the format punktfunk.ico already uses)."""
header = struct.pack("<HHH", 0, 1, len(pngs))
entries = b""
blobs = b""
offset = len(header) + 16 * len(pngs)
for size, png in pngs:
entries += struct.pack(
"<BBBBHHII", size if size < 256 else 0, size if size < 256 else 0, 0, 0, 1, 32, len(png), offset
)
blobs += png
offset += len(png)
return header + entries + blobs
def main():
ico_dir = REPO / "packaging/windows/branding"
for state, spec in STATES.items():
pngs = [
(s, png_bytes(s, render(s, spec["dot"], spec["gray"])))
for s in (16, 20, 24, 32, 48)
]
out = ico_dir / f"punktfunk-tray-{state}.ico"
out.write_bytes(ico_bytes(pngs))
print(f"wrote {out.relative_to(REPO)}")
for s in (22, 48):
name = "punktfunk-tray" if state == "running" else f"punktfunk-tray-{state}"
png_dir = REPO / f"packaging/linux/icons/hicolor/{s}x{s}/apps"
png_dir.mkdir(parents=True, exist_ok=True)
out = png_dir / f"{name}.png"
out.write_bytes(png_bytes(s, render(s, spec["dot"], spec["gray"])))
print(f"wrote {out.relative_to(REPO)}")
if __name__ == "__main__":
main()