diff --git a/src/plaud_tools/tray/process_launch.py b/src/plaud_tools/tray/process_launch.py index cc7d228..6a7ff4b 100644 --- a/src/plaud_tools/tray/process_launch.py +++ b/src/plaud_tools/tray/process_launch.py @@ -43,6 +43,14 @@ # future Python drops the attribute. _CREATE_BREAKAWAY_FROM_JOB: int = getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0x01000000) +# On a stock machine the default PowerShell execution policy (``Restricted``) +# refuses to run any script, and an interactive prompt would hang forever with +# stdio wired to DEVNULL. Every launch needs both flags to run reliably from +# the no-console frozen tray; only the updater passed them explicitly, so the +# uninstall-helper and toast launches could still fail on a stock policy +# (#142). Injected here (once) so every call site gets them for free. +_REQUIRED_FLAGS: tuple[str, ...] = ("-NonInteractive", "-ExecutionPolicy", "Bypass") + def launch_hidden_powershell( args: list[str], @@ -61,7 +69,15 @@ def launch_hidden_powershell( this): ``CREATE_BREAKAWAY_FROM_JOB`` is requested, with a same-args retry (flag dropped) if the enclosing Job Object forbids it -- so the operation is never worse off than launching without the flag at all. + + ``-NonInteractive -ExecutionPolicy Bypass`` are injected right after + ``args[0]`` (the powershell.exe path) unless already present, so every + caller is immune to a stock ``Restricted`` execution policy without + having to remember the flags itself (#142). """ + if "-NonInteractive" not in args: + args = [args[0], *_REQUIRED_FLAGS, *args[1:]] + base_flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP kwargs: dict[str, object] = { "stdin": subprocess.DEVNULL, diff --git a/src/plaud_tools/tray/updater.py b/src/plaud_tools/tray/updater.py index 1c6de8c..ae633aa 100644 --- a/src/plaud_tools/tray/updater.py +++ b/src/plaud_tools/tray/updater.py @@ -53,13 +53,12 @@ def _launch_updater(ps_path: Path) -> subprocess.Popen[bytes]: and update.ps1 then waits for the tray to exit before replacing its files. Delegates to :func:`process_launch.launch_hidden_powershell` (#142) for the safe-stdio + job-breakaway launch semantics shared with the uninstaller. + ``-NonInteractive -ExecutionPolicy Bypass`` are injected by the shared + helper itself, so they aren't repeated here. """ args = [ _POWERSHELL_EXE, "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", "-WindowStyle", "Hidden", "-File", diff --git a/tests/test_process_launch.py b/tests/test_process_launch.py index 93953fe..60a6191 100644 --- a/tests/test_process_launch.py +++ b/tests/test_process_launch.py @@ -144,6 +144,52 @@ def fake_popen(args, creationflags=0, **kwargs): assert len(calls) == 1 +def test_execution_policy_flags_injected_when_missing(monkeypatch): + """A stock machine's default ``Restricted`` execution policy refuses to run + any script; every call site must get ``-NonInteractive -ExecutionPolicy + Bypass`` even if it forgot to pass them itself (#142). + """ + seen: dict[str, list[str]] = {} + + def fake_popen(args, **kwargs): + seen["args"] = args + return _FakeProc() + + monkeypatch.setattr(process_launch.subprocess, "Popen", fake_popen) + + launch_hidden_powershell(["powershell.exe", "-WindowStyle", "Hidden", "-Command", "Write-Host hi"]) + + assert seen["args"] == [ + "powershell.exe", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-Command", + "Write-Host hi", + ] + + +def test_execution_policy_flags_not_duplicated(monkeypatch): + """A caller that already passes the flags (the updater, historically) + must not get them injected a second time. + """ + seen: dict[str, list[str]] = {} + + def fake_popen(args, **kwargs): + seen["args"] = args + return _FakeProc() + + monkeypatch.setattr(process_launch.subprocess, "Popen", fake_popen) + + original = ["powershell.exe", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "x.ps1"] + launch_hidden_powershell(original) + + assert seen["args"] == original + assert seen["args"].count("-NonInteractive") == 1 + + def test_cwd_is_forwarded(monkeypatch): seen: dict[str, object] = {}