Files
punktfunk/scripts/gen-tray-icons.py
T
enricobuehler 8005b11faf 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>
2026-07-03 12:09:35 +00:00

167 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()