diff options
| -rw-r--r-- | packages/meshbay-client/src/main.js | 106 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 10 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 79 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/platform.py | 88 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_cli_dispatch.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_platform.py | 75 |
6 files changed, 348 insertions, 17 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 9c27cfe..d341619 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -804,7 +804,59 @@ function registerBridge() { }); } + // ── Windows: the Startup-folder launcher that stands in for the systemd unit ── + // A logon-triggered Task Scheduler task needs elevation to create, which an + // ordinary user does not have, so autostart is a `.vbs` in the per-user + // Startup folder instead: wscript runs it hidden at every sign-in — no admin, + // no console window. Kept in step with meshbay_node.platform._startup_vbs(). + const WIN_STARTUP_VBS = path.join( + app.getPath('appData'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', + 'Startup', 'MeshBay Node.vbs'); + + function winAutostartInstalled() { + try { return fs.existsSync(WIN_STARTUP_VBS); } catch { return false; } + } + + function winAutostartInstall(bin) { + fs.mkdirSync(path.dirname(WIN_STARTUP_VBS), { recursive: 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. Kept in step with + // meshbay_node.platform.autostart_install(). + fs.writeFileSync(WIN_STARTUP_VBS, + `CreateObject("WScript.Shell").Run Chr(34) & "${bin}" & Chr(34), 0, False\r\n`); + } + + function winAutostartRemove() { + try { fs.rmSync(WIN_STARTUP_VBS, { force: true }); } catch { /* not there */ } + } + + function killNodeProcesses() { + return new Promise((resolve) => { + execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve()); + }); + } + + async function spawnNodeDetached() { + const bin = await findNodeBinary(); + if (!bin) throw new Error('meshbay-node not found on PATH'); + const child = spawn(bin, [], { detached: true, stdio: 'ignore', windowsHide: true }); + child.unref(); + } + + async function waitForNode(deadline) { + while (Date.now() < deadline) { + const p = await probeNode(); + if (p) return p; + await new Promise((r) => setTimeout(r, 800)); + } + return null; + } + ipcMain.handle('node:installed', async () => { + if (process.platform === 'win32') { + const bin = await findNodeBinary(); + return { installed: Boolean(bin), autostart: winAutostartInstalled() }; + } if (process.platform !== 'linux') return { installed: false }; const unit = await new Promise((resolve) => { execFile('systemctl', ['--user', 'show', 'meshbay-node.service', @@ -823,6 +875,16 @@ function registerBridge() { // own HTTP API, which cannot answer while the daemon is stopped or crash- // looping — exactly the states this panel exists to show and act on. ipcMain.handle('node:service-status', async () => { + if (process.platform === 'win32') { + // No Task Scheduler to ask "is it running" — probe the daemon itself. + const p = await probeNode(); + return { + supported: true, + installed: winAutostartInstalled(), + activeState: p ? 'active' : 'inactive', + subState: p ? 'running' : '', + }; + } if (process.platform !== 'linux') return { supported: false }; return new Promise((resolve) => { execFile('systemctl', ['--user', 'show', 'meshbay-node.service', @@ -848,6 +910,10 @@ function registerBridge() { }); ipcMain.handle('node:service-stop', async () => { + if (process.platform === 'win32') { + await killNodeProcesses(); // hard kill — no CTRL_CLOSE handler yet + return { stopped: true }; + } if (process.platform !== 'linux') { throw new Error('Service control is only supported on Linux'); } @@ -862,6 +928,13 @@ function registerBridge() { }); ipcMain.handle('node:service-restart', async () => { + if (process.platform === 'win32') { + await killNodeProcesses(); + await spawnNodeDetached(); + const p = await waitForNode(Date.now() + 30000); + if (!p) throw new Error('node did not come back up within 30s'); + return { restarted: true, ...p }; + } if (process.platform !== 'linux') { throw new Error('Service control is only supported on Linux'); } @@ -875,6 +948,22 @@ function registerBridge() { return { restarted: true }; }); + // Install / remove the Windows Startup-folder launcher, and query it. + ipcMain.handle('node:autostart', async (_e, action) => { + if (process.platform !== 'win32') return { supported: false }; + if (action === 'install') { + const bin = await findNodeBinary(); + if (!bin) throw new Error('meshbay-node not found on PATH'); + winAutostartInstall(bin); + return { supported: true, installed: true }; + } + if (action === 'remove') { + winAutostartRemove(); + return { supported: true, installed: false }; + } + return { supported: true, installed: winAutostartInstalled() }; + }); + async function probeNode() { const nc = readNodeConfig(); const dataDir = nc ? nc.dataDir : meshbayDataDir(); @@ -895,6 +984,11 @@ function registerBridge() { } catch { return null; } } + // A path inside a TOML basic string: forward slashes only. A raw Windows + // path there (`C:\Users\...`) is a parse error — `\U`, `\a`, ... are escape + // sequences. pathlib on the node reads the `/` form fine. + const tomlPath = (p) => p.split(path.sep).join('/'); + function provisionNode(hubUrl, username) { const configDir = meshbayConfigDir(); const dataDir = meshbayDataDir(); @@ -920,7 +1014,7 @@ function registerBridge() { 'ui_port = 18000', '', '[keystore]', - `unlock_file = "${path.join(configDir, 'unlock.key')}"`, + `unlock_file = "${tomlPath(path.join(configDir, 'unlock.key'))}"`, '', ].join('\n'); fs.writeFileSync(configFile, toml, { mode: 0o600 }); @@ -939,6 +1033,16 @@ function registerBridge() { return { started: true, ...already }; } + if (process.platform === 'win32') { + if (opts && opts.hubUrl && opts.username) provisionNode(opts.hubUrl, opts.username); + await killNodeProcesses(); // clear a crash-looping one + await spawnNodeDetached(); + const p = await waitForNode(Date.now() + 60000); + if (!p) throw new Error('the node did not start within 60s — run it from a ' + + 'terminal (`meshbay-node`) to see why'); + return { started: true, ...p }; + } + if (process.platform !== 'linux') { throw new Error('Automatic node start is only supported on Linux'); } diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 96d6790..38c1a57 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -97,13 +97,19 @@ contextBridge.exposeInMainWorld('meshbay', { call: (method, path, body) => ipcRenderer.invoke('node:call', method, path, body), pairingCode: () => ipcRenderer.invoke('node:pairing-code'), setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), - // The systemd unit's own state — reachable even while the daemon itself - // is stopped or crash-looping, which `call()` above is not. + // The daemon's lifecycle as seen from outside it: the systemd unit (Linux) + // or, on Windows, a probe of the daemon plus whether the Startup launcher + // is in place — reachable even while the daemon itself is stopped or + // crash-looping, which `call()` above is not. service: { status: () => ipcRenderer.invoke('node:service-status'), stop: () => ipcRenderer.invoke('node:service-stop'), restart: () => ipcRenderer.invoke('node:service-restart'), }, + // Windows only: the "run at every sign-in" Startup-folder launcher. + // action: 'install' | 'remove' | 'status' (default). Elsewhere returns + // { supported: false }. + autostart: (action) => ipcRenderer.invoke('node:autostart', action), }, // LAN cast relay. The main process runs a local HTTP server and the 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) diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index 76eb6d5..606e586 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -42,6 +42,7 @@ VERBS = [ ["stun", "list"], ["reload"], ["restart-daemon"], + ["autostart", "status"], ] @@ -141,7 +142,8 @@ def test_the_verb_list_here_matches_the_parser(): @pytest.mark.skipif(sys.platform == "win32", - reason="systemd lifecycle; Windows path (schtasks) is W3, not built") + reason="systemd lifecycle; Windows uses the loopback API / " + "Startup-folder launcher, covered by test_platform.py") @pytest.mark.parametrize("argv,verb", [ (["reload"], "reload"), (["restart-daemon"], "restart"), @@ -170,7 +172,8 @@ def test_lifecycle_commands_delegate_to_systemctl_user( @pytest.mark.skipif(sys.platform == "win32", - reason="systemd lifecycle; Windows path (schtasks) is W3, not built") + reason="systemd lifecycle; Windows uses the loopback API / " + "Startup-folder launcher, covered by test_platform.py") @pytest.mark.parametrize("argv", [["reload"], ["restart-daemon"]]) def test_lifecycle_commands_report_systemctl_failure( argv, stub_daemon, monkeypatch, capsys): diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py index 9358894..f9464c3 100644 --- a/packages/meshbay-node/tests/test_platform.py +++ b/packages/meshbay-node/tests/test_platform.py @@ -102,3 +102,78 @@ def test_configure_event_loop_selector_opt_in(monkeypatch): plat.configure_event_loop() assert isinstance(asyncio.get_event_loop_policy(), asyncio.WindowsSelectorEventLoopPolicy) + + +# ── Autostart ──────────────────────────────────────────────────────────────── +# +# On Windows autostart is a `.vbs` in the per-user Startup folder (a logon task +# would need elevation). Tests point APPDATA at a tmp dir so the real Startup +# folder is never touched, and force sys.platform. + +@pytest.fixture +def win_startup(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", str(tmp_path)) + return (tmp_path / "Microsoft" / "Windows" / "Start Menu" / "Programs" + / "Startup" / "MeshBay Node.vbs") + + +def test_autostart_supported_tracks_the_platform(monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + assert plat.autostart_supported() is True + monkeypatch.setattr(sys, "platform", "linux") + assert plat.autostart_supported() is False + + +def test_autostart_status_is_inert_off_windows(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + assert plat.autostart_status() == {"installed": False, "state": ""} + + +def test_autostart_install_writes_a_hidden_launcher_in_the_startup_folder(win_startup): + exe = r"C:\Program Files\MeshBay\meshbay-node.exe" + plat.autostart_install(exe=exe) + + assert win_startup.exists() + assert plat.autostart_status() == {"installed": True, "state": ""} + raw = win_startup.read_bytes() + assert b"\r\n" in raw and b"\n\n" not in raw # CRLF, no stray LF + text = raw.decode("utf-8") + assert f'"{exe}"' in text # path is quote-wrapped + assert "Chr(34)" in text and ", 0, False" in text # hidden, non-blocking + + +def test_autostart_remove_deletes_the_launcher_and_is_idempotent(win_startup): + plat.autostart_install(exe=r"C:\x\meshbay-node.exe") + assert win_startup.exists() + plat.autostart_remove() + assert not win_startup.exists() + plat.autostart_remove() # no error second time + + +def test_autostart_install_needs_a_locatable_launcher(win_startup, monkeypatch): + monkeypatch.setattr(plat, "_node_exe", lambda: None) + with pytest.raises(RuntimeError, match="locate the meshbay-node launcher"): + plat.autostart_install() + + +def test_autostart_install_refuses_off_windows(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + with pytest.raises(RuntimeError, match="Windows-only"): + plat.autostart_install(exe="/usr/bin/meshbay-node") + + +def test_autostart_run_launches_the_resolved_exe_detached(win_startup, monkeypatch): + monkeypatch.setattr(plat, "_node_exe", lambda: r"C:\x\meshbay-node.exe") + calls = {} + monkeypatch.setattr(plat.subprocess, "Popen", + lambda argv, **kw: calls.update(argv=argv, kw=kw)) + plat.autostart_run() + assert calls["argv"] == [r"C:\x\meshbay-node.exe"] + assert calls["kw"]["creationflags"] & 0x08000000 # CREATE_NO_WINDOW + + +def test_autostart_run_refuses_off_windows(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + with pytest.raises(RuntimeError, match="Windows-only"): + plat.autostart_run() |