Files
clawdmeter/daemon/autostart_windows.py
kvenanzi eb70456755 Add native Windows host daemon (bleak/WinRT)
Adds a Windows port of the host daemon so the Clawdmeter stays connected
on Windows independent of WSL. Mirrors the macOS daemon and speaks the
existing GATT data service unchanged — no firmware changes.

- claude_usage_daemon_windows.py: Windows-local OAuth token read +
  Anthropic poll + BLE scan/connect/write, with auto-reconnect
  (connect-retry wrapper, zombie-link break, split fast/slow backoff)
- tray_windows.py: pystray login-startup tray app (status icon + Quit)
- autostart_windows.py: winreg HKCU\Run autostart via pythonw.exe
- icon_assets.py: per-state tray icons composited from logo.h
- install-windows.ps1 + daemon/README-windows.md: turnkey setup
- pytest suite: token / poll / reconnect / tray / autostart / no-WSL guard
2026-06-02 16:39:07 -05:00

109 lines
4.0 KiB
Python

"""Login-autostart toggle for Clawdmeter — APP-01 / D-07.
Manages a per-user HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run
registry value named "Clawdmeter" that launches the tray app headlessly via
pythonw.exe (no console window — D-08).
winreg is Windows stdlib; this module guards the import so it can be imported
on the Linux dev box (unit tests mock `daemon.autostart_windows.winreg`).
Public API:
enable(tray_script=None) -- write/overwrite the Run value
disable() -- remove the Run value; idempotent when absent
is_enabled() -- True if the Run value is currently present
"""
import os
import sys
import time
# Guard the import so the module is importable off-Windows.
# Unit tests replace this attribute via:
# patch("daemon.autostart_windows.winreg", <MagicMock>)
try:
import winreg as winreg # type: ignore[import]
except ImportError:
winreg = None # type: ignore[assignment]
# Registry key (no leading backslash — OpenKey uses relative path under hive).
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
_VALUE_NAME = "Clawdmeter"
def log(msg: str) -> None:
"""Log in the daemon [HH:MM:SS] style."""
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def _command(tray_script: str | None = None) -> str:
"""Build the headless launch command for the Run value.
Uses the BASE interpreter's pythonw.exe — `sys.base_exec_prefix` points at
the real Python install even inside a venv — NOT the venv's
`Scripts\\pythonw.exe`. The venv pythonw is a redirector stub that
re-launches the CONSOLE `python.exe` build as a child process (a CPython
venv-launcher bug, verified empirically on Python 3.13), which pops a black
console window at logon and kills the tray when closed (field bug, SC#1).
The base pythonw loads in-process and is genuinely windowless.
The path is never hard-coded (D-08, CLAUDE.md "repoint ExecStart" lesson);
both paths are quoted for space safety. tray_windows.py adds the venv's
site-packages to sys.path itself, so the venv's deps still resolve under the
base interpreter.
Args:
tray_script: absolute path to the tray entry script. Defaults to this
module's own path (useful when autostart_windows.py IS
the entry point, but callers should pass tray_windows.py).
"""
pythonw = os.path.join(sys.base_exec_prefix, "pythonw.exe")
script = os.path.abspath(tray_script if tray_script is not None else __file__)
return f'"{pythonw}" "{script}"'
def enable(tray_script: str | None = None) -> None:
"""Write (or overwrite) the HKCU Run value pointing at pythonw.exe.
No admin elevation required — HKCU is per-user (D-07, ASVS V4).
Args:
tray_script: path to the tray entry script (passed to _command()).
"""
cmd = _command(tray_script)
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, _VALUE_NAME, 0, winreg.REG_SZ, cmd)
log(f"Autostart enabled: {cmd}")
def disable() -> None:
"""Remove the HKCU Run value. Idempotent — no error if already absent.
Mirrors the read_token() OSError-swallow pattern (daemon L201-208).
"""
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_SET_VALUE
) as key:
winreg.DeleteValue(key, _VALUE_NAME)
log("Autostart disabled")
except FileNotFoundError:
pass # already absent — idempotent
def is_enabled() -> bool:
"""Return True if the Run value is currently present, False otherwise.
Queries the live registry on every call so the state reflects external
changes (e.g. the user deleting the value manually) — Pitfall 6 guard.
"""
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_QUERY_VALUE
) as key:
winreg.QueryValueEx(key, _VALUE_NAME)
return True
except FileNotFoundError:
return False