diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-04 03:58:16 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-04 03:58:16 +0200 |
| commit | 220e6e701806213a576ce80fa655cd9cf4a51880 (patch) | |
| tree | 04b1f059044f4e02d1e8cb6c4a6588982cf395c4 /packages/meshbay-node/src | |
| parent | 5098e6cb54173b27673f5761ce187d799ba36b30 (diff) | |
| download | meshbay-220e6e701806213a576ce80fa655cd9cf4a51880.tar.gz | |
feat: Windows daemon lifecycle (W3) — Startup-folder autostart
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) & "<exe>" & 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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 79 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/platform.py | 88 |
2 files changed, 155 insertions, 12 deletions
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 <name> --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" diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py index 351289f..b59418f 100644 --- a/packages/meshbay-node/src/meshbay_node/platform.py +++ b/packages/meshbay-node/src/meshbay_node/platform.py @@ -3,6 +3,7 @@ import asyncio import os import shutil +import subprocess import sys from pathlib import Path @@ -109,3 +110,90 @@ def ffmpeg_cmd() -> str: def ffprobe_cmd() -> str: return _ffprobe_path + + +# ── Autostart (Windows) ────────────────────────────────────────────────────── +# +# The Windows stand-in for the Linux `systemctl --user` unit. Task Scheduler +# would be nicer (retry semantics), but a logon-triggered task needs elevation +# to create — and this must work for an ordinary user with no admin rights. +# So: a `.vbs` launcher in the per-user Startup folder. wscript runs it hidden +# (Run(..., 0, ...)) at every sign-in; no console window, no admin, no +# third-party dependency. + +TASK_NAME = "MeshBay Node" # the name the Electron client shows + + +def autostart_supported() -> bool: + return sys.platform == "win32" + + +def _startup_vbs() -> Path: + base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") + return (Path(base) / "Microsoft" / "Windows" / "Start Menu" / "Programs" + / "Startup" / "MeshBay Node.vbs") + + +def _node_exe() -> str | None: + """Best guess at the meshbay-node launcher: PATH first, then next to the + interpreter (a venv's Scripts/ dir, or a bundled runtime), then argv[0].""" + found = shutil.which("meshbay-node") + if found: + return found + for cand in (Path(sys.executable).parent / "meshbay-node.exe", + Path(sys.argv[0])): + if cand.name.lower().startswith("meshbay-node") and cand.exists(): + return str(cand.resolve()) + return None + + +def autostart_status() -> dict: + """{'installed': bool, 'state': str}. 'state' is left empty — there is no + Task Scheduler to ask 'is it running'; the Node page probes the daemon.""" + if not autostart_supported(): + return {"installed": False, "state": ""} + return {"installed": _startup_vbs().exists(), "state": ""} + + +def autostart_install(exe: str | None = None) -> None: + """Write the Startup-folder launcher. Raises RuntimeError on failure.""" + if not autostart_supported(): + raise RuntimeError("autostart is Windows-only") + exe = exe or _node_exe() + if not exe: + raise RuntimeError( + "cannot locate the meshbay-node launcher — pass its path, or run " + "this from where meshbay-node is on PATH") + vbs = _startup_vbs() + vbs.parent.mkdir(parents=True, exist_ok=True) + # Chr(34) is a literal " — wraps the path so a space in it doesn't split the + # command. 0 = hidden window, False = don't wait. (A Windows path cannot + # itself contain ", so no further escaping is needed.) + vbs.write_text( + f'CreateObject("WScript.Shell").Run Chr(34) & "{exe}" & Chr(34), 0, False\n', + encoding="utf-8", newline="\r\n") + + +def autostart_remove() -> None: + """Delete the Startup-folder launcher if present.""" + if autostart_supported(): + _startup_vbs().unlink(missing_ok=True) + + +def autostart_run() -> None: + """Start the daemon now, detached and windowless. Raises RuntimeError if + the launcher cannot be located.""" + if not autostart_supported(): + raise RuntimeError("autostart is Windows-only") + exe = _node_exe() + if not exe: + raise RuntimeError("cannot locate the meshbay-node launcher") + subprocess.Popen([exe], creationflags=0x00000008 | 0x08000000, # DETACHED | NO_WINDOW + close_fds=True) + + +def autostart_end() -> None: + """Stop any running daemon (hard: there is no CTRL_CLOSE handler yet).""" + if autostart_supported(): + subprocess.run(["taskkill", "/IM", "meshbay-node.exe", "/F"], + capture_output=True) |