aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/platform.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-09 18:19:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 09:57:29 +0200
commit42b562fcf312ddceb78438b5daea49d4c9b465c8 (patch)
tree770913e96292b4c5123f8c5ecba2d62577307f67 /packages/meshbay-node/src/meshbay_node/platform.py
parent25152ddb61a89ea3ea29d3aef5de13343429d32a (diff)
downloadmeshbay-42b562fcf312ddceb78438b5daea49d4c9b465c8.tar.gz
feat(packaging): the Store package declares what the NSIS scripts do
A test-signed install of the MSIX build, in the WindowsApps folder a Store install uses, showed that every script-made piece of the NSIS model breaks there, because each names the install folder and every update deletes it: the firewall rules went stale, the PATH entries piled up pointing at deleted folders, and the Startup-folder .vbs was refused ("Permission denied") right after sign-in. The network capabilities the manifest declared covered nothing: they make rules for sandboxed apps only, and a listener in the package still got the Windows firewall prompt. The package's own startup task was on by default, started the node whatever mode the Node page said, and ran the console executable, whose window stopped the node when closed. The package now declares what Windows then creates at install, carries across updates and removes with the app, all without an administrator prompt (each measured on the real install, through an update and a reboot): - firewall rules for the node, in a custom manifest template, since only a package-level element can hold them; - the startup task, off by default, running meshbay-nodew.exe, a new build of the daemon without a console; - an execution alias for meshbay-node.exe, so the app adds no PATH entry. The node's CLI switches the startup task (platform.startup_task, ctypes over the WinRT ABI): Windows gives the package's identity to the executables in it, not to a powershell.exe the app starts, which got "Element not found". `meshbay-node autostart install | remove | status` therefore works in the Store package from the app and a terminal alike; the app caches the answer, since the Node page polls. Starting at boot stays the .exe installer's: the Store package offers no service mode, and the CLI refuses `service install` there. Process listings count both image names. The Node page's status poll cleared the message of a refused action within five seconds; the two errors are kept apart now (all Windows builds). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/platform.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py156
1 files changed, 149 insertions, 7 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py
index c0e4d00..df29784 100644
--- a/packages/meshbay-node/src/meshbay_node/platform.py
+++ b/packages/meshbay-node/src/meshbay_node/platform.py
@@ -236,6 +236,141 @@ def autostart_supported() -> bool:
return sys.platform == "win32"
+STORE_PACKAGE_FAMILY_PREFIX = "MeshBay.MeshBay_"
+
+
+def in_store_package() -> bool:
+ """Whether this process runs as part of MeshBay's Microsoft Store package.
+
+ There, what starts the node at sign-in is the package's own startup task,
+ which Windows manages and the app switches on and off; a Startup-folder
+ launcher or a boot task would name the versioned WindowsApps path, which
+ each update deletes (and which Windows refused to a launcher right after
+ sign-in, found on a real install). The family name is checked, not merely
+ "some package": a process started from another packaged application -- a
+ developer's terminal inside one -- carries that application's identity.
+ """
+ if sys.platform != "win32":
+ return False
+ import ctypes
+ length = ctypes.c_uint32(0)
+ kernel32 = ctypes.windll.kernel32
+ if kernel32.GetCurrentPackageFamilyName(ctypes.byref(length), None) != 122:
+ return False # 15700: no package identity at all
+ buf = ctypes.create_unicode_buffer(length.value)
+ if kernel32.GetCurrentPackageFamilyName(ctypes.byref(length), buf) != 0:
+ return False
+ return buf.value.startswith(STORE_PACKAGE_FAMILY_PREFIX)
+
+
+# The startup task the Store package declares (meshbay-client/build/
+# appx-extensions.xml), through Windows.ApplicationModel.StartupTask. Only a
+# process with the package's identity may ask, and Windows gives it to the
+# executables inside the package -- this one -- but not to one it starts from
+# elsewhere: a powershell.exe the app ran for this got "Element not found".
+# So the CLI does it, for the app and a terminal alike. ctypes over the WinRT
+# ABI rather than a binding package: four calls do not justify a dependency.
+STARTUP_TASK_ID = "MeshBayNodeStartup"
+STARTUP_TASK_STATES = ("Disabled", "DisabledByUser", "Enabled",
+ "DisabledByPolicy", "EnabledByPolicy")
+_IID_STARTUP_TASK_STATICS = "{EE5B60BD-A148-41A7-B26E-E8B88A1E62F8}"
+_IID_ASYNC_INFO = "{00000036-0000-0000-C000-000000000046}"
+
+
+def _winrt_method(obj, index: int, *argtypes):
+ """Method `index` of a COM/WinRT interface pointer. IUnknown takes 0-2 and
+ IInspectable 3-5, so an interface's own methods start at 6."""
+ import ctypes
+ vtbl = ctypes.cast(obj, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p)))[0]
+ proto = ctypes.WINFUNCTYPE(ctypes.HRESULT, ctypes.c_void_p, *argtypes)
+ return lambda *args: proto(vtbl[index])(obj, *args)
+
+
+def _winrt_release(obj) -> None:
+ import ctypes
+ if obj:
+ ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_void_p)(
+ ctypes.cast(obj, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p)))[0][2])(obj)
+
+
+def _winrt_await(op, result_type, timeout: float = 15.0):
+ """Wait for an IAsyncOperation<T> and return its result (T)."""
+ import ctypes
+ import time
+ import uuid
+ iid = (ctypes.c_byte * 16).from_buffer_copy(uuid.UUID(_IID_ASYNC_INFO).bytes_le)
+ info = ctypes.c_void_p()
+ _winrt_method(op, 0, ctypes.c_void_p, ctypes.c_void_p)(ctypes.byref(iid), ctypes.byref(info))
+ try:
+ status = ctypes.c_int(0)
+ deadline = time.monotonic() + timeout
+ while True:
+ _winrt_method(info, 7, ctypes.POINTER(ctypes.c_int))(ctypes.byref(status))
+ if status.value: # 1 Completed, 2 Canceled, 3 Error
+ break
+ if time.monotonic() > deadline:
+ raise RuntimeError("Windows did not answer about the startup task")
+ time.sleep(0.02)
+ if status.value != 1:
+ code = ctypes.c_long(0)
+ _winrt_method(info, 8, ctypes.POINTER(ctypes.c_long))(ctypes.byref(code))
+ raise OSError(None, "the startup task", None, code.value)
+ finally:
+ _winrt_release(info)
+ result = result_type()
+ _winrt_method(op, 8, ctypes.POINTER(result_type))(ctypes.byref(result)) # GetResults
+ return result
+
+
+def startup_task(action: str = "query") -> str:
+ """Query, enable or disable the Store package's startup task and return
+ its state, one of STARTUP_TASK_STATES. A task the user switched off in
+ Settings > Apps > Startup stays "DisabledByUser": Windows lets only the
+ user turn it back on, there. Raises RuntimeError outside the package."""
+ if action not in ("query", "enable", "disable"):
+ raise ValueError(action)
+ if not in_store_package():
+ raise RuntimeError("the startup task exists only in the Microsoft Store package")
+ import ctypes
+ import uuid
+ combase = ctypes.WinDLL("combase")
+ combase.RoGetActivationFactory.restype = ctypes.HRESULT # raises on failure
+ combase.RoInitialize(1) # multithreaded; already initialised is fine
+ name = "Windows.ApplicationModel.StartupTask"
+ class_id, task_id = ctypes.c_void_p(), ctypes.c_void_p()
+ combase.WindowsCreateString(ctypes.c_wchar_p(name), len(name), ctypes.byref(class_id))
+ combase.WindowsCreateString(ctypes.c_wchar_p(STARTUP_TASK_ID), len(STARTUP_TASK_ID),
+ ctypes.byref(task_id))
+ statics, op, task = ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_void_p()
+ try:
+ iid = (ctypes.c_byte * 16).from_buffer_copy(uuid.UUID(_IID_STARTUP_TASK_STATICS).bytes_le)
+ combase.RoGetActivationFactory(class_id, ctypes.byref(iid), ctypes.byref(statics))
+ _winrt_method(statics, 7, ctypes.c_void_p, ctypes.c_void_p)( # GetAsync
+ task_id, ctypes.byref(op))
+ task = _winrt_await(op, ctypes.c_void_p)
+ if action == "enable":
+ enable_op = ctypes.c_void_p()
+ _winrt_method(task, 6, ctypes.c_void_p)(ctypes.byref(enable_op)) # RequestEnableAsync
+ try:
+ _winrt_await(enable_op, ctypes.c_int)
+ finally:
+ _winrt_release(enable_op)
+ elif action == "disable":
+ _winrt_method(task, 7)() # Disable
+ state = ctypes.c_int(-1)
+ _winrt_method(task, 8, ctypes.POINTER(ctypes.c_int))(ctypes.byref(state)) # get_State
+ except OSError as e:
+ raise RuntimeError(f"the startup task: {e}") from e
+ finally:
+ for obj in (task, op, statics):
+ _winrt_release(obj)
+ combase.WindowsDeleteString(class_id)
+ combase.WindowsDeleteString(task_id)
+ if 0 <= state.value < len(STARTUP_TASK_STATES):
+ return STARTUP_TASK_STATES[state.value]
+ return f"unknown ({state.value})"
+
+
def _startup_vbs() -> Path:
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
return (Path(base) / "Microsoft" / "Windows" / "Start Menu" / "Programs"
@@ -366,7 +501,10 @@ def autostart_run() -> None:
subprocess.Popen([exe], creationflags=0x00000200 | 0x08000000, close_fds=True)
-NODE_IMAGE = "meshbay-node.exe"
+# The daemon's image names: the console build, which is also every CLI verb,
+# and the windowless one the Store package's startup task runs
+# (packaging/win/meshbay-node.spec). A node is a node whichever started it.
+NODE_IMAGES = ("meshbay-node.exe", "meshbay-nodew.exe")
def autostart_end() -> None:
@@ -391,15 +529,17 @@ def autostart_end() -> None:
"""
if not autostart_supported():
return
- argv = ["taskkill", "/F", "/T", "/IM", NODE_IMAGE]
+ argv = ["taskkill", "/F", "/T"]
+ for image in NODE_IMAGES:
+ argv += ["/IM", image]
for pid in {os.getpid(), os.getppid()}:
argv += ["/FI", f"PID ne {pid}"]
subprocess.run(argv, capture_output=True)
def node_pids() -> list[int]:
- """Every meshbay-node.exe running, in any session, except this process and
- its parent (the CLI is meshbay-node.exe too). Empty off Windows.
+ """Every node process running (NODE_IMAGES), in any session, except this
+ process and its parent (the CLI is meshbay-node.exe too). Empty off Windows.
What says whether a node is still there after a stop: its control API
closes first, so a process that has not exited yet answers nothing and
@@ -407,13 +547,15 @@ def node_pids() -> list[int]:
"""
if sys.platform != "win32":
return []
- r = subprocess.run(["tasklist", "/FI", f"IMAGENAME eq {NODE_IMAGE}", "/NH", "/FO", "CSV"],
- capture_output=True, text=True)
+ # One filter cannot name two images, so the whole list, filtered here.
+ r = subprocess.run(["tasklist", "/NH", "/FO", "CSV"], capture_output=True, text=True)
+ images = {i.lower() for i in NODE_IMAGES}
mine = {os.getpid(), os.getppid()}
pids = []
for line in r.stdout.splitlines():
cells = [c.strip('"') for c in line.split('","')]
- if len(cells) > 1 and cells[1].isdigit() and int(cells[1]) not in mine:
+ if (len(cells) > 1 and cells[0].lower() in images and cells[1].isdigit()
+ and int(cells[1]) not in mine):
pids.append(int(cells[1]))
return pids