aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-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
4 files changed, 235 insertions, 14 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)
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()