aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/lifecycle.py37
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py156
2 files changed, 186 insertions, 7 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
index b461055..1c9e026 100644
--- a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
+++ b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
@@ -179,6 +179,35 @@ def restart_daemon(args) -> None:
return
+# In the Store package the sign-in start is the package's startup task, which
+# Windows lists in Settings > Apps > Startup (platform.startup_task).
+STORE_SIGNIN = ("In MeshBay from the Microsoft Store, the node starts at sign-in through "
+ "the \"MeshBay Node\" entry of Windows Settings > Apps > Startup "
+ "(meshbay-node autostart install | remove).")
+
+
+def _store_autostart(sub: str) -> None:
+ """autostart install | remove | status in the Store package. The first line
+ is "startup task <state>", which the desktop app reads."""
+ from meshbay_node import platform as _plat
+ action = {"install": "enable", "remove": "disable", "status": "query"}[sub]
+ try:
+ state = _plat.startup_task(action)
+ except RuntimeError as e:
+ print(f"Could not reach {e}")
+ sys.exit(1)
+ print(f"startup task {state}")
+ if state == "DisabledByUser":
+ print("Switched off in Windows Settings > Apps > Startup (\"MeshBay Node\"): "
+ "only you can switch it back on, there.")
+ elif state.endswith("ByPolicy"):
+ print("Set by a policy on this computer.")
+ elif state == "Enabled":
+ print("meshbay-node starts at each sign-in (no window, no admin).")
+ if sub == "install" and not state.startswith("Enabled"):
+ sys.exit(1)
+
+
def autostart(args) -> None:
from meshbay_node import platform as _plat
if sys.platform != "win32":
@@ -186,6 +215,9 @@ def autostart(args) -> None:
"'systemctl --user enable --now meshbay-node'.")
sys.exit(1)
sub = args.subcommand or "status"
+ if sub in ("install", "remove", "status") and _plat.in_store_package():
+ _store_autostart(sub)
+ return
service_mode = _plat.service_status()["installed"]
if sub == "install":
if service_mode:
@@ -226,6 +258,11 @@ def service(args) -> None:
"'systemctl --user enable --now meshbay-node'.")
sys.exit(1)
sub = args.subcommand or "status"
+ if sub == "install" and _plat.in_store_package():
+ print("Starting the node at boot, before anyone signs in, needs MeshBay's .exe "
+ "installer: the Microsoft Store version starts it at sign-in at most. "
+ + STORE_SIGNIN)
+ sys.exit(1)
if sub == "install":
# A node already running in this session holds the control API's port:
# the service's own would exit at once, leaving the old one in charge.
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