From 095fd20d6ebdf2d9e3ae8d991a0676fcf1a4a1c1 Mon Sep 17 00:00:00 2001 From: Michael Saba Date: Sun, 4 Oct 2026 13:59:18 -0400 Subject: [PATCH 1/3] feat: package HogWatch as a single Windows exe - hogwatch_app.py + build-exe.cmd build dist\HogWatch.exe with PyInstaller (one file, no console, dashboard files and an icon packed in). - Double-clicking it (launch): open the dashboard if already running; otherwise start a background copy as administrator, wait for it, open the dashboard un-elevated, and exit. A refused admin prompt falls back to running without per-program detail. - Packaged data lives in %LOCALAPPDATA%\HogWatch; --data-dir/--port (or HOGWATCH_DATA_DIR/HOGWATCH_PORT) override, and a copy on another port gets its own ETW session so it can't cut off the main one. - Dashboard: sign in to eero (login, code, network choice), start-at-login switch, Stop button, version. No terminal needed. - The eero session token is now DPAPI-encrypted; plain tokens from older versions are upgraded on load. - stop command; browser is opened via explorer.exe so it never runs elevated. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 1 + .gitignore | 4 + assets/hogwatch.ico | Bin 0 -> 2762 bytes assets/hogwatch.png | Bin 0 -> 2740 bytes build-exe.cmd | 11 ++ hogwatch/__init__.py | 2 +- hogwatch/__main__.py | 104 +++++++++++++--- hogwatch/autostart.py | 81 ++++++++++++ hogwatch/collector.py | 6 +- hogwatch/config.py | 54 ++++++-- hogwatch/eero.py | 18 ++- hogwatch/etw.py | 10 +- hogwatch/pcnet.py | 6 +- hogwatch/static/app.js | 127 +++++++++++++++---- hogwatch/static/index.html | 43 ++++++- hogwatch/static/style.css | 11 +- hogwatch/web.py | 72 ++++++++++- hogwatch/winutil.py | 43 +++++++ hogwatch_app.py | 13 ++ requirements-build.txt | 2 + scripts/make_icon.py | 69 +++++++++++ tests/test_packaging.py | 245 +++++++++++++++++++++++++++++++++++++ 22 files changed, 854 insertions(+), 68 deletions(-) create mode 100644 assets/hogwatch.ico create mode 100644 assets/hogwatch.png create mode 100644 build-exe.cmd create mode 100644 hogwatch/autostart.py create mode 100644 hogwatch/winutil.py create mode 100644 hogwatch_app.py create mode 100644 requirements-build.txt create mode 100644 scripts/make_icon.py create mode 100644 tests/test_packaging.py diff --git a/.gitattributes b/.gitattributes index c4179a4..dfe92c2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,4 @@ *.cmd text eol=crlf *.ps1 text eol=crlf *.png binary +*.ico binary diff --git a/.gitignore b/.gitignore index 3c8957f..707d773 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ __pycache__/ # History, logs, and the eero login token live here -- never commit it. data/ +# PyInstaller output (build-exe.cmd). Releases are built by the release workflow. +build/ +dist/ +*.spec diff --git a/assets/hogwatch.ico b/assets/hogwatch.ico new file mode 100644 index 0000000000000000000000000000000000000000..9b9c239c4566936ffaa6561bdc4e53e229c7eeea GIT binary patch literal 2762 zcmb_edpK0<8voXs!I(`3(cTf0OVNdj$aR*YP%fcVh)uWc62;yaGg?R`rIB{T4!dk5 z8BT6p%#4iWq|Gi&RC^}Gxa?FeLo;UfnmYY+_Bqcv&w0*T&wAGP``+*OzQ6Zr+$#SFir6pkc=;g!h)HhFPJRjff4r)R z&>j7z?U+{h!jke`Z#+recU|KfiRFD;hbXS!1V$1!nr6iqloNLj`Ym5V(mm;mFdi~m zVhEngn{(5)G`l&IvyU}aKg^%FuG4%i!0lfDl^(k(+WxuJ?6m>O!*sS?rycvs-bBeD ze>i6epzE1pV~q8 z73LQtBY=!qy@0za?7Enc){on6^X|7b0sjo~tjRO38>&JUuLtxWC6I2Vv+Yxf5S zda^!z(B0`-rtAt%q4w{$eH5z41z`KP4VKV^oe9UM&)fxcE>jUWB}lvAckOC%b32eu zHKX4(t6!)Yv20`b;}YVcOKrtbP$XdR*f|n0qHjHflkQHA=!hRp+UfPj6Nu z@z2BE!G)KdYH=SD;S2dB)b~Vj*)7hR&Ue19#rN#@Is-j{a$Lc6F%c0c2@?^a6_jw4 z(xsL)qQ$Ad1~r8y=|Qd%NLeG*x~bf^8I`_|(}ohZn|FCFdaJ>?YXCJJTCjptvWk)?1&3Mav=7VO%dOb~c4 zM=$A8=T?Ew&1Cu5; zs*M8(j{jyQn;x`rqr0Of83>kAB(1z%IfnfyJ|a8;u2(*W?)(>=Czxll(I5TgyF0}LK$93}ItmW`_Y@LVhlre@@WX)>; zE={wIri@9!gyF$@KBiKxW+2ZT3vPX^BNq!%u<3uNrK?AuSr9(EJ=S`&p{K7PJ~#Tj z>HzP3-CUCo-(WT7cTdRcHu39`#9Z16^)vGtNOXq(mbNkp{dd#g4-vn#V%p+KWz(5i zb9P)BhKkdQBp&Bk?g*xg+TPpA4yQes^?*ZJ_^trf+zablJ6^I}KN%l0WzXM!7sWFs zV^OLOuKW?jauSL)x67^53vQq0KT1n9NLV%KCw!E#=qQVF1J9)?BmAO0a-YSMUaKIi zB3+^{^qfzT|4I-2C%WIirvI1G`2z#1_kQ*eo#=b>z-Q;|boHlKBsT7sK4%i}^>0Ua zh!fo}{sS&uURaP^>SH9&_oGyac>BPoluO3MZt|2biWWYZ+QrfddCurG{lxBnqk7H% zHR&%k%lHo^|5CHRL>E-?&Zc2KQ^2-`?`Nc;(tNInE86Fb^=EREK5g#cO#8@)1rB_4o19B(sso94`I#3VMHYY!dD}qEPXQC8h?E< zHfkSVZ5jbnfjPY*oP!2J`#3I6DS;EtY}+#`b^__73=2Ai;Rn?1dPF!kh3bx1C+HLc z`35End8&>1GWs3gjakfbV;Ikgj4ie#R!%Bu@Ks7Cm2BheV(gFA-*`P*hbZY22JXxt zp-|ve<%FXSm{0#CxG+G?NQ+ZKuy4?FT?GIhpa75nw&x%K5I)}qzD%Oqp+FNn<6 zRnOgeie>w#f~8PQYJ`0-nSW4=2>G(u2rx1dK`< z+YDWn4*67Dcz^=p=QAZ$vlXH~X&=gGn*osmaOjlrSD@c3z|H;=E$OYy?rvVzJfTOm~+QaXvy^ATj{MuimHBp_DsNSnN3noZ4Y{l6= z`7J6!Vg}joM2eq%Ivus)xZI|Dy`nX6!u0d;7PRWvKIW@*1yZD`NtbE;p}j9|6Oq+m zTG%afgc&5fZT?(@J9w{=oo#?~*-*hd5&@G)uI*)gYQ72;+(`rWgw7>FY!3qG3h1iwl9nojcmW~O$u9+g%K-7! zAHkPF)p7X2W@Ku4k<+vBI#nppSh4j~%J4vwPE=)2Jzk(_5Ylcvd&va78Ia-N1@dIc z(p&Yskq!8m8E^H&IL<`T#|xKhta6Rf_QNXFE|YavueZh9w=l=yy!$?aCLE_hx1pvb z_-|kZ9~laVHcY;arVK5m9f5YR7~KIG+39I*zWckc>;?YZdyh|tdG64dK9@s1%FL}D zcGu(>6@>jzOAyYPNYquS&kO(n literal 0 HcmV?d00001 diff --git a/assets/hogwatch.png b/assets/hogwatch.png new file mode 100644 index 0000000000000000000000000000000000000000..d7a8d48333c5ea177dcdabdfc05a7ac069749f6a GIT binary patch literal 2740 zcmb_ddpMNq7Jt8Q24glEM7xN|rRYLMjqm&YbxpYT$kNth8e#KG%6Gl< zAa&h!j%y&6_ih`cI4=!|AZ{|wjMghB?i}!4v4o_3$_HUQWHd(;JXSR2rfzL=aU^FS zYp8yhKYmTC>8iiWy}rxcHj}jdvnko@{F8?0Y?}@n_T{|^k^#!iSVs2-tx);U)1fo1 z+L=~5G1f!0_9N{rdBi|w_2FJ8}1p2Bg1r8pxJ7cLBY|%6Jyh1%k!ET_W@Z}i(LttU1)p{4ksZ{E3 zWlK19J~V?U)jBg`u-8OsKAuw_{vE>scs|s&5^#JoM>jpQfov;Gvo=5CydLikFI@t^ zsK6~=w06Bs*>vB?Z9p5vWfUpLdH}5-M9JHjnh~*1Al2IDcMkMqL)d_;!}AQ;Wt`mY zzuxkes~!^ox9{rBp>Z1{j(4xA6X;m3ByfnAcETUpRAOg$ARQ_OZ`7+_su-}WqxoYJ z;-ZVK#gR}1VGcN*Nwer%jK-UKB}Fu7Kn*nt!d_*^$yd*AR3mXO!dyYts}7aePYG~V zJ_+?XSzLCLv$o@dk8|-o+r5rJN1zy6a7|1^1Pa0gL}&rUAEk7vWDaX^>aIeKA&EMW zvjkF9OR;P$_h~|Z#VFFJP8IU1#2mO&hEo(lAs?BU zcf3esjhZ1p=Kh&q$cVi~1c1{m;dioRu1M|_n9+cp+mZ+ZuNCM;ZR+eQM(zL!yw<|}#FWFm!CxASGm_pw*KR}tNDBT{Rqz1e;7tK$zcllrA-U2WPOPcIu z->}%8u9boZKhE_|NcYy->8*7H$afN*c+xJq8I`>l;&x*&;@iY# zir>jOv8>{tGG_LRg6?0xWOW-LDk8dLs^#R8~VJCM-o5?Yu))rOE2!%h0(mIZ}inpXPX-UvO{~^@l&x|>;ucy zA*8+jt!m_Uzfng(nv9I*o(|0ob6Pka;r{xtsyhRahhB2dGIlNr--dYuqq%G?nLPyB zLRcI6+ppa?p%2c^nXC`#_~mi!!g0oY$PK&au03gpSt~Q=)Bu;J-bz!%s9?nKU;`gx zxxl7B&lD?ed8|DbD^alde}|>1MxC7(KEE~Ea-+Vxw;(Pz>b!D4?_=$3qc>k~4VHIL z@Y`1L+u?*<+Dg^4b81Lby5H8;G70^SanPsmUt2J4aip@TjLcaZE)BDaQ;Q_-=UJ`@ z#*Et5)4>j-J(zKaLsOZ``@r$`+pVvN6*s#L(Bi@*`KNl zs(9y8v6;zX&%%jmX^1qRE8>dwIb!pf*sM#NJviMqJZ#3(mqyUC&gFcqHa)t5uR}&gT!&h-rOIzfMFt3GH8pB zt{@?<9$P`{g1H$VU5=THwgNF`9-!dY`x3~8%_I(jVU)H2tp%?>zIwGXVbBeF*rx*F zO*Qb&PL*>%t%1gk35;`%F3<}C{z4@1AO^cTpuUyvM28Zag7lG(!y65cw0Hry3_ zRLjoR!#QoN;2jBvNhH|%+u85EL6cdF{JH*Kde0}i9BF*DwHN-LD6lnyKz`vz6FBq+ z9Z)u1jSB9h0$W1Il0ddQfpZyj)_6)w<$^td5b5BTf`H|K`1<#tOQ7mF{9p?*xuVG7 z`B<$o6ksUdayogazfmi)vbzp1kk<=tvz)nTgx>H^xAO#fvLxxvI^OU`eDt)J>R}vb zyy$b*r5cM|L$vL%61CH4{grF2akkCOF*xtOx1bTnsn@QrX%6}eSjk5Q!=Q~5@1rP# z%Vb>cUj5>7U16?fXm#Q$NQu(aBB)*yrNp;aIgLIOT8c ChRziL literal 0 HcmV?d00001 diff --git a/build-exe.cmd b/build-exe.cmd new file mode 100644 index 0000000..d3c3c1a --- /dev/null +++ b/build-exe.cmd @@ -0,0 +1,11 @@ +@echo off +rem Builds dist\HogWatch.exe: one file, no console window, the dashboard's files packed inside. +rem The GitHub workflows run this same script, so releases never depend on one PC's setup. +cd /d "%~dp0" +set PY=python +if exist ".venv\Scripts\python.exe" set PY=.venv\Scripts\python.exe +%PY% -m pip install --quiet --disable-pip-version-check -r requirements-build.txt || exit /b 1 +%PY% -m PyInstaller --noconfirm --clean --onefile --noconsole --name HogWatch ^ + --icon assets\hogwatch.ico --add-data "hogwatch\static;hogwatch\static" hogwatch_app.py || exit /b 1 +echo. +echo Built dist\HogWatch.exe diff --git a/hogwatch/__init__.py b/hogwatch/__init__.py index 1146426..61e625b 100644 --- a/hogwatch/__init__.py +++ b/hogwatch/__init__.py @@ -11,4 +11,4 @@ web.py serves the local dashboard at http://127.0.0.1:8765 """ -__version__ = "1.0.0" +__version__ = "1.2.0" diff --git a/hogwatch/__main__.py b/hogwatch/__main__.py index c8dc8a5..f0371fd 100644 --- a/hogwatch/__main__.py +++ b/hogwatch/__main__.py @@ -1,35 +1,35 @@ """Command line: - python -m hogwatch run [--no-browser] start monitoring + dashboard (default) - python -m hogwatch eero-login connect to the eero (one time) + python -m hogwatch run [--no-browser] start monitoring + dashboard (default from source) + python -m hogwatch launch what double-clicking HogWatch.exe does: ask for admin + rights, start in the background, open the dashboard + python -m hogwatch stop stop a running HogWatch + python -m hogwatch eero-login connect to the eero (one time; also on the dashboard) python -m hogwatch eero-check show what the eero reports right now python -m hogwatch selftest 15-second check that everything works python -m hogwatch send-report email the last 24 hours now (--preview: save it as HTML instead) - python -m hogwatch installer-report technical report for the eero installer (HTML + PDF in data\\) + python -m hogwatch installer-report technical report for the eero installer (HTML + PDF in the data folder) + +Options before the command: --data-dir FOLDER, --port N (see config.py). """ from __future__ import annotations import argparse -import ctypes import json import logging import logging.handlers +import subprocess import sys import threading import time -import webbrowser +import urllib.request +from pathlib import Path from . import config, ping -from .config import DATA_DIR - - -def _is_admin() -> bool: - """True when running elevated (needed for the per-program breakdown).""" - try: - return bool(ctypes.windll.shell32.IsUserAnAdmin()) - except OSError: - return False +from .config import DATA_DIR, FROZEN, ROOT +from .winutil import is_admin as _is_admin +from .winutil import open_dashboard, run_elevated, server_up def _setup_logging() -> None: @@ -59,11 +59,12 @@ def cmd_run(args) -> int: db_ref: dict = {} reporter_ref: dict = {} try: - server = make_server(cfg["port"], collector_ref, db_ref, on_shutdown=done.set, reporter_ref=reporter_ref) + server = make_server(cfg["port"], collector_ref, db_ref, on_shutdown=done.set, reporter_ref=reporter_ref, + eero_session=DATA_DIR / "eero_session.json") except OSError: log.info("HogWatch is already running -- opening the dashboard") if not args.no_browser: - webbrowser.open(url) + open_dashboard(url) return 0 db = DB(DATA_DIR / "hogwatch.db") @@ -79,7 +80,7 @@ def cmd_run(args) -> int: reporter_ref["r"] = reporter threading.Thread(target=reporter.loop, name="report", daemon=True).start() if not args.no_browser: - webbrowser.open(url) + open_dashboard(url) try: while not done.wait(1): pass @@ -91,6 +92,61 @@ def cmd_run(args) -> int: return 0 +def _global_options() -> list[str]: + """--data-dir / --port as given on this command line, to pass on to a relaunched copy.""" + out = [] + for name in ("--data-dir", "--port"): + if name in sys.argv and sys.argv.index(name) + 1 < len(sys.argv): + out += [name, sys.argv[sys.argv.index(name) + 1]] + return out + + +def cmd_launch(args) -> int: + """Double-click behaviour: make sure HogWatch is running in the background (as admin if + the user allows it), open the dashboard, and exit. + + The background copy is started as administrator because Windows only shares + per-program network data with admin tools. This copy stays un-elevated so the + browser it opens isn't running as administrator. + """ + cfg = config.load() + port = int(cfg["port"]) + url = f"http://127.0.0.1:{port}/" + if server_up(port): + open_dashboard(url) + return 0 + if not _is_admin(): + params = _global_options() + ["run", "--no-browser"] + if FROZEN: + exe, argv, cwd = sys.executable, params, str(Path(sys.executable).parent) + else: + pyw = Path(sys.executable).with_name("pythonw.exe") + exe, argv, cwd = str(pyw if pyw.exists() else sys.executable), ["-m", "hogwatch", *params], str(ROOT) + if run_elevated(exe, subprocess.list2cmdline(argv), cwd): + for _ in range(60): # the packaged exe unpacks itself first; allow it 30 seconds + if server_up(port, timeout=0.5): + break + time.sleep(0.5) + open_dashboard(url) + return 0 + # The user said No to the admin prompt: run here without per-program detail. + args.no_browser = False + return cmd_run(args) + + +def cmd_stop(args) -> int: + """Ask a running HogWatch to shut down cleanly.""" + port = int(config.load()["port"]) + req = urllib.request.Request(f"http://127.0.0.1:{port}/api/shutdown", data=b"{}", method="POST", + headers={"X-HogWatch": "1", "Content-Type": "application/json"}) + try: + urllib.request.urlopen(req, timeout=5).close() + print("HogWatch stopped.") + except OSError: + print("HogWatch wasn't running.") + return 0 + + def cmd_eero_login(args) -> int: """Interactive one-time login. The user types their own login and code; we store only the session token.""" from .eero import Eero, EeroError @@ -245,9 +301,14 @@ def cmd_installer_report(args) -> int: def main() -> int: """Parse the command and run it.""" p = argparse.ArgumentParser(prog="hogwatch", description="Find out who is slowing the internet down.") + # Read early by config.py (they decide where config lives); declared here so argparse accepts them. + p.add_argument("--data-dir", help="folder for history, settings and logs") + p.add_argument("--port", type=int, help="dashboard port (default 8765)") sub = p.add_subparsers(dest="cmd") r = sub.add_parser("run", help="start monitoring and the dashboard") r.add_argument("--no-browser", action="store_true", help="don't open the dashboard") + sub.add_parser("launch", help="start in the background as administrator and open the dashboard") + sub.add_parser("stop", help="stop a running HogWatch") sub.add_parser("eero-login", help="connect to the eero (one time)") sub.add_parser("eero-check", help="show what the eero reports right now") st = sub.add_parser("selftest", help="15-second check that everything works") @@ -260,10 +321,13 @@ def main() -> int: ir.add_argument("--note", help="notes from the homeowner to include") args = p.parse_args() if args.cmd is None: - args = p.parse_args(["run"]) + # Double-clicking the exe gives no command: do the friendly thing. From source, + # plain `python -m hogwatch` keeps its original meaning (run in this window). + args.cmd = "launch" if FROZEN else "run" + args.no_browser = False DATA_DIR.mkdir(parents=True, exist_ok=True) - return {"run": cmd_run, "eero-login": cmd_eero_login, "eero-check": cmd_eero_check, - "selftest": cmd_selftest, "send-report": cmd_send_report, + return {"run": cmd_run, "launch": cmd_launch, "stop": cmd_stop, "eero-login": cmd_eero_login, + "eero-check": cmd_eero_check, "selftest": cmd_selftest, "send-report": cmd_send_report, "installer-report": cmd_installer_report}[args.cmd](args) diff --git a/hogwatch/autostart.py b/hogwatch/autostart.py new file mode 100644 index 0000000..a79a864 --- /dev/null +++ b/hogwatch/autostart.py @@ -0,0 +1,81 @@ +"""Start HogWatch automatically at login, via a Windows scheduled task. + +The task runs with highest privileges, so there's no admin prompt at login and +per-program tracking works. Creating or removing it needs admin itself, which +HogWatch has when it was started the normal way. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from .config import FROZEN, ROOT + +TASK_NAME = "HogWatch" + + +class AutostartError(Exception): + """The task couldn't be created or removed (usually: not running as administrator).""" + + +def _ps(script: str) -> subprocess.CompletedProcess: + """Run a PowerShell snippet without flashing a window.""" + return subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, timeout=60, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + + +def _q(value: str) -> str: + """Quote a value for a PowerShell single-quoted string.""" + return "'" + value.replace("'", "''") + "'" + + +def command() -> tuple[str, str, str]: + """(program, arguments, working folder) that starts HogWatch in the background.""" + if FROZEN: + exe = Path(sys.executable) + return str(exe), "run --no-browser", str(exe.parent) + pyw = Path(sys.executable).with_name("pythonw.exe") # no console window + return str(pyw if pyw.exists() else sys.executable), "-m hogwatch run --no-browser", str(ROOT) + + +def installed() -> bool: + """True if the login task exists.""" + r = _ps(f"if (Get-ScheduledTask -TaskName {_q(TASK_NAME)} -ErrorAction SilentlyContinue) {{ 'yes' }}") + return "yes" in r.stdout + + +def install_script() -> str: + """The PowerShell that registers the task (separate so it can be inspected and tested).""" + exe, args, cwd = command() + return ( + "$ErrorActionPreference = 'Stop'; " + "$user = \"$env:USERDOMAIN\\$env:USERNAME\"; " + f"$action = New-ScheduledTaskAction -Execute {_q(exe)} -Argument {_q(args)} -WorkingDirectory {_q(cwd)}; " + "$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user; " + "$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest; " + # No time limit, keep running on battery, restart if it ever crashes. + "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries " + "-ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) " + "-MultipleInstances IgnoreNew; " + f"Register-ScheduledTask -TaskName {_q(TASK_NAME)} -Action $action -Trigger $trigger -Principal $principal " + "-Settings $settings -Description 'HogWatch home network monitor' -Force | Out-Null" + ) + + +def install() -> None: + """Create (or replace) the login task.""" + r = _ps(install_script()) + if r.returncode != 0: + raise AutostartError("Windows wouldn't create the startup task. HogWatch needs to be running as " + "administrator for this: close it, start it again, and click Yes on the Windows prompt.") + + +def uninstall() -> None: + """Remove the login task (history is kept).""" + r = _ps(f"$ErrorActionPreference = 'Stop'; if (Get-ScheduledTask -TaskName {_q(TASK_NAME)} -ErrorAction " + f"SilentlyContinue) {{ Unregister-ScheduledTask -TaskName {_q(TASK_NAME)} -Confirm:$false }}") + if r.returncode != 0: + raise AutostartError("Windows wouldn't remove the startup task. HogWatch needs to be running as administrator.") diff --git a/hogwatch/collector.py b/hogwatch/collector.py index dc0b1ef..96cb590 100644 --- a/hogwatch/collector.py +++ b/hogwatch/collector.py @@ -20,7 +20,7 @@ import psutil from . import ping -from .config import DATA_DIR +from .config import DATA_DIR, DEFAULTS from .db import DB from .eero import Eero, EeroError, LoginNeeded, parse_device, parse_unit, speed_from_network, uplink_chain from .incidents import Detector, HiccupTracker, Incident, where_text @@ -83,7 +83,9 @@ def start(self) -> None: if not self.db.get_meta("link_watch_since"): # Reports may only claim "the PC's cable never dropped" for the time it was watched. self.db.set_meta("link_watch_since", str(int(time.time()))) - self.pc = PCMonitor() + # A copy on a non-default port gets its own trace session, so it can't cut off the main one. + port = int(self.cfg["port"]) + self.pc = PCMonitor(None if port == DEFAULTS["port"] else f"HogWatch-KernelNetwork-{port}") for fn in (self._ping_loop, self._pc_loop, self._eero_loop, self._housekeeping_loop): threading.Thread(target=self._guard(fn), name=fn.__name__.strip("_"), daemon=True).start() diff --git a/hogwatch/config.py b/hogwatch/config.py index 709516a..2dfafe7 100644 --- a/hogwatch/config.py +++ b/hogwatch/config.py @@ -1,17 +1,50 @@ -"""Settings, stored as plain JSON in data/config.json so they can be hand-edited. +"""Settings, stored as plain JSON in the data folder's config.json so they can be hand-edited. The file is created with defaults on first run. Unknown keys are ignored and missing keys fall back to the defaults, so old config files keep working. + +Where the data folder is: + - running from source: `data\\` next to the code + - the packaged HogWatch.exe: `%LOCALAPPDATA%\\HogWatch` (an exe has no folder of its + own to write to; when packed into one file its code lives in a temp folder) + - either can be overridden with `--data-dir` or the HOGWATCH_DATA_DIR variable """ from __future__ import annotations import json +import os +import sys from pathlib import Path + +def _cli_option(name: str) -> str | None: + """Read `--name value` straight from the command line. + + Needed before argparse runs: the data folder decides where config itself lives, + and modules capture DATA_DIR when they're imported. + """ + if name in sys.argv: + i = sys.argv.index(name) + if i + 1 < len(sys.argv): + return sys.argv[i + 1] + return None + + +FROZEN = bool(getattr(sys, "frozen", False)) # True inside the packaged HogWatch.exe ROOT = Path(__file__).resolve().parent.parent -DATA_DIR = ROOT / "data" + + +def _default_data_dir() -> Path: + if FROZEN: + return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "HogWatch" + return ROOT / "data" + + +DATA_DIR = Path(_cli_option("--data-dir") or os.environ.get("HOGWATCH_DATA_DIR") or _default_data_dir()) CONFIG_PATH = DATA_DIR / "config.json" +# Lets a second copy run beside the first (for testing) without editing its config. +PORT_OVERRIDE = _cli_option("--port") or os.environ.get("HOGWATCH_PORT") DEFAULTS: dict = { # Dashboard port. Only bound to 127.0.0.1, so nobody else on the network can see it. @@ -45,14 +78,15 @@ def load() -> dict: """Return the merged config (defaults + data/config.json), creating the file if missing.""" DATA_DIR.mkdir(parents=True, exist_ok=True) + merged = dict(DEFAULTS) if not CONFIG_PATH.exists(): CONFIG_PATH.write_text(json.dumps(DEFAULTS, indent=2) + "\n", encoding="utf-8") - return dict(DEFAULTS) - try: - user = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) - except (OSError, ValueError): - # A typo in the file shouldn't stop monitoring; run on defaults instead. - return dict(DEFAULTS) - merged = dict(DEFAULTS) - merged.update({k: v for k, v in user.items() if k in DEFAULTS}) + else: + try: + user = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + merged.update({k: v for k, v in user.items() if k in DEFAULTS}) + except (OSError, ValueError): + pass # a typo in the file shouldn't stop monitoring; run on defaults instead + if PORT_OVERRIDE: + merged["port"] = int(PORT_OVERRIDE) return merged diff --git a/hogwatch/eero.py b/hogwatch/eero.py index a4205b8..7d15e80 100644 --- a/hogwatch/eero.py +++ b/hogwatch/eero.py @@ -17,6 +17,7 @@ from __future__ import annotations +import base64 import gzip import json import logging @@ -25,6 +26,8 @@ import urllib.request from pathlib import Path +from . import secret + log = logging.getLogger(__name__) API = "https://api-user.e2ro.com" @@ -58,11 +61,15 @@ def __init__(self, session_path: Path): if session_path.exists(): try: s = json.loads(session_path.read_text(encoding="utf-8")) - self.token = s.get("token") self.network_url = s.get("network_url") self.network_name = s.get("network_name") + if s.get("token_dpapi"): + self.token = secret.unprotect(base64.b64decode(s["token_dpapi"])) + elif s.get("token"): + self.token = s["token"] # plain text from an older version: encrypt it now + self.save() except (OSError, ValueError): - pass + pass # unreadable, or encrypted by another Windows account: log in again @property def ready(self) -> bool: @@ -70,9 +77,12 @@ def ready(self) -> bool: return bool(self.token and self.network_url) def save(self) -> None: - """Persist the session. The token is a login credential -- the file stays local.""" + """Persist the session. The token is a login credential, so it's stored encrypted + with Windows DPAPI: only this Windows account on this PC can read it back.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + blob = base64.b64encode(secret.protect(self.token)).decode("ascii") if self.token else None self.path.write_text(json.dumps({ - "token": self.token, "network_url": self.network_url, "network_name": self.network_name, + "token_dpapi": blob, "network_url": self.network_url, "network_name": self.network_name, }, indent=2), encoding="utf-8") def _call(self, method: str, path: str, body: dict | None = None, retry: bool = True): diff --git a/hogwatch/etw.py b/hogwatch/etw.py index 625d4b5..9644aa0 100644 --- a/hogwatch/etw.py +++ b/hogwatch/etw.py @@ -210,8 +210,14 @@ class NetworkEventTrace: SESSION_NAME = "HogWatch-KernelNetwork" - def __init__(self): - """Prepare (but don't start) the trace session.""" + def __init__(self, session_name: str | None = None): + """Prepare (but don't start) the trace session. + + Starting a session takes over any existing one with the same name, so a second + HogWatch on another port needs its own name or it would cut off the first. + """ + if session_name: + self.SESSION_NAME = session_name self._acc: dict[tuple, int] = {} self._lock = threading.Lock() self._session = c_uint64(0) diff --git a/hogwatch/pcnet.py b/hogwatch/pcnet.py index 69b49cb..d47692a 100644 --- a/hogwatch/pcnet.py +++ b/hogwatch/pcnet.py @@ -164,8 +164,8 @@ def primary_interface() -> str | None: class PCMonitor: """Samples the network card and (optionally) the ETW per-program feed.""" - def __init__(self): - """Pick the network card and try to start per-program tracing.""" + def __init__(self, session_name: str | None = None): + """Pick the network card and try to start per-program tracing (`session_name`: see etw.py).""" self.nic = primary_interface() self._last_nic = self._nic_counters() self._last_t = time.time() @@ -176,7 +176,7 @@ def __init__(self): self.trace_error: str | None = None self._naming = threading.Event() try: - t = NetworkEventTrace() + t = NetworkEventTrace(session_name) t.start() self.trace = t threading.Thread(target=self._name_new_pids, name="namer", daemon=True).start() diff --git a/hogwatch/static/app.js b/hogwatch/static/app.js index d78b244..4270598 100644 --- a/hogwatch/static/app.js +++ b/hogwatch/static/app.js @@ -109,7 +109,7 @@ function renderNow() { if (state.offline || !n) { st.append(badge("muted", "–"), h("div", { class: "status-text" }, h("div", { class: "status-line" }, "HogWatch isn't running"), - h("p", { class: "status-meta" }, "Start it with start-hogwatch.cmd in the hogwatch folder, then reload this page."))); + h("p", { class: "status-meta" }, "Start HogWatch again (double-click it), then reload this page."))); return; } const lat = n.latency || {}; @@ -197,7 +197,7 @@ function renderNowDevices(n) { return; } if (e.status === "login_needed") { - box.append(h("p", { class: "empty" }, "The eero login expired. Double-click eero-login.cmd to sign in again.")); + box.append(h("p", { class: "empty" }, "The eero sign-in expired. Sign in again under “Connect the eero” below.")); return; } if (e.status === "error") box.append(h("p", { class: "empty" }, "Can't reach the eero cloud right now: " + (e.message || ""))); @@ -205,7 +205,7 @@ function renderNowDevices(n) { if (!devs.length) { box.append(h("p", { class: "empty" }, "No devices reported yet.")); return; } if (!e.usage_reported) { box.append(h("p", { class: "empty" }, `The eero lists ${devs.length} connected devices but isn't reporting their speeds. ` + - "Run eero-check.cmd for details.")); + "That usually clears up within a few minutes.")); return; } const active = devs.filter((d) => (d.down_mbps || 0) + (d.up_mbps || 0) >= 0.05).slice(0, 10); @@ -230,7 +230,7 @@ function renderNowApps(pc) { $("pc-sub").textContent = `Last 10 seconds · this PC in total: ↓ ${fmtRate(pc.down_mbps)} / ↑ ${fmtRate(pc.up_mbps)} Mbps`; if (!pc.per_app) { box.append(h("p", { class: "empty" }, - "Per-program detail needs administrator rights. Start HogWatch with start-hogwatch.cmd and click Yes on the Windows prompt.")); + "Per-program detail needs administrator rights. " + NEEDS_ADMIN)); return; } const apps = (pc.apps || []).filter((a) => a.down_mbps + a.up_mbps >= 0.01); @@ -239,29 +239,76 @@ function renderNowApps(pc) { box.append(barLegend(), rateBars(apps.map((a) => ({ name: a.app, down: a.down_mbps, up: a.up_mbps })), scale)); } +const NEEDS_ADMIN = "Stop HogWatch (the button at the bottom of this page), start it again, and click Yes on the Windows prompt."; + function renderSetup(n) { - const box = $("setup"); - box.replaceChildren(); + /* The eero sign-in form lives in the page (index.html) and is only shown or hidden here: + this runs every few seconds, and rebuilding the form would wipe what's being typed. */ const e = n.eero || {}; - if (e.status === "not_connected" || e.status === "login_needed") { - box.append(h("div", { class: "alert info" }, - h("h3", {}, "Connect the eero to see every device"), - h("p", {}, "HogWatch can already see this PC. To see your roommates' devices and the DirecTV boxes, it needs to read the eero:"), - h("ol", { class: "steps" }, - h("li", {}, "Ask the eero owner to add you as an admin: in the eero app, ", h("strong", {}, "Settings › Network settings › Admins › Add an admin"), "."), - h("li", {}, "Accept the invite in your own eero app."), - h("li", {}, "In the hogwatch folder, double-click ", h("code", {}, "eero-login.cmd"), - " and enter your eero email or phone number, then the code eero sends you."), - h("li", {}, "That's it. This page picks it up within 15 seconds, with no restart.")))); - } + $("eero-connect").hidden = !(e.status === "not_connected" || e.status === "login_needed"); + if (e.status === "login_needed") $("eero-connect-intro").textContent = "The eero sign-in has expired. Sign in again:"; + const hints = $("setup-hints"); + hints.replaceChildren(); if (n.pc && !n.pc.per_app) { - box.append(h("div", { class: "alert info" }, + hints.append(h("div", { class: "alert info" }, h("h3", {}, "See which program on this PC is using the internet"), - h("p", {}, "Windows only shares per-program network data with administrator tools. Close this HogWatch window, then start it with ", - h("code", {}, "start-hogwatch.cmd"), " and click Yes on the Windows prompt."))); + h("p", {}, "Windows only shares per-program network data with administrator tools. " + NEEDS_ADMIN))); } } +/* ---- eero sign-in: login -> code -> (pick a network). The token never comes back to the page. */ + +function eeroStep(step, message, kind) { + for (const s of ["login", "code", "network"]) $("eero-step-" + s).hidden = s !== step; + const el = $("eero-connect-status"); + el.textContent = message || ""; + el.className = kind || ""; +} + +async function eeroSubmit(form, path, body) { + const btn = form.querySelector("button[type=submit]"); + btn.disabled = true; + try { + const res = await postJSON(path, body); + if (!res.ok) { $("eero-connect-status").textContent = res.data.error || "That didn't work. Try again."; $("eero-connect-status").className = "error"; return null; } + return res.data; + } finally { + btn.disabled = false; + } +} + +function eeroConnected(name) { + eeroStep("login", `Connected to “${name}”. Devices will appear here within about 15 seconds.`, "ok"); + $("eero-step-login").hidden = true; +} + +$("eero-step-login").addEventListener("submit", async (ev) => { + ev.preventDefault(); + const form = ev.currentTarget; + if (await eeroSubmit(form, "/api/eero/login", { login: form.elements.login.value })) { + eeroStep("code", "eero has sent a code to your email or phone.", ""); + $("eero-step-code").elements.code.focus(); + } +}); +$("eero-step-code").addEventListener("submit", async (ev) => { + ev.preventDefault(); + const form = ev.currentTarget; + const data = await eeroSubmit(form, "/api/eero/verify", { code: form.elements.code.value }); + if (!data) return; + form.elements.code.value = ""; + if (data.connected) return eeroConnected(data.connected); + const select = $("eero-step-network").elements.url; + select.replaceChildren(...data.networks.map((n) => h("option", { value: n.url }, n.name))); + eeroStep("network", "Your account can manage more than one network.", ""); +}); +$("eero-step-network").addEventListener("submit", async (ev) => { + ev.preventDefault(); + const form = ev.currentTarget; + const data = await eeroSubmit(form, "/api/eero/network", { url: form.elements.url.value }); + if (data) eeroConnected(data.connected); +}); +$("eero-restart").addEventListener("click", () => eeroStep("login", "", "")); + /* ------------------------------------------------------------------ lag spikes */ function pathLine(path) { @@ -686,7 +733,7 @@ function renderTables() { const pcTot = u.pc_total || {}; if (!u.apps.length) { apps.append(h("p", { class: "empty" }, state.now && state.now.pc && !state.now.pc.per_app - ? "Per-program detail needs HogWatch to run as administrator (start-hogwatch.cmd)." + ? "Per-program detail needs HogWatch to run as administrator. " + NEEDS_ADMIN : "No program data for this period yet.")); } else { apps.append(h("div", { class: "table-wrap" }, h("table", {}, @@ -792,6 +839,43 @@ $("email-send").addEventListener("click", async () => { } }); +/* ------------------------------------------------------------------ settings */ + +function settingsStatus(text, kind) { + const el = $("settings-status"); + el.textContent = text || ""; + el.className = kind || ""; +} + +async function loadSettings() { + let st; + try { st = await getJSON("/api/settings"); } catch (e) { return; } + $("autostart").checked = !!st.autostart; + $("version").textContent = `Version ${st.version} · ` + + (st.admin ? "running as administrator" : "not running as administrator, so per-program detail is off"); +} + +$("autostart").addEventListener("change", async (ev) => { + const box = ev.currentTarget; + box.disabled = true; + const res = await postJSON("/api/autostart", { enabled: box.checked }); + box.disabled = false; + if (res.ok) { + box.checked = !!res.data.autostart; + settingsStatus(box.checked ? "HogWatch will start by itself every time you sign in to Windows." + : "HogWatch will no longer start by itself.", "ok"); + } else { + box.checked = !box.checked; // put it back: nothing changed + settingsStatus(res.data.error || "Couldn't change that.", "error"); + } +}); + +$("stop-btn").addEventListener("click", async () => { + const res = await postJSON("/api/shutdown"); + settingsStatus(res.ok ? "HogWatch has stopped. Double-click HogWatch to start it again." : "Couldn't stop HogWatch.", + res.ok ? "" : "error"); +}); + /* ------------------------------------------------------------------ wiring */ function setRange(hours) { @@ -819,5 +903,6 @@ window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer setRange(state.hours); refreshNow(); loadEmail(); +loadSettings(); setInterval(refreshNow, 3000); setInterval(refreshHistory, 30000); diff --git a/hogwatch/static/index.html b/hogwatch/static/index.html index b3985c3..8b92c76 100644 --- a/hogwatch/static/index.html +++ b/hogwatch/static/index.html @@ -35,7 +35,37 @@

