v2: standalone Clawdmeter.exe — runs on any Win11 machine, no Python
Package the tray+daemon as a single PyInstaller onefile exe so it runs on a fresh Windows 11 box with no Python and no pip install. Verified on hardware: the frozen exe connects over BLE, reads the Windows media session, and pushes now-playing (incl. Cyrillic) at the 3s / 60s cadence. - clawdmeter.spec: onefile, windowed (no console; logs to daemon.log). collect_all for winrt + bleak is the key bit — bleak loads its WinRT backend dynamically and the winrt.windows.* projections are split distributions that PyInstaller's static analysis misses, so BLE and the media session would otherwise fail at runtime. upx off (lowers AV false-positive rate). - build-exe.ps1: one-command build (venv + deps + pyinstaller + spec). - tray_windows.py: resolve the brand-logo asset from sys._MEIPASS when frozen. - autostart_windows.py: when frozen, the HKCU\Run "Start at login" value points at the exe itself (sys.executable), not pythonw + script. - .gitignore: ignore /build, /dist, /.venv — the exe ships via a Gitea release. - README-windows.md: document the standalone-exe path (download / run / build). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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}"'
|
||||
|
||||
@@ -27,6 +27,12 @@ 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).
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user