aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-client/src/main.js106
-rw-r--r--packages/meshbay-client/src/preload.js10
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py79
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py88
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py7
-rw-r--r--packages/meshbay-node/tests/test_platform.py75
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()