Programs on this PC right now

-
+
+ +
+
@@ -149,6 +179,17 @@

Report for your installer

In the report, click "Notes from the homeowner" to add anything you know, then use "Print or save as PDF".

+
+

HogWatch settings

+ +
+ + +
+

+
+

Why one person can slow down everyone

When one device fills the connection (a big download, a 4K stream, or a heavy diff --git a/hogwatch/static/style.css b/hogwatch/static/style.css index c0aae3a..da40821 100644 --- a/hogwatch/static/style.css +++ b/hogwatch/static/style.css @@ -81,6 +81,8 @@ } * { box-sizing: border-box; } +/* `hidden` must win over layout rules like `display: grid` (it hides the sign-in steps). */ +[hidden] { display: none !important; } html { -webkit-text-size-adjust: 100%; } body { margin: 0; @@ -229,8 +231,13 @@ td .sub { color: var(--muted); font-size: 12px; display: block; overflow-wrap: a font: inherit; font-weight: 400; padding: 8px 10px; width: 100%; border: 1px solid var(--axis); border-radius: 8px; background: var(--surface); color: var(--ink); } -.email-form label.check { display: flex; gap: 8px; align-items: flex-start; font-weight: 400; } -.email-form label.check input { margin-top: 4px; } +label.check { display: flex; gap: 8px; align-items: flex-start; font-weight: 400; font-size: 14px; } +label.check input { margin-top: 4px; } +.email-form select { font: inherit; padding: 8px 10px; border: 1px solid var(--axis); border-radius: 8px; background: var(--surface); color: var(--ink); } +#eero-connect form { margin-top: 12px; } +#eero-connect-status, #settings-status { font-size: 14px; min-height: 1.5em; margin-top: 8px; } +#eero-connect-status.error, #settings-status.error { color: var(--critical-ink); } +#eero-connect-status.ok, #settings-status.ok { color: var(--good-ink); } .email-form .hint { font-size: 13px; color: var(--ink-2); } .email-advanced { display: grid; gap: 10px; margin-top: 10px; } .actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } diff --git a/hogwatch/web.py b/hogwatch/web.py index 616b970..729fdda 100644 --- a/hogwatch/web.py +++ b/hogwatch/web.py @@ -14,9 +14,13 @@ from pathlib import Path from urllib.parse import parse_qs, urlparse +from . import __version__, autostart +from .config import FROZEN from .db import HICCUP_END +from .eero import Eero, EeroError from .installer import build_installer_report from .report import ReportError, build_report +from .winutil import is_admin log = logging.getLogger(__name__) @@ -150,9 +154,14 @@ def incidents(db, days: float, limit: int) -> list[dict]: def make_server(port: int, collector_ref: dict, db_ref: dict, on_shutdown, - reporter_ref: dict | None = None) -> ThreadingHTTPServer: - """Create (bind) the server. Binding first doubles as the 'already running?' check.""" + reporter_ref: dict | None = None, eero_session: Path | None = None) -> ThreadingHTTPServer: + """Create (bind) the server. Binding first doubles as the 'already running?' check. + + `eero_session` is where the eero login is saved; with it, the dashboard can do the + eero sign-in itself (the packaged exe has no terminal to do it in). + """ reporter_ref = reporter_ref if reporter_ref is not None else {} + eero_login: dict = {} # the sign-in in progress: {"client": Eero, "networks": [...]} # Only answer requests addressed to this machine by name. A web page elsewhere can # point its own domain at 127.0.0.1 ("DNS rebinding") and then read this dashboard # as if it were its own site; checking the Host header stops that. It matters more @@ -189,6 +198,9 @@ def do_GET(self): # noqa: N802 (http.server naming) q = {k: v[0] for k, v in parse_qs(url.query).items()} col, db, rep = collector_ref.get("c"), db_ref.get("db"), reporter_ref.get("r") try: + if url.path == "/api/settings": + return self._json({"version": __version__, "packaged": FROZEN, "admin": is_admin(), + "autostart": autostart.installed()}) if url.path == "/api/email": # Settings never include the password itself, only whether one is saved. return self._json({"settings": rep.settings.load(), "status": rep.status()} if rep @@ -231,6 +243,16 @@ def do_POST(self): # noqa: N802 self._json({"ok": True}) threading.Thread(target=on_shutdown, daemon=True).start() return + try: + if path == "/api/autostart": + (autostart.install if self._body().get("enabled") else autostart.uninstall)() + return self._json({"ok": True, "autostart": autostart.installed()}) + if path.startswith("/api/eero/") and eero_session is not None: + return self._json(self._eero_sign_in(path, self._body())) + except (autostart.AutostartError, EeroError) as e: + return self._json({"ok": False, "error": str(e)}, 400) + except ValueError as e: + return self._json({"ok": False, "error": f"Bad request: {e}"}, 400) rep = reporter_ref.get("r") if rep is None: return self._json({"ok": False, "error": "HogWatch is still starting"}, 503) @@ -249,6 +271,52 @@ def do_POST(self): # noqa: N802 return self._json({"ok": False, "error": f"Bad request: {e}"}, 400) self._send(404, b"not found", "text/plain") + def _eero_sign_in(self, path: str, body: dict) -> dict: + """The eero sign-in, step by step: login -> emailed/texted code -> (pick a network). + + Nothing is saved until a network is chosen, so a half-finished sign-in never + replaces a working one. The collector notices the saved file and starts using it. + """ + if path == "/api/eero/login": + login = str(body.get("login") or "").strip() + if not login: + raise EeroError("Enter the email or phone number of your eero account.") + client = Eero(eero_session) + try: + client.start_login(login) + except EeroError as e: + raise EeroError("eero didn't recognise that login. Use your eero account's email, or its phone " + f"number with the country code (like +15551234567). ({e})") from e + eero_login.clear() + eero_login["client"] = client + return {"ok": True} + client = eero_login.get("client") + if client is None: + raise EeroError("Start again: enter your eero login first.") + if path == "/api/eero/verify": + try: + client.verify(str(body.get("code") or "")) + nets = client.networks() + except EeroError as e: + raise EeroError(f"That code didn't work. Check it, or start again to get a new one. ({e})") from e + if not nets: + raise EeroError("You're signed in, but this eero account isn't an admin on any network yet. " + "Ask the owner to add you as an admin in the eero app, then sign in again.") + eero_login["networks"] = nets + if len(nets) > 1: + return {"ok": True, "networks": [{"name": n["name"], "url": n["url"]} for n in nets]} + chosen = nets[0] + elif path == "/api/eero/network": + chosen = next((n for n in eero_login.get("networks", []) if n["url"] == body.get("url")), None) + if chosen is None: + raise EeroError("Pick one of the listed networks.") + else: + raise EeroError("Unknown sign-in step.") + client.network_url, client.network_name = chosen["url"], chosen["name"] + client.save() + eero_login.clear() + return {"ok": True, "connected": chosen["name"]} + def log_message(self, fmt, *args): """Silence per-request logging; the dashboard polls every few seconds.""" diff --git a/hogwatch/winutil.py b/hogwatch/winutil.py new file mode 100644 index 0000000..58abc21 --- /dev/null +++ b/hogwatch/winutil.py @@ -0,0 +1,43 @@ +"""Small Windows helpers shared by the launcher and the dashboard server.""" + +from __future__ import annotations + +import ctypes +import subprocess +import urllib.request +import webbrowser + + +def is_admin() -> bool: + """True when running elevated (needed for the per-program breakdown).""" + try: + return bool(ctypes.windll.shell32.IsUserAnAdmin()) + except OSError: + return False + + +def open_dashboard(url: str) -> None: + """Open the dashboard in the default browser, never as administrator. + + HogWatch itself runs elevated, and anything it launches directly inherits that. + Handing the URL to explorer.exe opens it at the user's normal level instead. + """ + if is_admin(): + subprocess.Popen(["explorer.exe", url]) + else: + webbrowser.open(url) + + +def server_up(port: int, timeout: float = 1.0) -> bool: + """True if a HogWatch dashboard is already answering on this port.""" + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/now", timeout=timeout) as r: + return r.status == 200 + except OSError: + return False + + +def run_elevated(exe: str, params: str, cwd: str) -> bool: + """Start a program as administrator (shows the Windows prompt). False if the user said no.""" + # ShellExecute returns a value > 32 on success; SW_HIDE (0) keeps a console from flashing. + return ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, params, cwd, 0) > 32 diff --git a/hogwatch_app.py b/hogwatch_app.py new file mode 100644 index 0000000..335b906 --- /dev/null +++ b/hogwatch_app.py @@ -0,0 +1,13 @@ +"""Entry point for the packaged HogWatch.exe (PyInstaller needs a script outside the package). + +Double-clicking the exe runs `launch`: it starts HogWatch in the background as +administrator and opens the dashboard. All the usual commands still work, e.g. +`HogWatch.exe stop` or `HogWatch.exe installer-report`. +""" + +import sys + +from hogwatch.__main__ import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..dd72fe5 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pyinstaller>=6.10 diff --git a/scripts/make_icon.py b/scripts/make_icon.py new file mode 100644 index 0000000..0370dcb --- /dev/null +++ b/scripts/make_icon.py @@ -0,0 +1,69 @@ +"""Draw HogWatch's icon (the dashboard's blue chart mark) and save it as assets/hogwatch.ico. + +Standard library only, so the build needs no image package. Run it again only if +the design changes; the .ico is committed. +""" + +import struct +import zlib +from pathlib import Path + +SIZE = 256 +BLUE = (42, 120, 214) +WHITE = (255, 255, 255) +RADIUS = 56 # rounded corners of the square +STROKE = 12 # half-width of the chart line +# The dashboard favicon's path, scaled from its 32-unit grid to 256 pixels. +POINTS = [(48, 176), (96, 112), (136, 152), (208, 64)] + + +def _coverage(distance: float, edge: float) -> float: + """1 inside, 0 outside, a smooth ramp across one pixel at the edge (anti-aliasing).""" + return max(0.0, min(1.0, edge - distance + 0.5)) + + +def _segment_distance(px, py, ax, ay, bx, by) -> float: + dx, dy = bx - ax, by - ay + t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy))) + return ((px - ax - t * dx) ** 2 + (py - ay - t * dy) ** 2) ** 0.5 + + +def render() -> bytes: + """RGBA pixels, row by row.""" + rows = bytearray() + half = SIZE / 2 + for y in range(SIZE): + rows.append(0) # PNG filter type for this row: none + for x in range(SIZE): + cx, cy = x + 0.5, y + 0.5 + # Distance outside a rounded square. + qx, qy = abs(cx - half) - (half - RADIUS), abs(cy - half) - (half - RADIUS) + outside = (max(qx, 0) ** 2 + max(qy, 0) ** 2) ** 0.5 + min(max(qx, qy), 0) + alpha = _coverage(outside, RADIUS) + line = min(_segment_distance(cx, cy, *a, *b) for a, b in zip(POINTS, POINTS[1:])) + w = _coverage(line, STROKE) + r, g, b = (round(BLUE[i] + (WHITE[i] - BLUE[i]) * w) for i in range(3)) + rows += bytes((r, g, b, round(255 * alpha))) + return bytes(rows) + + +def png(pixels: bytes) -> bytes: + def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + header = struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0) # 8-bit RGBA + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(pixels, 9)) + chunk(b"IEND", b"") + + +def ico(png_bytes: bytes) -> bytes: + """A one-image .ico holding the 256x256 PNG (Windows scales it for smaller sizes).""" + directory = struct.pack(" Date: Sun, 4 Oct 2026 13:59:18 -0400 Subject: [PATCH 2/3] ci: build and run the exe on every PR; publish it on version tags The package job builds HogWatch.exe and runs it (dashboard, API, data folder). Pushing a tag vX.Y.Z that matches hogwatch.__version__ runs the tests, builds and runs the exe on a clean runner, and publishes it with a SHA-256 checksum. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 71 +++++++++++++++++++++++++++++++++++ .github/workflows/tests.yml | 27 +++++++++++++ scripts/smoke_test_exe.ps1 | 35 +++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/smoke_test_exe.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..26955c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +# Pushing a version tag (v1.2.0) builds HogWatch.exe on a clean GitHub machine, tests +# it, and publishes it as a release download. The exe people download is therefore +# built from the public source at that tag, not on anyone's own PC. +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write # to create the release + +jobs: + release: + runs-on: windows-latest + timeout-minutes: 20 + env: + TAG: ${{ github.ref_name }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: requirements-build.txt + + - name: Install dependencies + run: python -m pip install -r requirements-build.txt + + - name: The tag must match the app's version + shell: pwsh + run: | + $version = python -c "import hogwatch; print(hogwatch.__version__)" + if ("v$version" -ne $env:TAG) { throw "Tag $env:TAG does not match hogwatch.__version__ ($version)" } + + - name: Tests + run: python -m unittest discover -s tests + + - name: Build HogWatch.exe + run: cmd /c build-exe.cmd + + - name: Run the exe + shell: pwsh + run: ./scripts/smoke_test_exe.ps1 + + - name: Checksum + shell: pwsh + run: | + $hash = (Get-FileHash dist/HogWatch.exe -Algorithm SHA256).Hash.ToLower() + "$hash HogWatch.exe" | Out-File dist/HogWatch.exe.sha256 -Encoding ascii + "SHA256=$hash" >> $env:GITHUB_ENV + + - name: Publish the release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $notes = @" + ## Install + + 1. Download **HogWatch.exe** below and put it somewhere it can stay (for example a HogWatch folder in Documents). + 2. Double-click it. Windows will warn that it's from an unknown publisher, because the exe isn't code-signed: click **More info**, then **Run anyway**. + 3. Click **Yes** on the admin prompt (needed to see which program on the PC is using the internet). The dashboard opens in your browser. + 4. On the dashboard, sign in under **Connect the eero**, and tick **Start HogWatch automatically** under HogWatch settings. + + Your history and settings are stored on your own PC in ``%LOCALAPPDATA%\HogWatch``. Nothing is sent anywhere except the eero sign-in (to eero) and the daily email, if you set one up. + + SHA-256 of HogWatch.exe: ``$env:SHA256`` + "@ + gh release create $env:TAG dist/HogWatch.exe dist/HogWatch.exe.sha256 --title "HogWatch $env:TAG" --notes $notes --generate-notes diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b034b5e..89e4e44 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,3 +30,30 @@ jobs: - name: Dashboard script syntax run: node --check hogwatch/static/app.js + + # Builds HogWatch.exe and runs it, so a packaging problem shows up in the pull + # request rather than on release day. + package: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: requirements-build.txt + + - name: Build HogWatch.exe + run: cmd /c build-exe.cmd + + - name: Run the exe + shell: pwsh + run: ./scripts/smoke_test_exe.ps1 + + - uses: actions/upload-artifact@v4 + with: + name: HogWatch-exe + path: dist/HogWatch.exe + retention-days: 7 diff --git a/scripts/smoke_test_exe.ps1 b/scripts/smoke_test_exe.ps1 new file mode 100644 index 0000000..95dd38b --- /dev/null +++ b/scripts/smoke_test_exe.ps1 @@ -0,0 +1,35 @@ +# Starts dist\HogWatch.exe on a spare port with a throwaway data folder, checks that the +# dashboard and its API answer, and shuts it down. Used by the GitHub workflows so a +# release is never published without having actually run. + +$ErrorActionPreference = 'Stop' +$exe = Join-Path $PSScriptRoot '..\dist\HogWatch.exe' +$data = Join-Path ([IO.Path]::GetTempPath()) ("hogwatch-smoke-" + [Guid]::NewGuid().ToString('N')) +$port = 8799 +$base = "http://127.0.0.1:$port" + +$proc = Start-Process -FilePath $exe -ArgumentList '--data-dir', "`"$data`"", '--port', $port, 'run', '--no-browser' -PassThru +try { + $settings = $null + foreach ($i in 1..60) { + Start-Sleep -Seconds 1 + try { $settings = Invoke-RestMethod "$base/api/settings" -TimeoutSec 10; break } catch { } + } + if (-not $settings) { throw 'HogWatch.exe did not start answering within 60 seconds' } + if (-not $settings.packaged) { throw 'The exe does not report itself as packaged' } + + $page = Invoke-WebRequest "$base/" -UseBasicParsing + if ($page.Content -notmatch 'eero-step-login') { throw 'The dashboard page is missing from the exe' } + foreach ($file in 'app.js', 'style.css') { + if ((Invoke-WebRequest "$base/$file" -UseBasicParsing).StatusCode -ne 200) { throw "$file is missing from the exe" } + } + $now = Invoke-RestMethod "$base/api/now" -TimeoutSec 10 + if (-not $now.status) { throw 'The monitor is not reporting a status' } + if (-not (Test-Path (Join-Path $data 'hogwatch.db'))) { throw 'The exe did not use the data folder it was given' } + + Write-Host "HogWatch.exe $($settings.version) started, served the dashboard, status '$($now.status)', admin=$($settings.admin)" +} +finally { + try { Invoke-RestMethod -Method Post "$base/api/shutdown" -Headers @{ 'X-HogWatch' = '1' } -TimeoutSec 10 | Out-Null } catch { } + if (-not $proc.WaitForExit(20000)) { $proc.Kill() } +} From 8f5b10a21f6284fe82ecc374bfa919d778468abd Mon Sep 17 00:00:00 2001 From: Michael Saba Date: Sun, 4 Oct 2026 13:59:18 -0400 Subject: [PATCH 3/3] docs: download and install instructions for HogWatch.exe Co-Authored-By: Claude Opus 5.5 --- README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2935066..e7055d5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,31 @@ they're probably doing**, and shows it on a dashboard at Screenshot uses demo data; device names and rooms are made up. +## Download (Windows) + +**[Download HogWatch.exe](https://github.com/MSabacreationslab/hogwatch/releases/latest/download/HogWatch.exe)** +from the [latest release](https://github.com/MSabacreationslab/hogwatch/releases/latest). It needs nothing else installed. + +1. Put the exe somewhere it can stay, such as a `HogWatch` folder in Documents. +2. Double-click it. Windows warns that it's from an unknown publisher, because the exe isn't + code-signed: click **More info**, then **Run anyway**. +3. Click **Yes** on the admin prompt. HogWatch needs it to see which program on the PC is using + the internet. The dashboard then opens in your browser. +4. On the dashboard, sign in under **Connect the eero** (your eero account's email or phone, then + the code eero sends you), and tick **Start HogWatch automatically** under *HogWatch settings*. + +Good to know: + +- Each release's exe is built by GitHub from the public source at that version, and the release + page lists its SHA-256 checksum. +- History and settings stay on your PC in `%LOCALAPPDATA%\HogWatch`. Logins (eero, email) are + stored encrypted so only your Windows account can read them. +- Double-clicking the exe again just opens the dashboard. **Stop HogWatch** is at the bottom of it. +- To remove it: untick *Start automatically*, click *Stop HogWatch*, then delete the exe and + the `%LOCALAPPDATA%\HogWatch` folder. + +The rest of this page covers running from source and how it works. + It watches three things at once: | What | How | Needs | @@ -29,7 +54,9 @@ a **slowdown**. It also writes a plain-English explanation, for example: It only names someone when they were actually using a big share of the line. If nobody was, it says so and points at AT&T or the eero instead. -## Everyday use +## Everyday use (running from source) + +Run `setup.cmd` once after cloning. `build-exe.cmd` builds `dist\HogWatch.exe` yourself. | Double-click | To | |---|---|