From 220e6e701806213a576ce80fa655cd9cf4a51880 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 4 Sep 2026 03:58:16 +0200 Subject: feat: Windows daemon lifecycle (W3) — Startup-folder autostart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux node runs under `systemctl --user`. Windows has no per-user equivalent that works without elevation: `schtasks /create /sc ONLOGON` (even `/rl LIMITED /it`) fails with "Access is denied" for a non-admin user, because a logon trigger touches machine-wide scheduler state. So autostart is a `.vbs` in the per-user Startup folder instead: CreateObject("WScript.Shell").Run Chr(34) & "" & Chr(34), 0, False wscript runs it at every sign-in, hidden (0) and non-blocking. No admin, no console window, no new dependency. Verified end to end: the launcher brings the daemon up with no window and it answers its loopback API. node/platform.py autostart_install/remove/status — write / delete / detect the launcher autostart_run/end — start now (DETACHED|NO_WINDOW) / taskkill _node_exe — PATH, then next to sys.executable, then argv[0] node/daemon.py new `autostart install|remove|start|stop|status` verb reload (win32) -> POST /api/reload on the loopback API restart-daemon (win32) -> autostart_end + autostart_run reset (win32) -> also removes the launcher client/main.js, preload.js node:autostart handler + winAutostart* helpers (kept in step with platform.py) node:service-status (win32) probes the daemon; stop/restart/start use taskkill + a detached, windowless spawn Tests: 8 autostart cases in test_platform.py (mocked sys.platform, APPDATA pointed at tmp); `autostart status` added to the CLI dispatch sweep. Full meshbay-node suite green on Windows (784 passed / 34 skipped). Still open: no CTRL_CLOSE_EVENT handler, so a bare taskkill / window close does not run _shutdown() (SetConsoleCtrlHandler, follow-up). Service mode (pywin32/NSSM) stays Phase 2. Co-Authored-By: Claude Sonnet 5 --- packages/meshbay-node/src/meshbay_node/daemon.py | 79 ++++++++++++++++++++---- 1 file changed, 67 insertions(+), 12 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 5aa6654..b5a249d 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -1643,7 +1643,8 @@ def main() -> None: choices=["init", "reset", "status", "gek-init", "gek", "operator", "member", "group", "file", "video", "denylist", "stun", "reload", - "restart-daemon", "calibrate-argon2"], + "restart-daemon", "autostart", + "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " "| operator pair: pair a " @@ -1652,15 +1653,18 @@ def main() -> None: "| video rematch: re-resolve TMDB matches for a group's " "videos | denylist show|clear " "| stun list|add|remove|reset " - "| reload: re-read node.toml " - "(systemctl --user reload) | restart-daemon: restart " - "the systemd unit (systemctl --user restart) " + "| reload: re-read node.toml (hot; systemd or the " + "loopback API) | restart-daemon: restart the node " + "(systemd unit, or the Windows autostart launcher) " + "| autostart install|remove|start|stop|status " + "(Windows: run meshbay-node at each sign-in) " "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", help="'pair' for operator; list|invite|revoke|unpin for " "member; list|add|remove for group; init|rotate for gek; " "list|rm for file; rematch for video; show|clear for " - "denylist; list|add|remove|reset for stun") + "denylist; list|add|remove|reset for stun; " + "install|remove|start|stop|status for autostart") parser.add_argument("target", nargs="?", help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " @@ -1771,7 +1775,8 @@ def main() -> None: print("Next steps:") print(f" 1. Link this node key on {hub_url} → Settings → Link Node") if sys.platform == "win32": - print(" 2. meshbay-node (start the daemon)") + print(" 2. meshbay-node autostart install (run at each sign-in)") + print(" — or just: meshbay-node (start it now, this session)") else: print(" 2. systemctl --user enable --now meshbay-node") print(" 3. meshbay-node group add --dir /path/to/files") @@ -1828,7 +1833,10 @@ def main() -> None: except Exception: print("Could not unlink from hub (daemon not reachable).") - if sys.platform != "win32": + if sys.platform == "win32": + from meshbay_node.platform import autostart_remove + autostart_remove() + else: _sp.run(["systemctl", "--user", "disable", "--now", "meshbay-node"], capture_output=True) @@ -2072,8 +2080,12 @@ def main() -> None: if args.command == "reload": if sys.platform == "win32": - print("reload is not supported on Windows — restart the daemon instead.") - sys.exit(1) + # No systemd, no SIGHUP: the daemon exposes a hot reload on its + # own loopback API (the same one ops.reload_config drives). + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + _daemon_api(cfg, "/api/reload", method="POST") + print("sent reload to the running node") + return _systemctl_user( "reload", "meshbay-node", not_running_hint="Node is not running as a systemd unit — start it " @@ -2084,9 +2096,16 @@ def main() -> None: if args.command == "restart-daemon": if sys.platform == "win32": - print("restart-daemon is not supported on Windows — stop and start " - "the daemon manually.") - sys.exit(1) + from meshbay_node.platform import autostart_end, autostart_run + autostart_end() # kill whatever is running now + try: + autostart_run() + except RuntimeError as e: + print(f"Could not restart: {e}. Stop the daemon (Ctrl+C) and " + "relaunch it from where meshbay-node is on PATH.") + sys.exit(1) + print("restarted the node") + return _systemctl_user( "restart", "meshbay-node", not_running_hint="meshbay-node is not installed as a systemd unit — " @@ -2096,6 +2115,42 @@ def main() -> None: "watch logs: journalctl --user -u meshbay-node -f") return + if args.command == "autostart": + from meshbay_node import platform as _plat + if sys.platform != "win32": + print("autostart is Windows-only — elsewhere use " + "'systemctl --user enable --now meshbay-node'.") + sys.exit(1) + sub = args.subcommand or "status" + if sub == "install": + _plat.autostart_install() + print("Installed the Startup launcher — meshbay-node starts at " + "each sign-in (no window, no admin).") + print("Start it now with: meshbay-node autostart start") + elif sub == "remove": + _plat.autostart_remove() + print("Removed the Startup launcher.") + elif sub == "start": + try: + _plat.autostart_run() + except RuntimeError as e: + print(f"Could not start: {e}") + sys.exit(1) + print("started") + elif sub == "stop": + _plat.autostart_end() + print("stopped") + elif sub == "status": + st = _plat.autostart_status() + if st["installed"]: + print("autostart installed — runs meshbay-node at sign-in") + else: + print("autostart not installed — meshbay-node autostart install") + else: + print("autostart: install | remove | start | stop | status") + sys.exit(1) + return + if args.command == "denylist": cfg = load_config(args.config or DEFAULT_CONFIG_PATH) sub = args.subcommand or "show" -- cgit v1.2.3