1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
|
"""Platform-specific paths and tool resolution for meshbay-node."""
import asyncio
import os
import shutil
import subprocess
import sys
from pathlib import Path
# ── Console ──────────────────────────────────────────────────────────────────
def force_utf8_stdio() -> None:
"""
Make stdout/stderr UTF-8. A Windows console is cp1252 by default, so any
``print()`` carrying a character outside it — the ``->`` arrows and em
dashes the CLI help and messages are full of — raises UnicodeEncodeError
and takes the command down with it. No effect where the streams are
already UTF-8 or cannot be reconfigured.
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError, OSError):
pass
# ── Event loop ───────────────────────────────────────────────────────────────
def configure_event_loop() -> None:
"""
The daemon runs on Windows' default ProactorEventLoop: verified end to end
(a live browser peer connecting, an index sync, a file download and an
ffmpeg-transcoded video stream). aiortc only ever hangs on it in the
*same-process loopback* the tests use, which the test suite handles on its
own (repo-root conftest).
Escape hatch, opt-in only: MESHBAY_NODE_EVENT_LOOP=selector switches to the
SelectorEventLoop. That fixes aiortc-in-one-process but breaks ffmpeg
(SelectorEventLoop cannot spawn subprocesses on Windows), so it is not the
default and probably never should be.
"""
if sys.platform != "win32":
return
if os.environ.get("MESHBAY_NODE_EVENT_LOOP", "").lower() == "selector":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# ── Directories ──────────────────────────────────────────────────────────────
def config_dir() -> Path:
if sys.platform == "win32":
return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "meshbay"
return Path.home() / ".config" / "meshbay"
def data_dir() -> Path:
if sys.platform == "win32":
return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "meshbay" / "data"
return Path.home() / ".local" / "share" / "meshbay"
def state_dir() -> Path:
if sys.platform == "win32":
return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "meshbay" / "state"
return Path.home() / ".local" / "state" / "meshbay"
# ── Packaged defaults ────────────────────────────────────────────────────────
def packaged_default_env() -> Path | None:
"""
The `default.env` shipped with the package: build-time defaults, currently
the shared read-only TMDB token. `init` copies it to config_dir()/node.env
and nothing reads it in place, so an operator's edits to their own copy
survive an upgrade.
Frozen (PyInstaller/Windows): beside the executable, where
build-node-runtime.ps1 puts it -- the same placement it uses for ffmpeg.
Packaged (Linux): /opt/meshbay-node/share/default.env, from build-node.sh.
None in a source checkout, where no package wrote one.
"""
candidates = []
if getattr(sys, "frozen", False):
candidates.append(Path(sys.executable).parent / "default.env")
candidates.append(Path("/opt/meshbay-node/share/default.env"))
for path in candidates:
try:
if path.is_file():
return path
except OSError:
continue
return None
def install_node_env(target_dir: Path) -> Path | None:
"""
Copy the packaged default.env to <target_dir>/node.env, once, at init.
Never overwrites: an existing node.env holds the operator's own values, and
silently replacing a configured token with the packaged one would be worse
than doing nothing. Returns the path when written, None when there was
nothing to copy or a file was already there.
"""
src = packaged_default_env()
if src is None:
return None
dest = target_dir / "node.env"
if dest.exists():
return None
dest.write_bytes(src.read_bytes())
chmod_private(dest)
return dest
def load_node_env(source_dir: Path) -> int:
"""
Read <source_dir>/node.env into os.environ, returning how many names were
set.
systemd does this on Linux through `EnvironmentFile=`, but the Windows
autostart is a Startup-folder .vbs with no equivalent, so the daemon reads
the file itself and both platforms behave the same. An existing environment
variable always wins -- an operator exporting a value, or systemd having
already loaded the same file, overrides the packaged default rather than
being overridden by it.
"""
path = source_dir / "node.env"
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return 0
count = 0
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, _, value = line.partition("=")
name = name.strip()
value = value.strip().strip('"').strip("'")
if not name or name in os.environ:
continue
os.environ[name] = value
count += 1
return count
# ── File permissions ─────────────────────────────────────────────────────────
def chmod_private(path: Path, *, mode: int = 0o600) -> None:
"""Set restrictive permissions on a file. No-op on Windows (NTFS ignores mode bits)."""
if sys.platform != "win32":
path.chmod(mode)
# ── Media tools ──────────────────────────────────────────────────────────────
_ffmpeg_path: str = "ffmpeg"
_ffprobe_path: str = "ffprobe"
def check_media_tools(
ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe",
) -> None:
"""Resolve ffmpeg/ffprobe at daemon startup. Raises RuntimeError if not found."""
global _ffmpeg_path, _ffprobe_path
resolved = shutil.which(ffmpeg)
if not resolved:
raise RuntimeError(
f"{ffmpeg!r} not found in PATH. "
"Install ffmpeg or set [node] ffmpeg_path in node.toml."
)
_ffmpeg_path = resolved
resolved = shutil.which(ffprobe)
if not resolved:
raise RuntimeError(
f"{ffprobe!r} not found in PATH. "
"Install ffmpeg or set [node] ffprobe_path in node.toml."
)
_ffprobe_path = resolved
def ffmpeg_cmd() -> str:
return _ffmpeg_path
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. See the Service mode section below for the
# boot-capable, admin-once alternative built on top of Task Scheduler instead.
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)
# ── Service mode (Windows, opt-in at install time) ───────────────────────────
#
# The Startup-folder .vbs above only ever runs after *this* user signs in. A
# real Windows Service would run before anyone signs in, but under
# LocalSystem/NetworkService — accounts with no normal user profile, so
# %LOCALAPPDATA%\meshbay\ (config, keystore, data) would not exist for it.
# Relocating storage to make that work is real surgery (Phase 2, deliberately
# not this).
#
# The middle ground: a Scheduled Task, created once with admin rights, that
# runs *as this user* at system boot without needing them to sign in first.
# `schtasks /create ... /ru <user> /rp ""` with no `/it` registers an S4U
# (Service For User) logon — no password stored anywhere, and unlike
# LocalSystem it loads this account's own profile, so config_dir()/data_dir()
# need no special-casing at all. The cost: S4U carries no *network* credential
# (no reaching a domain share as this user), which the node never needed
# anyway — everything it touches is local disk plus outbound internet.
#
# Creating the task needs admin (a boot-trigger touches system-wide scheduler
# state, the same reason /sc onlogon did — see the autostart section above).
# Querying, running and ending an *already-created* task, as the same user it
# was registered for, does not — Task Scheduler grants the owner that much by
# default, which is what lets the Node page drive it with no further prompts.
TASK_NAME = "MeshBay Node" # the Scheduled Task's own name
def service_supported() -> bool:
return sys.platform == "win32"
def _current_user() -> str:
domain = os.environ.get("USERDOMAIN") or os.environ.get("COMPUTERNAME") or "."
user = os.environ.get("USERNAME") or ""
return f"{domain}\\{user}" if user else ""
def _schtasks(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["schtasks", *args], capture_output=True, text=True)
def service_status() -> dict:
"""{'installed': bool, 'state': str}. 'state' is Task Scheduler's own word
('Ready', 'Running', 'Disabled', ...), '' when not installed."""
if not service_supported():
return {"installed": False, "state": ""}
r = _schtasks("/query", "/tn", TASK_NAME, "/fo", "list")
if r.returncode != 0:
return {"installed": False, "state": ""}
state = ""
for line in r.stdout.splitlines():
if line.lower().startswith("status:"):
state = line.split(":", 1)[1].strip()
break
return {"installed": True, "state": state}
def service_install(exe: str | None = None) -> None:
"""
Register the boot-time Scheduled Task. Needs admin — raises RuntimeError
with schtasks' own message on failure, which is "Access is denied." when
not elevated.
"""
if not service_supported():
raise RuntimeError("service mode 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")
user = _current_user()
if not user:
raise RuntimeError("could not determine the current user (USERNAME unset)")
r = _schtasks("/create", "/tn", TASK_NAME, "/tr", f'"{exe}"',
"/sc", "onstart", "/ru", user, "/rp", "", "/rl", "limited", "/f")
if r.returncode != 0:
raise RuntimeError(f"schtasks /create failed: {r.stderr.strip() or r.stdout.strip()}")
def service_remove() -> None:
"""Delete the Scheduled Task if present. Needs admin; silent otherwise
(mirrors autostart_remove — nothing to report if it was never installed)."""
if service_supported():
_schtasks("/delete", "/tn", TASK_NAME, "/f")
def service_run() -> None:
"""Start the task now. No admin needed for an already-registered task."""
if service_supported():
_schtasks("/run", "/tn", TASK_NAME)
def service_end() -> None:
"""Stop the running instance, if any. No admin needed."""
if service_supported():
_schtasks("/end", "/tn", TASK_NAME)
|