diff --git a/CHANGELOG.md b/CHANGELOG.md index e77c47f4..eb241a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured. + ## 0.27.0 (2026-05-31) ### What changed in this release diff --git a/README.md b/README.md index a80129a6..1f1b5cae 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,8 @@ pythinker --version installs to `%ProgramFiles%\Pythinker` and writes PATH to HKLM (requires admin). **Upgrade:** `pythinker update` from inside the running app — it downloads -the newest installer, verifies SHA-256, and re-runs it silently -(`/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`). +the newest installer, verifies SHA-256, and launches the signed Inno installer +with visible progress (`/SILENT /NORESTART /CURRENTUSER /CLOSEAPPLICATIONS /NORESTARTAPPLICATIONS`). **Uninstall:** Apps & Features → *Pythinker Code* → Uninstall reverts both the files and the PATH edit. diff --git a/docs/public/install.ps1 b/docs/public/install.ps1 index fa1e6d2f..6e4d2e54 100644 --- a/docs/public/install.ps1 +++ b/docs/public/install.ps1 @@ -198,8 +198,14 @@ try { OK "Checksum OK" Step "Running Pythinker installer" - $args = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER') - $process = Start-Process -FilePath $installerPath -ArgumentList $args -Wait -PassThru + $installerArgs = @( + '/SILENT', + '/NORESTART', + '/CURRENTUSER', + '/CLOSEAPPLICATIONS', + '/NORESTARTAPPLICATIONS' + ) + $process = Start-Process -FilePath $installerPath -ArgumentList $installerArgs -Wait -PassThru if ($process.ExitCode -ne 0) { Fail "installer exited with code $($process.ExitCode)" } diff --git a/packages/windows-installer/build.ps1 b/packages/windows-installer/build.ps1 index 474f5ef2..51d93dc1 100644 --- a/packages/windows-installer/build.ps1 +++ b/packages/windows-installer/build.ps1 @@ -5,10 +5,10 @@ Steps: 1. Validate version, generate versioninfo.generated.txt. 2. Run PyInstaller using pythinker.spec -> dist/pythinker/ - 3. Sign dist/pythinker/pythinker.exe (no-op if cert env unset). - 4. Compile installer.iss with iscc -> dist/PythinkerSetup-.exe - 5. Sign the resulting setup .exe. - 6. Write SHA256 next to the installer. + 3. Sign bundled PE files (.exe/.dll/.pyd) when cert env is configured. + 4. Compile installer.iss with iscc -> dist/PythinkerSetup-.exe. + When signing is configured, Inno signs Setup, Uninstall, and temp copies. + 5. Write SHA256 next to the installer. #> [CmdletBinding()] param( @@ -42,6 +42,26 @@ Set-Content -Path $verOut -Value $verTemplate -Encoding UTF8 Write-Host "build.ps1: building Pythinker $Version" +$signScript = Join-Path $here 'sign\sign.ps1' +$signingConfigured = ` + -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CERT_PFX_BASE64) -and ` + -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CERT_PASSWORD) + +if (-not $signingConfigured) { + Write-Warning "build.ps1: WINDOWS_CERT_PFX_BASE64 / WINDOWS_CERT_PASSWORD not set; Windows artifacts will be unsigned" +} + +function Invoke-PythinkerSign { + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + if ($signingConfigured) { + & $signScript $Path + } +} + # --- 2. PyInstaller -------------------------------------------------------- if (-not $SkipFreeze) { Push-Location $here @@ -58,8 +78,15 @@ if (-not (Test-Path $frozenExe)) { throw "build.ps1: frozen binary not found at $frozenExe" } -# --- 3. sign inner exe ----------------------------------------------------- -& (Join-Path $here 'sign\sign.ps1') $frozenExe +# --- 3. sign bundled PE files --------------------------------------------- +if ($signingConfigured) { + $bundleRoot = Join-Path $dist 'pythinker' + $peFiles = Get-ChildItem $bundleRoot -Recurse -File | + Where-Object { $_.Extension -in @('.exe', '.dll', '.pyd') } + foreach ($file in $peFiles) { + Invoke-PythinkerSign $file.FullName + } +} # --- 4. Inno Setup compile ------------------------------------------------- if (-not $SkipInstaller) { @@ -70,7 +97,20 @@ if (-not $SkipInstaller) { if (-not $iscc) { throw "build.ps1: iscc.exe not found. Install Inno Setup 6." } - & $iscc.Source "/DAppVersion=$Version" (Join-Path $here 'installer.iss') + $isccArgs = @("/DAppVersion=$Version") + if ($signingConfigured) { + # Keep quoted paths out of /SPythinkerSign itself. Windows PowerShell 5.1 + # can mangle nested quotes when passing native arguments to iscc.exe. + $env:PYTHINKER_INNO_SIGN_SCRIPT = "`"$signScript`"" + $signCommand = 'cmd.exe /D /C powershell.exe -NoProfile -NonInteractive -File %PYTHINKER_INNO_SIGN_SCRIPT% $f' + $isccArgs += "/SPythinkerSign=$signCommand" + $isccArgs += "/DUseInnoSignTool=1" + } + $isccArgs += (Join-Path $here 'installer.iss') + + Write-Host "build.ps1: invoking Inno Setup with arguments:" + foreach ($arg in $isccArgs) { Write-Host " $arg" } + & $iscc.Source @isccArgs if ($LASTEXITCODE -ne 0) { throw "Inno Setup compile failed ($LASTEXITCODE)" } } @@ -79,8 +119,12 @@ if (-not (Test-Path $installer)) { throw "build.ps1: installer not produced at $installer" } -# --- 5. sign installer ----------------------------------------------------- -& (Join-Path $here 'sign\sign.ps1') $installer +# --- 5. sign installer when compilation was skipped ------------------------ +if ($SkipInstaller) { + Invoke-PythinkerSign $installer +} elseif ($signingConfigured) { + Write-Host "build.ps1: installer, uninstaller, and setup temp copies signed by Inno Setup" +} # --- 6. SHA-256 ----------------------------------------------------------- $hash = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLower() diff --git a/packages/windows-installer/installer.iss b/packages/windows-installer/installer.iss index 82f2706d..83aa3224 100644 --- a/packages/windows-installer/installer.iss +++ b/packages/windows-installer/installer.iss @@ -30,8 +30,12 @@ ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible LicenseFile=assets\LICENSE.rtf ChangesEnvironment=yes -CloseApplications=force +CloseApplications=yes RestartApplications=no +#ifdef UseInnoSignTool +SignTool=PythinkerSign +SignedUninstaller=yes +#endif [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -82,6 +86,20 @@ begin ';' + UpperCase(OrigPath) + ';') = 0; end; +function PathWithoutEntry(OrigPath, Param: string): string; +var + BoundedPath: string; +begin + BoundedPath := ';' + OrigPath + ';'; + StringChangeEx(BoundedPath, ';' + Param + ';', ';', True); + while Pos(';;', BoundedPath) > 0 do + StringChangeEx(BoundedPath, ';;', ';', True); + if BoundedPath = ';' then + Result := '' + else + Result := Copy(BoundedPath, 2, Length(BoundedPath) - 2); +end; + procedure AddToPath(Param, RootHive: string); var OrigPath, NewPath: string; @@ -97,10 +115,11 @@ begin end; if not RegQueryStringValue(Root, Subkey, 'Path', OrigPath) then OrigPath := ''; + OrigPath := PathWithoutEntry(OrigPath, Param); if OrigPath = '' then NewPath := Param else - NewPath := OrigPath + ';' + Param; + NewPath := Param + ';' + OrigPath; RegWriteExpandStringValue(Root, Subkey, 'Path', NewPath); end; @@ -118,9 +137,7 @@ begin Subkey := 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; end; if not RegQueryStringValue(Root, Subkey, 'Path', OrigPath) then exit; - StringChangeEx(OrigPath, ';' + Param, '', True); - StringChangeEx(OrigPath, Param + ';', '', True); - StringChangeEx(OrigPath, Param, '', True); + OrigPath := PathWithoutEntry(OrigPath, Param); RegWriteExpandStringValue(Root, Subkey, 'Path', OrigPath); end; @@ -130,11 +147,9 @@ var begin if CurStep = ssPostInstall then begin AppDir := ExpandConstant('{app}'); - if WizardIsTaskSelected('modifypath') - and NeedsAddPath(AppDir, 'HKCU') then + if WizardIsTaskSelected('modifypath') then AddToPath(AppDir, 'HKCU'); - if WizardIsTaskSelected('modifypathmachine') - and NeedsAddPath(AppDir, 'HKLM') then + if WizardIsTaskSelected('modifypathmachine') then AddToPath(AppDir, 'HKLM'); end; end; diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fa1e6d2f..6e4d2e54 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -198,8 +198,14 @@ try { OK "Checksum OK" Step "Running Pythinker installer" - $args = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER') - $process = Start-Process -FilePath $installerPath -ArgumentList $args -Wait -PassThru + $installerArgs = @( + '/SILENT', + '/NORESTART', + '/CURRENTUSER', + '/CLOSEAPPLICATIONS', + '/NORESTARTAPPLICATIONS' + ) + $process = Start-Process -FilePath $installerPath -ArgumentList $installerArgs -Wait -PassThru if ($process.ExitCode -ne 0) { Fail "installer exited with code $($process.ExitCode)" } diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 4b249c1e..74d23641 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import base64 import contextlib import os import platform @@ -54,6 +53,7 @@ LAST_SEEN_VERSION_FILE = get_share_dir() / "last_seen_version.txt" AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60 PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS = 2.0 +WINDOWS_UPDATE_STAGING_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 _UPDATE_LOCK = asyncio.Lock() _skipped_version_this_session: str | None = None @@ -94,7 +94,7 @@ def _detect_upgrade_command() -> list[str]: exe = sys.executable.replace("\\", "/").lower() if "/cellar/pythinker-code/" in exe or "/homebrew/cellar/pythinker-code/" in exe: return ["brew", "upgrade", "pythinker-code"] - if _is_native_build(): + if _is_native_build() or (_is_windows() and not _is_running_from_source_checkout()): return [NATIVE_INSTALLER_MARKER] if "/uv/tools/" in exe: return ["uv", "tool", "upgrade", "pythinker-code"] @@ -122,40 +122,28 @@ def _is_windows() -> bool: def _spawn_detached_windows_upgrade(upgrade_command: list[str]) -> bool: - """Launch the upgrade in a new console window that survives this process exit. + """Launch a Windows upgrade command without a PowerShell wrapper. - Returns True if the helper was spawned. The helper waits for this process - to exit before invoking ``upgrade_command`` so the currently running - ``pythinker.exe`` has released the executable lock that ``uv``/``pip`` - would otherwise trip over with ``os error 32``. + Older builds used an encoded PowerShell payload to wait for the + current process and then run the updater. That shape is common in malware + and trips command-line heuristics in products such as Bitdefender. Instead + we start the real updater directly with inherited handles closed; the caller + exits immediately after spawning so Windows can release the running binary. """ if not _is_windows(): return False - pid = os.getpid() executable = which(upgrade_command[0]) or upgrade_command[0] - argument_text = subprocess.list2cmdline(upgrade_command[1:]) - script = ( - f"Write-Host 'Waiting for Pythinker (PID {pid}) to exit...';" - f"Wait-Process -Id {pid} -Timeout 60 -ErrorAction SilentlyContinue;" - "$psi = [System.Diagnostics.ProcessStartInfo]::new();" - f"$psi.FileName = {_ps_single_quote(executable)};" - f"$psi.Arguments = {_ps_single_quote(argument_text)};" - "$psi.UseShellExecute = $false;" - "$process = [System.Diagnostics.Process]::Start($psi);" - "$process.WaitForExit();" - "$exitCode = $process.ExitCode;" - "Write-Host '';" - "if ($exitCode -eq 0) {" - "Write-Host 'Upgrade finished. Press any key to close this window.';" - "} else {" - 'Write-Host "Upgrade failed with exit code $exitCode. ' - 'Press any key to close this window.";' - "};" - "$null = [System.Console]::ReadKey($true);" - ) - if not _spawn_detached_windows_powershell(script): - logger.warning("Failed to spawn detached Windows upgrade helper") + CREATE_NEW_CONSOLE = 0x00000010 + CREATE_NEW_PROCESS_GROUP = 0x00000200 + try: + subprocess.Popen( + [executable, *upgrade_command[1:]], + creationflags=CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP, + close_fds=True, + ) + except OSError: + logger.exception("Failed to spawn detached Windows upgrade helper:") return False return True @@ -302,6 +290,7 @@ async def prompt_pre_start_update() -> None: """ from pythinker_code.constant import VERSION as current_version + _cleanup_stale_windows_update_staging() if _auto_update_disabled() or _is_running_from_source_checkout(): return if not sys.stdout.isatty(): @@ -786,104 +775,41 @@ async def _fetch_native_release_asset( return download_url, sha -def _ps_single_quote(value: str) -> str: - """Quote a string for embedding inside a PowerShell single-quoted literal. +def _windows_native_installer_args() -> list[str]: + """Installer arguments for user-initiated Windows native updates. - PowerShell single-quoted strings (``'...'``) treat every character as a - literal except ``'``, which doubles. Windows paths don't normally contain - ``'``, but we quote defensively so an exotic ``%TEMP%`` can't break out. + Keep this transparent and boring. Hidden, encoded, or fully suppressed + updater chains look like commodity malware to command-line heuristics. + ``/SILENT`` still avoids the wizard, but leaves normal installer UI/errors + visible and delegates app-closing to Inno Setup's Restart Manager. """ - return "'" + value.replace("'", "''") + "'" + return [ + "/SILENT", + "/NORESTART", + "/CURRENTUSER", + "/CLOSEAPPLICATIONS", + "/NORESTARTAPPLICATIONS", + ] -def _spawn_detached_windows_powershell(script: str) -> bool: - """Run a PowerShell script in a new console using ``-EncodedCommand``. +def _spawn_detached_windows_installer(installer_path: Path) -> bool: + """Run the native installer directly, without PowerShell or cmd wrappers. - ``-EncodedCommand`` expects UTF-16LE bytes, and avoids the cmd.exe / Python - quoting layers entirely for the script payload. ``CREATE_NEW_CONSOLE`` keeps - the helper visible after the current Pythinker process exits. + The caller exits immediately after spawning, which releases the running + ``pythinker.exe`` handle. The installer itself handles closing/replacing + files through Inno Setup's Restart Manager support. """ if not _is_windows(): return False - powershell = which("powershell") or which("powershell.exe") - if powershell is None: - return False - CREATE_NEW_CONSOLE = 0x00000010 CREATE_NEW_PROCESS_GROUP = 0x00000200 - # PowerShell's -EncodedCommand mandates UTF-16LE bytes; the project lint - # blocks `.encode("utf-16-le")` so we use the bytes() constructor instead. - # The base64 output is pure ASCII, which is a strict subset of UTF-8. - encoded = base64.b64encode(bytes(script, "utf-16-le")).decode("utf-8") try: subprocess.Popen( - [powershell, "-NoProfile", "-EncodedCommand", encoded], - creationflags=CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP, + [str(installer_path), *_windows_native_installer_args()], + creationflags=CREATE_NEW_PROCESS_GROUP, close_fds=True, ) except OSError: - logger.exception("Failed to spawn detached Windows PowerShell helper:") - return False - return True - - -def _spawn_detached_windows_installer(installer_path: Path) -> bool: - """Run the native installer after this process exits and releases file locks. - - The detached PowerShell process waits on *this* process's PID (capped at - 60s via ``Wait-Process``) so the installer only runs once the running - ``pythinker.exe`` has released its own-file lock, then cleans up the - staged installer + temp dir. The cap stops a hung process stranding the - installer forever. - - Why a base64-encoded PowerShell payload instead of inline ``cmd /k``: - Windows has at least three command-line parsers in this chain — Python's - ``subprocess.list2cmdline``, cmd.exe's own rules, and - ``CommandLineToArgvW`` for the spawned child. Each strips a layer of - quoting differently. The previous inline ``powershell -Command - "Wait-Process …"`` form leaked the double quotes through every layer - until PowerShell saw a literal ``"Wait-Process …"`` *string expression* - and printed it instead of running the cmdlet. The base64 payload contains - only ``[A-Za-z0-9+/=]`` — no character that any of those parsers need to - escape — so the script reaches PowerShell intact regardless of how - ``%TEMP%`` or the installer path is spelled (spaces, parentheses, etc.). - """ - if not _is_windows(): - return False - pid = os.getpid() - tmpdir = installer_path.parent - - installer_arg = _ps_single_quote(str(installer_path)) - installer_arguments = _ps_single_quote( - subprocess.list2cmdline(["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"]) - ) - tmpdir_arg = _ps_single_quote(str(tmpdir)) - # PowerShell handles all I/O: wait, install, clean up, prompt. Each step - # is independently fault-tolerant; -ErrorAction SilentlyContinue lets the - # script complete even if (e.g.) Wait-Process can't find the PID because - # pythinker.exe has already exited. - script = ( - f"Write-Host 'Waiting for Pythinker (PID {pid}) to exit...';" - f"Wait-Process -Id {pid} -Timeout 60 -ErrorAction SilentlyContinue;" - f"$psi = [System.Diagnostics.ProcessStartInfo]::new();" - f"$psi.FileName = {installer_arg};" - f"$psi.Arguments = {installer_arguments};" - f"$psi.UseShellExecute = $false;" - f"$process = [System.Diagnostics.Process]::Start($psi);" - f"$process.WaitForExit();" - f"$exitCode = $process.ExitCode;" - f"Remove-Item -LiteralPath {tmpdir_arg} -Recurse -Force " - f"-ErrorAction SilentlyContinue;" - f"Write-Host '';" - f"if ($exitCode -eq 0) {{" - f"Write-Host 'Installer finished. Press any key to close this window.';" - f"}} else {{" - f'Write-Host "Installer failed with exit code $exitCode. ' - f'Press any key to close this window.";' - f"}};" - f"$null = [System.Console]::ReadKey($true);" - ) - if not _spawn_detached_windows_powershell(script): - logger.warning("Failed to spawn detached Windows native installer helper") + logger.exception("Failed to spawn detached Windows native installer:") return False return True @@ -891,22 +817,26 @@ def _spawn_detached_windows_installer(installer_path: Path) -> bool: def _run_native_installer(installer_path: Path) -> None: """Spawn the downloaded native installer and exit this process. - ``close_fds=True`` is critical on the fallback path: PyInstaller's official - Windows recipe (`Recipe-subprocess`) notes that the child inherits the - parent's open file handles by default — including the handle to - pythinker.exe itself — which leaves the parent binary locked even after - this process exits and prevents Inno Setup from replacing it. The detached - PowerShell helper (the preferred path) already sets this in - ``_spawn_detached_windows_powershell``. + ``close_fds=True`` is critical: PyInstaller's official Windows subprocess + recipe notes that child processes otherwise inherit open file handles, + including the handle to ``pythinker.exe`` itself. Inheriting that handle can + keep the parent binary locked even after this process exits. """ if _spawn_detached_windows_installer(installer_path): sys.exit(0) - subprocess.Popen( - [str(installer_path), "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], - creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) - | getattr(subprocess, "DETACHED_PROCESS", 0), - close_fds=True, - ) + try: + subprocess.Popen( + [str(installer_path), *_windows_native_installer_args()], + creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + | getattr(subprocess, "DETACHED_PROCESS", 0), + close_fds=True, + ) + except Exception as exc: + logger.exception("Failed to launch Windows native installer fallback:") + _t = _get_tui_tokens() + console.print(f"[{_t.error}]Failed to launch the Windows installer.[/]") + console.print(f"[{_t.muted}]Run it manually: {installer_path}[/]") + raise typer.Exit(1) from exc sys.exit(0) @@ -1088,10 +1018,41 @@ def _install_native_archive(archive: Path) -> UpdateResult: return UpdateResult.UPDATED -async def _maybe_run_native_update(latest_version: str, channel: str = "latest") -> UpdateResult: - """Native-build update path for an explicit user-requested update.""" +def _windows_update_staging_parent() -> Path: + return get_share_dir() / "windows-update-staging" + + +def _cleanup_stale_windows_update_staging(now: float | None = None) -> None: + if not _is_windows(): + return + parent = _windows_update_staging_parent() + if not parent.exists(): + return + cutoff = (time.time() if now is None else now) - WINDOWS_UPDATE_STAGING_MAX_AGE_SECONDS + for child in parent.glob("pythinker-update-*"): + try: + if child.is_dir() and child.stat().st_mtime < cutoff: + shutil.rmtree(child, ignore_errors=True) + except OSError: + logger.exception("Failed to inspect stale Windows update staging directory:") + + +def _make_native_update_tmpdir() -> Path: import tempfile + if _is_windows(): + _cleanup_stale_windows_update_staging() + staging_parent = _windows_update_staging_parent() + try: + staging_parent.mkdir(parents=True, exist_ok=True) + return Path(tempfile.mkdtemp(prefix="pythinker-update-", dir=staging_parent)) + except OSError: + logger.exception("Failed to create Windows update staging directory; using temp:") + return Path(tempfile.mkdtemp(prefix="pythinker-update-")) + + +async def _maybe_run_native_update(latest_version: str, channel: str = "latest") -> UpdateResult: + """Native-build update path for an explicit user-requested update.""" linux_package_kind = _installed_linux_package_kind() if _is_windows(): asset_name = native_installer_asset_name(latest_version) @@ -1113,11 +1074,12 @@ async def _maybe_run_native_update(latest_version: str, channel: str = "latest") return UpdateResult.FAILED download_url, expected_sha = fetched - tmpdir = Path(tempfile.mkdtemp(prefix="pythinker-update-")) - # On Windows, the detached PowerShell helper owns tmpdir cleanup so the - # installer .exe survives this process's exit. On Linux/Mac the install - # runs inline, so we own cleanup and must release ~50-100MB of archive - # + extracted-binary debris from /tmp on every update, success or fail. + tmpdir = _make_native_update_tmpdir() + # On Windows, the spawned installer must keep its staged .exe after + # this process exits, so a later launch prunes stale staging dirs. On + # Linux/Mac the install runs inline, so we own cleanup and must release + # ~50-100MB of archive + extracted-binary debris from /tmp on every + # update, success or fail. cleanup_tmpdir = True try: asset = tmpdir / asset_name @@ -1224,8 +1186,8 @@ def _print(message: str) -> None: _print(f"[{_t.muted}]Downloading native installer from GitHub Releases...[/]") if _is_windows(): _print( - f"[{_t.warning}]Pythinker will exit after staging the installer so " - "Windows can replace the running app.[/]" + f"[{_t.warning}]Pythinker will exit after staging the installer; " + "the signed Windows installer will continue normally.[/]" ) native_result = await _maybe_run_native_update(latest_version) if native_result is UpdateResult.UPDATE_AVAILABLE: @@ -1245,17 +1207,14 @@ def _print(message: str) -> None: _print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]") return native_result - # On Windows, the running pythinker.exe holds an exclusive lock on its own - # binary. Any in-process `uv tool upgrade` / `pip install --upgrade` fails - # with `os error 32` (file in use). Spawn the upgrade in a detached console - # that waits a few seconds, then exit so the lock releases first. + # On Windows, the running pythinker.exe can hold a lock on its own binary. + # Spawn the real upgrade command directly (no PowerShell/cmd wrapper), then + # exit so Windows can release the running executable. if _is_windows() and _spawn_detached_windows_upgrade(upgrade_command): _print( f"[{_t.warning}]Pythinker will exit so Windows can release the running executable.[/]" ) - _print(f"[{_t.muted}]The upgrade will continue in a new console window.[/]") - # Brief pause so the user can read the banner before the process dies. - await asyncio.sleep(1.0) + _print(f"[{_t.muted}]The upgrade will continue in a new process.[/]") sys.exit(0) try: diff --git a/tests/test_installation_docs.py b/tests/test_installation_docs.py index 98ab3fe3..0f43ee27 100644 --- a/tests/test_installation_docs.py +++ b/tests/test_installation_docs.py @@ -49,6 +49,10 @@ def test_windows_installer_bootstrap_downloads_native_setup() -> None: assert "Get-FileHash -Algorithm SHA256" in installer assert "Start-Process -FilePath $installerPath" in installer assert "'/CURRENTUSER'" in installer + assert "'/SILENT'" in installer + assert "'/CLOSEAPPLICATIONS'" in installer + assert "'/VERYSILENT'" not in installer + assert "'/SUPPRESSMSGBOXES'" not in installer assert "uv tool install" not in installer diff --git a/tests/test_release_update_pipeline.py b/tests/test_release_update_pipeline.py index b2c281d8..f25e61df 100644 --- a/tests/test_release_update_pipeline.py +++ b/tests/test_release_update_pipeline.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -64,6 +65,29 @@ def test_install_scripts_gate_on_asset_readiness() -> None: assert (ROOT / "web" / "public" / "install.sh").read_text() == sh +def test_windows_installer_signs_update_artifacts_when_credentials_are_available() -> None: + installer_script = (ROOT / "packages" / "windows-installer" / "installer.iss").read_text() + build_script = (ROOT / "packages" / "windows-installer" / "build.ps1").read_text() + + assert "CloseApplications=yes" in installer_script + assert "CloseApplications=force" not in installer_script + assert "SignTool=PythinkerSign" in installer_script + assert "SignedUninstaller=yes" in installer_script + assert re.search(r"NewPath\s*:=\s*Param\s*\+\s*';'\s*\+\s*OrigPath", installer_script) + assert not re.search(r"NewPath\s*:=\s*OrigPath\s*\+\s*';'\s*\+\s*Param", installer_script) + assert not re.search(r"StringChangeEx\(\s*OrigPath\s*,\s*Param\s*,", installer_script) + + # Signing only the final setup executable leaves Smart App Control and AV + # heuristics to inspect unsigned bundled/native helper files. Keep signing + # wired for the frozen executable, bundled DLL/PYD files, and Inno's + # setup/uninstaller/temp copies. + assert "@('.exe', '.dll', '.pyd')" in build_script + assert "PYTHINKER_INNO_SIGN_SCRIPT" in build_script + assert '-File `"$signScript`"' not in build_script + assert "/SPythinkerSign=$signCommand" in build_script + assert "/DUseInnoSignTool=1" in build_script + + def test_release_asset_wait_covers_all_updater_channels() -> None: promote_workflow = (WORKFLOWS / "promote-release.yml").read_text() diff --git a/tests/ui_and_conv/test_native_update_parity.py b/tests/ui_and_conv/test_native_update_parity.py index 73e7e38a..2382fafb 100644 --- a/tests/ui_and_conv/test_native_update_parity.py +++ b/tests/ui_and_conv/test_native_update_parity.py @@ -2,13 +2,14 @@ Kept in a separate file from test_shell_update.py to avoid colliding with concurrent edits there. Covers: the cached-only startup notice, the Windows -PID-wait installer command shape, the /update slash command registration, and -the in-shell run_update_prompt flow. +installer command shape, the /update slash command registration, and the +in-shell run_update_prompt flow. """ from __future__ import annotations from types import SimpleNamespace +from typing import cast import pythinker_code.constant as constant from pythinker_code.ui.shell import update @@ -58,17 +59,37 @@ def test_pending_update_notice_none_for_source_checkout(monkeypatch): assert update.pending_update_notice() is None -def test_windows_upgrade_helper_uses_encoded_powershell(monkeypatch): - import base64 +def test_windows_python_install_prefers_native_installer(monkeypatch): + monkeypatch.setattr(update, "_is_windows", lambda: True) + monkeypatch.setattr(update, "_is_native_build", lambda: False) + monkeypatch.setattr(update, "_is_running_from_source_checkout", lambda: False) + monkeypatch.setattr(update.sys, "executable", r"C:\Users\me\.local\bin\python.exe") + + assert update._detect_upgrade_command() == [update.NATIVE_INSTALLER_MARKER] + + +def test_windows_source_checkout_keeps_python_upgrade_command(monkeypatch): + monkeypatch.setattr(update, "_is_windows", lambda: True) + monkeypatch.setattr(update, "_is_native_build", lambda: False) + monkeypatch.setattr(update, "_is_running_from_source_checkout", lambda: True) + python = r"C:\repo\.venv\Scripts\python.exe" + monkeypatch.setattr(update.sys, "executable", python) + + assert update._detect_upgrade_command() == [ + python, + "-m", + "pip", + "install", + "--upgrade", + "pythinker-code", + ] + +def test_windows_upgrade_helper_uses_direct_command_not_powershell(monkeypatch): monkeypatch.setattr(update, "_is_windows", lambda: True) - monkeypatch.setattr(update.os, "getpid", lambda: 4242) def fake_which(name: str) -> str | None: - return { - "powershell": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - "uv": "C:\\Program Files\\uv\\uv.exe", - }.get(name) + return {"uv": "C:\\Program Files\\uv\\uv.exe"}.get(name) monkeypatch.setattr(update, "which", fake_which) @@ -83,24 +104,14 @@ def fake_popen(args, **kwargs): assert update._spawn_detached_windows_upgrade(["uv", "tool", "upgrade", "pythinker code"]) - args = captured["args"] - assert isinstance(args, list) - assert args[0] == "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - assert "-EncodedCommand" in args - kwargs = captured["kwargs"] - assert isinstance(kwargs, dict) - assert kwargs["creationflags"] & 0x00000010 # CREATE_NEW_CONSOLE - - encoded = args[args.index("-EncodedCommand") + 1] - assert all(c.isalnum() or c in "+/=" for c in encoded) - script = base64.b64decode(encoded).decode("utf-16-le") + args = cast(list[str], captured["args"]) + assert args == ["C:\\Program Files\\uv\\uv.exe", "tool", "upgrade", "pythinker code"] + assert "powershell" not in " ".join(args).lower() + assert "encoded" not in " ".join(args).lower() - assert "Waiting for Pythinker (PID 4242) to exit..." in script - assert "Wait-Process -Id 4242 -Timeout 60" in script - assert "Start-Sleep" not in script - assert "$psi.FileName = 'C:\\Program Files\\uv\\uv.exe'" in script - assert "$psi.Arguments = 'tool upgrade \"pythinker code\"'" in script - assert "Upgrade finished. Press any key to close this window." in script + kwargs = cast(dict[str, object], captured["kwargs"]) + assert cast(int, kwargs["creationflags"]) & 0x00000010 # CREATE_NEW_CONSOLE + assert kwargs["close_fds"] is True async def test_shell_auto_update_toast_shows_new_version_immediately(monkeypatch): @@ -146,18 +157,8 @@ def fake_toast(message: str, **kwargs): assert invalidated == [True] -def test_windows_installer_waits_on_pid_and_cleans_up(monkeypatch, tmp_path): - import base64 - +def test_windows_installer_launches_signed_inno_directly(monkeypatch, tmp_path): monkeypatch.setattr(update, "_is_windows", lambda: True) - monkeypatch.setattr( - update, - "which", - lambda name: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - if name == "powershell" - else None, - ) - monkeypatch.setattr(update.os, "getpid", lambda: 4242) captured: dict[str, object] = {} @@ -172,40 +173,23 @@ def fake_popen(args, **kwargs): installer.write_bytes(b"stub") assert update._spawn_detached_windows_installer(installer) is True - args = captured["args"] - assert isinstance(args, list) - # PowerShell -EncodedCommand sidesteps the cmd+list2cmdline+CommandLineToArgvW - # multi-layer quoting that previously turned the Wait-Process call into a - # string literal PowerShell printed verbatim instead of running. - assert args[0] == "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - assert "-EncodedCommand" in args - kwargs = captured["kwargs"] - assert isinstance(kwargs, dict) - assert kwargs["creationflags"] & 0x00000010 # CREATE_NEW_CONSOLE - - encoded = args[args.index("-EncodedCommand") + 1] - # The encoded payload must contain no characters cmd.exe needs to escape. - assert all(c.isalnum() or c in "+/=" for c in encoded) - - script = base64.b64decode(encoded).decode("utf-16-le") - # Robust wait on this process's own PID instead of a fixed sleep, capped: - assert "Wait-Process -Id 4242" in script - assert "-Timeout 60" in script - assert "Start-Sleep" not in script - assert "timeout /t" not in script - # Silent install + staged-tmpdir cleanup: - assert "/VERYSILENT" in script - assert "/SUPPRESSMSGBOXES" in script - assert "/NORESTART" in script - assert "[System.Diagnostics.ProcessStartInfo]::new()" in script - assert "Remove-Item" in script - assert "-Recurse" in script - # The actual installer + tmpdir paths are embedded as PS string literals: - assert str(installer) in script - assert str(installer.parent) in script - # User-visible bookend messages: - assert "Waiting for Pythinker (PID 4242) to exit..." in script - assert "Installer finished" in script + args = cast(list[str], captured["args"]) + assert args == [ + str(installer), + "/SILENT", + "/NORESTART", + "/CURRENTUSER", + "/CLOSEAPPLICATIONS", + "/NORESTARTAPPLICATIONS", + ] + assert "powershell" not in " ".join(args).lower() + assert "encoded" not in " ".join(args).lower() + assert "/VERYSILENT" not in args + assert "/SUPPRESSMSGBOXES" not in args + + kwargs = cast(dict[str, object], captured["kwargs"]) + assert cast(int, kwargs["creationflags"]) & 0x00000200 # CREATE_NEW_PROCESS_GROUP + assert kwargs["close_fds"] is True def test_update_command_registered(): diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 7c87e3c8..c016ac84 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -5,6 +5,7 @@ from types import SimpleNamespace import pytest +import typer from rich.console import Console from pythinker_code.ui.shell import update @@ -725,6 +726,56 @@ async def fake_unavailable(session, latest_version: str, upgrade_command: list[s assert native_versions == ["999.0.0"] +def test_spawn_detached_windows_upgrade_uses_real_command_not_powershell(monkeypatch): + launched: list[tuple[list[str], dict[str, object]]] = [] + + monkeypatch.setattr(update, "_is_windows", lambda: True) + monkeypatch.setattr(update, "which", lambda name: f"C:\\Tools\\{name}.exe") + + def fake_popen(args, **kwargs): + launched.append((args, kwargs)) + return object() + + monkeypatch.setattr(update.subprocess, "Popen", fake_popen) + + assert update._spawn_detached_windows_upgrade(["uv", "tool", "upgrade", "pythinker-code"]) + assert launched == [ + ( + ["C:\\Tools\\uv.exe", "tool", "upgrade", "pythinker-code"], + {"creationflags": 0x00000010 | 0x00000200, "close_fds": True}, + ) + ] + + +def test_spawn_detached_windows_installer_uses_inno_directly_not_powershell(monkeypatch, tmp_path): + installer = tmp_path / "PythinkerSetup-999.0.0.exe" + installer.write_bytes(b"") + launched: list[tuple[list[str], dict[str, object]]] = [] + + monkeypatch.setattr(update, "_is_windows", lambda: True) + + def fake_popen(args, **kwargs): + launched.append((args, kwargs)) + return object() + + monkeypatch.setattr(update.subprocess, "Popen", fake_popen) + + assert update._spawn_detached_windows_installer(installer) + assert launched == [ + ( + [ + str(installer), + "/SILENT", + "/NORESTART", + "/CURRENTUSER", + "/CLOSEAPPLICATIONS", + "/NORESTARTAPPLICATIONS", + ], + {"creationflags": 0x00000200, "close_fds": True}, + ) + ] + + def test_run_native_installer_detaches_on_windows(monkeypatch, tmp_path): installer = tmp_path / "PythinkerSetup-999.0.0.exe" installer.write_bytes(b"") @@ -741,6 +792,23 @@ def test_run_native_installer_detaches_on_windows(monkeypatch, tmp_path): assert spawned == [installer] +def test_run_native_installer_reports_fallback_spawn_failure(monkeypatch, tmp_path): + installer = tmp_path / "PythinkerSetup-999.0.0.exe" + installer.write_bytes(b"") + + monkeypatch.setattr(update, "_spawn_detached_windows_installer", lambda path: False) + + def fake_popen(*args, **kwargs): + raise OSError("blocked") + + monkeypatch.setattr(update.subprocess, "Popen", fake_popen) + + with pytest.raises(typer.Exit) as excinfo: + update._run_native_installer(installer) + + assert excinfo.value.exit_code == 1 + + def test_version_from_release_payload_parses_v_tag(): assert update._version_from_release_payload({"tag_name": "v1.2.3"}) == "1.2.3" assert update._version_from_release_payload({"tag_name": "pythinker-code-v1.2.3"}) is None diff --git a/web/public/install.ps1 b/web/public/install.ps1 index fa1e6d2f..6e4d2e54 100644 --- a/web/public/install.ps1 +++ b/web/public/install.ps1 @@ -198,8 +198,14 @@ try { OK "Checksum OK" Step "Running Pythinker installer" - $args = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER') - $process = Start-Process -FilePath $installerPath -ArgumentList $args -Wait -PassThru + $installerArgs = @( + '/SILENT', + '/NORESTART', + '/CURRENTUSER', + '/CLOSEAPPLICATIONS', + '/NORESTARTAPPLICATIONS' + ) + $process = Start-Process -FilePath $installerPath -ArgumentList $installerArgs -Wait -PassThru if ($process.ExitCode -ne 0) { Fail "installer exited with code $($process.ExitCode)" }