diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/platform.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/platform.py | 156 |
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 |