diff --git a/.gitignore b/.gitignore index 4b16631..4e287f5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,12 @@ daemon/.venv/ # Python bytecode cache __pycache__/ *.pyc + +# Windows daemon venv +/.venv/ + +# PyInstaller build artifacts — the standalone exe ships via a Gitea release, +# not git (build it with build-exe.ps1 / clawdmeter.spec). +/build/ +/dist/ +*_dbg.spec diff --git a/build-exe.ps1 b/build-exe.ps1 new file mode 100644 index 0000000..7cf58f2 --- /dev/null +++ b/build-exe.ps1 @@ -0,0 +1,48 @@ +# build-exe.ps1 — build the standalone Clawdmeter.exe (PyInstaller). +# +# Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable +# that runs on ANY Windows 11 machine with no Python install and no pip. Build it +# on a machine that DOES have Python (e.g. your dev box), then distribute the exe +# via a Gitea release; end users just download and run it (see daemon\README-windows.md). +# +# Usage (from anywhere): +# powershell -ExecutionPolicy Bypass -File build-exe.ps1 + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Log { param([string]$Msg) Write-Host "[$(Get-Date -Format HH:mm:ss)] $Msg" } + +$RepoRoot = $PSScriptRoot +if (-not $RepoRoot) { $RepoRoot = (Get-Location).Path } +Set-Location $RepoRoot + +$VenvDir = Join-Path $RepoRoot ".venv" +$PythonExe = Join-Path $VenvDir "Scripts\python.exe" + +Log "=== Clawdmeter exe build ===" + +# 1. Virtual environment +if (-not (Test-Path $PythonExe)) { + Log "Creating virtual environment at .venv ..." + & python -m venv $VenvDir + if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) — is Python on PATH?" } +} + +# 2. Runtime dependencies + PyInstaller (the only build-time extra) +Log "Installing runtime dependencies + PyInstaller ..." +& $PythonExe -m pip install --quiet -r (Join-Path $RepoRoot "daemon\requirements-windows.txt") +if ($LASTEXITCODE -ne 0) { throw "pip install (runtime deps) failed (exit $LASTEXITCODE)" } +& $PythonExe -m pip install --quiet pyinstaller +if ($LASTEXITCODE -ne 0) { throw "pip install pyinstaller failed (exit $LASTEXITCODE)" } + +# 3. Build from the committed spec (onefile, windowed) +Log "Building dist\Clawdmeter.exe (this takes a minute) ..." +& $PythonExe -m PyInstaller --noconfirm (Join-Path $RepoRoot "clawdmeter.spec") +if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed (exit $LASTEXITCODE)" } + +$ExePath = Join-Path $RepoRoot "dist\Clawdmeter.exe" +if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" } +$sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1) +Log "Build complete: $ExePath ($sizeMB MB)" +Log "Distribute this exe via a Gitea release — it is intentionally not committed to git." diff --git a/clawdmeter.spec b/clawdmeter.spec new file mode 100644 index 0000000..6f315a7 --- /dev/null +++ b/clawdmeter.spec @@ -0,0 +1,76 @@ +# -*- mode: python ; coding: utf-8 -*- +# +# clawdmeter.spec — build the standalone Windows daemon + tray executable. +# +# Produces a single, self-contained dist\Clawdmeter.exe that bundles its own +# Python, bleak (WinRT BLE) and the winrt media projection — so it runs on ANY +# Windows 11 machine with no Python and no pip install. The exe is the tray app +# (daemon thread + notification-area icon + login-autostart toggle); it is built +# windowed, so there is no console window and it logs to +# %LOCALAPPDATA%\Clawdmeter\daemon.log. +# +# Build (from the repo root, inside the venv) — or just run build-exe.ps1: +# .venv\Scripts\python.exe -m PyInstaller --noconfirm clawdmeter.spec +# +# winrt + bleak ship C-extension projections (.pyd) that PyInstaller's static +# analysis misses: bleak imports its WinRT backend dynamically, and the +# winrt.windows.* namespaces are split across separate distributions. collect_all +# pulls in their submodules, binaries and metadata so both BLE (bluetooth) and the +# now-playing media session work inside the frozen exe. Verified on hardware: +# connects, reads the media session, and pushes Cyrillic now-playing payloads. + +from PyInstaller.utils.hooks import collect_all + +datas = [('firmware/src/logo.h', 'firmware/src')] # tray icon parsed at runtime +binaries = [] +hiddenimports = [ + # Imported lazily inside tray_windows.main(), so name them explicitly. + 'daemon.claude_usage_daemon_windows', + 'daemon.autostart_windows', + 'daemon.icon_assets', + # The exact winrt media modules read_now_playing() pulls in. + 'winrt.windows.media', + 'winrt.windows.media.control', +] +for _pkg in ('winrt', 'bleak', 'pystray', 'PIL'): + _d, _b, _h = collect_all(_pkg) + datas += _d + binaries += _b + hiddenimports += _h + + +a = Analysis( + ['daemon\\tray_windows.py'], + pathex=['.'], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['tkinter', 'pytest'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Clawdmeter', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, # UPX compression raises AV false-positive rates — leave it off + upx_exclude=[], + runtime_tmpdir=None, + console=False, # windowed tray app: no console window; logs to daemon.log + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/daemon/README-windows.md b/daemon/README-windows.md index 5f75347..98f68e9 100644 --- a/daemon/README-windows.md +++ b/daemon/README-windows.md @@ -59,7 +59,46 @@ pairing disables the keyboard buttons. --- -## Setup (one time) +## Standalone executable — no Python required (recommended) + +To run Clawdmeter on a machine **without Python**, use the single-file +`Clawdmeter.exe`. It bundles its own Python, the WinRT BLE stack and the +media-session reader, so nothing needs to be installed. + +1. Pair the device with Windows once (see [Pair the device](#pair-the-device-one-time)). +2. Get `Clawdmeter.exe` — download it from the project's Gitea release, or build it + yourself (below). +3. Double-click `Clawdmeter.exe`. The tray icon appears and the watch starts + updating within ~10 seconds. +4. To launch it automatically at every logon, right-click the tray icon → + **Start at login**. That registers the exe itself under + `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` — no Python, no console window. + +There is no console window; the exe logs to `%LOCALAPPDATA%\Clawdmeter\daemon.log`. + +### Building the exe + +On a machine that *does* have Python (e.g. your dev box), from the repo root: + +```powershell +powershell -ExecutionPolicy Bypass -File build-exe.ps1 +``` + +This creates `dist\Clawdmeter.exe` (~30–60 MB). The PyInstaller config lives in +`clawdmeter.spec`. The exe is intentionally **not** committed to git — distribute +it through a Gitea release. + +> **SmartScreen / antivirus:** unsigned PyInstaller executables are sometimes +> flagged by a generic heuristic (not a real detection). Until the exe is +> code-signed, you may need to allow it through SmartScreen ("More info → Run +> anyway"). Building from source sidesteps this entirely. + +--- + +## Setup from source (one time) + +> Use this if you prefer running from a Python checkout instead of the standalone +> exe above (e.g. on your dev box). Open a PowerShell terminal and `cd` to the repository root. @@ -224,5 +263,5 @@ launched. ## What is NOT covered here -- PyInstaller / one-file `.exe` packaging — v2 +- Code-signing the standalone `.exe` (to avoid SmartScreen/AV prompts) — future - MAC-address cache / sleep-wake reconnect hardening — Phase 3 diff --git a/daemon/autostart_windows.py b/daemon/autostart_windows.py index 3bded88..70b1e16 100644 --- a/daemon/autostart_windows.py +++ b/daemon/autostart_windows.py @@ -56,6 +56,11 @@ def _command(tray_script: str | None = None) -> str: module's own path (useful when autostart_windows.py IS the entry point, but callers should pass tray_windows.py). """ + if getattr(sys, "frozen", False): + # Running as the bundled PyInstaller exe: autostart launches the exe + # itself (sys.executable), not a pythonw + script pair. The exe is built + # windowed, so this is genuinely consoleless — no script path involved. + return f'"{sys.executable}"' 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}"' diff --git a/daemon/tray_windows.py b/daemon/tray_windows.py index 58da9f0..d2900d7 100644 --- a/daemon/tray_windows.py +++ b/daemon/tray_windows.py @@ -27,7 +27,13 @@ import time # imports below and the brand-logo asset load work no matter what the current # working directory is — critical for logon autostart, where the HKCU\Run entry # starts with cwd = System32, not the repo (APP-01 / SC#1). -_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if getattr(sys, "frozen", False): + # PyInstaller bundle: the `daemon` package loads from the frozen archive and + # data files (the brand logo.h) live under sys._MEIPASS. Point _REPO_ROOT at + # the bundle root so the logo asset path below resolves inside the exe. + _REPO_ROOT = sys._MEIPASS # type: ignore[attr-defined] +else: + _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT)