diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca2b72..88d6ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.4] - 2026-06-14 + +### Fixed + +- **In-app update no longer reports "Updated successfully" while staying on + the old version (#131).** Three independent defects, each sufficient to + cause the symptom, were found and fixed: + 1. **`update.ps1` failed to parse under Windows PowerShell 5.1.** The script + was UTF-8 without a BOM and contained em-dashes (`—`). The updater + launches Windows PowerShell 5.1, which reads a BOM-less `.ps1` as the + ANSI codepage (Windows-1252), not UTF-8. The em-dash inside the + `"Tray exe missing at $trayExe — cannot restart"` string literal + misdecoded — its trailing byte read as a closing quote — so the whole + script failed to parse and silently never ran. `update.ps1` and + `install.ps1` are now pure ASCII, with regression guards. (PowerShell 7, + used in development, defaults to UTF-8 and masked this.) + 2. **False success reporting.** The success sentinel was pre-written before + the updater launched and only cleared on explicit failure. It is now + written by `update.ps1` only *after* a successful extraction, the tray + waits for the updater's heartbeat before quitting (reporting an honest + failure if it never starts), and the success banner is shown only when + the running version actually matches the installed target. + 3. **Stale `dist-info` left the wrong version reported.** Overlay extraction + (`Expand-Archive -Force`) never deletes orphaned files, so the previous + version's `plaud_tools-*.dist-info` survived next to the new one and + `importlib.metadata` resolved the old version. `update.ps1` now prunes + stale `dist-info` after extracting. +- The updater also now launches the helper with `CREATE_BREAKAWAY_FROM_JOB` + (graceful fallback when the job forbids it) so it survives the tray exiting. + +> **Note:** because the in-app updater runs the *currently installed* +> `update.ps1`, users on **v0.3.3 or earlier cannot reach v0.3.4 via the in-app +> button** — the broken script parse-fails the same way. Reinstall with the +> `install.ps1` one-liner (now ASCII-fixed) to land on v0.3.4; updates work +> in-app from v0.3.4 onward. + ## [0.3.3] - 2026-06-13 ### Fixed @@ -1225,7 +1261,8 @@ For full detail see the v0.1.20–v0.1.22 sections below. Headline items: `scripts/plaud_entry.py` wrapper mirrors the existing `plaud_mcp_entry.py` / `plaud_tray_entry.py` pattern. -[Unreleased]: https://github.com/massive-value/plaud-tools/compare/v0.3.3...HEAD +[Unreleased]: https://github.com/massive-value/plaud-tools/compare/v0.3.4...HEAD +[0.3.4]: https://github.com/massive-value/plaud-tools/compare/v0.3.3...v0.3.4 [0.3.3]: https://github.com/massive-value/plaud-tools/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/massive-value/plaud-tools/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/massive-value/plaud-tools/compare/v0.3.0...v0.3.1 diff --git a/pyproject.toml b/pyproject.toml index 2b28a57..639f312 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plaud-tools" -version = "0.3.3" +version = "0.3.4" description = "Python rewrite for Plaud CLI and MCP workflows." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index db5f9dc..86cbcdf 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -4,8 +4,8 @@ # irm https://raw.githubusercontent.com/massive-value/plaud-tools/main/scripts/install.ps1 | iex # # Options: -# -Force — remove any existing install (after shutting down tray + MCP) and reinstall. -# -Repair — alias for -Force; use when files are missing or quarantined. +# -Force - remove any existing install (after shutting down tray + MCP) and reinstall. +# -Repair - alias for -Force; use when files are missing or quarantined. # # Example: # irm .../install.ps1 | iex # normal install @@ -118,7 +118,7 @@ function Get-ZipExtractDestination { $prefix = $roots[0] + '/' $hasChildren = $zip.Entries | Where-Object { $_.FullName -ne $prefix -and $_.FullName.StartsWith($prefix) } if ($hasChildren) { - # Extract to parent — the folder inside the zip becomes $InstallDir. + # Extract to parent - the folder inside the zip becomes $InstallDir. return (Split-Path $InstallDir -Parent) } } @@ -145,9 +145,9 @@ try { throw "Could not find PlaudTools.zip in the latest release assets. Check https://github.com/massive-value/plaud-tools/releases/latest" } - Write-Host " Latest: v$latestVersion — PlaudTools.zip ($([math]::Round($asset.size / 1MB, 1)) MB)" + Write-Host " Latest: v$latestVersion - PlaudTools.zip ($([math]::Round($asset.size / 1MB, 1)) MB)" - # Strip any pre-release suffix (e.g. "0.3.0-rc1" → "0.3.0") before casting + # Strip any pre-release suffix (e.g. "0.3.0-rc1" -> "0.3.0") before casting # to [version] so that numeric comparison is always used and a pre-release tag # is never ranked above or equal to the same numeric release. function Get-NumericVersion { @@ -181,7 +181,7 @@ try { # -Force/-Repair: shut down running processes then wipe the install dir. $switchName = if ($Repair) { '-Repair' } else { '-Force' } Write-Host '' - Write-Host "$switchName specified — shutting down PlaudTools processes..." -ForegroundColor Yellow + Write-Host "$switchName specified - shutting down PlaudTools processes..." -ForegroundColor Yellow # Gracefully stop any running tray process. $trayProcs = Get-Process -Name 'PlaudTools' -ErrorAction SilentlyContinue | Where-Object { @@ -217,7 +217,7 @@ try { # Broken/partial install: directory exists but exe is missing (e.g. Defender quarantine). if (Test-Path $installDir) { Write-Host '' - Write-Host 'Found an incomplete installation (directory present, exe missing) — cleaning up...' -ForegroundColor Yellow + Write-Host 'Found an incomplete installation (directory present, exe missing) - cleaning up...' -ForegroundColor Yellow Remove-Item $installDir -Recurse -Force } @@ -233,7 +233,7 @@ try { # from v0.3.0 onward. # # Verification is FAIL CLOSED (#113): a hash mismatch aborts the install, and - # so does an absent SHA256SUMS asset — an absent asset means a malformed + # so does an absent SHA256SUMS asset - an absent asset means a malformed # release or a tampered asset list, and the download cannot be trusted. $sumsAsset = $release.assets | Where-Object { $_.name -eq 'SHA256SUMS' } | Select-Object -First 1 if (-not $sumsAsset) { @@ -249,7 +249,7 @@ try { $actualHash = (Get-FileHash -Path $zipTemp -Algorithm SHA256).Hash.ToUpper() if ($actualHash -ne $expectedHash) { throw ( - "SHA256 mismatch — the downloaded zip may be corrupt or tampered.`n" + + "SHA256 mismatch - the downloaded zip may be corrupt or tampered.`n" + " Expected: $expectedHash`n" + " Actual: $actualHash`n" + 'Please retry; if the mismatch persists report it at https://github.com/massive-value/plaud-tools/issues' @@ -307,7 +307,7 @@ try { $completionsDir = Join-Path $installDir 'completions' $ps1File = Join-Path $completionsDir 'plaud-tools.ps1' if (Test-Path $ps1File) { - # Regex anchored to the install dir — only our sourcing lines are touched + # Regex anchored to the install dir - only our sourcing lines are touched $escapedDir = [regex]::Escape($completionsDir) $stalePattern = "^\. `"$($escapedDir -replace '\\\\','[/\\\\]')[/\\\\]plaud[^`"]*\.ps1`"" $sourceLine = ". `"$ps1File`"" @@ -345,7 +345,7 @@ try { # 4c. Register autostart in HKCU Run key (idempotent) # # The value name MUST match plaud_tools.tray.setup._AUTOSTART_NAME ("Plaud - # Tools", with a space) — that is what the tray reads in _autostart_enabled + # Tools", with a space) - that is what the tray reads in _autostart_enabled # and writes in _set_autostart. Earlier revisions of this script wrote # "PlaudTools" (no space); we strip that stale name on every run so users # who upgraded through the buggy version do not end up with two Run keys diff --git a/src/plaud_tools/ps1_templates.py b/src/plaud_tools/ps1_templates.py index 10ee8ba..79a077b 100644 --- a/src/plaud_tools/ps1_templates.py +++ b/src/plaud_tools/ps1_templates.py @@ -69,6 +69,7 @@ def render_update_ps1( zip_path: str, extract_dir: str, dispatcher_path: str | None = None, + new_version: str | None = None, ) -> str: """Return a PS1 dispatcher that calls the bundled update.ps1 with the given args. @@ -91,6 +92,14 @@ def render_update_ps1( Absolute path to the dispatcher PS1 itself. Passed to update.ps1 as ``-DispatcherPath`` so update.ps1 can delete it after a successful run. Optional for backwards compatibility with older callers. + new_version: + The version being installed (e.g. ``"0.3.3"``). Passed to update.ps1 + as ``-NewVersion`` so it can (a) prune stale ``plaud_tools-*.dist-info`` + directories left behind by the overlay extraction — otherwise + ``importlib.metadata.version`` resolves the OLD version and the tray + keeps reporting the pre-update version — and (b) write the + ``plaud_just_updated.txt`` success sentinel only AFTER a successful + extraction. Optional for backwards compatibility with older callers. """ scripts = scripts_dir() ps1 = scripts / "update.ps1" @@ -108,6 +117,9 @@ def render_update_ps1( if dispatcher_path: safe_dispatcher = _ps_escape(dispatcher_path) line += f" -DispatcherPath '{safe_dispatcher}'" + if new_version: + safe_version = _ps_escape(new_version) + line += f" -NewVersion '{safe_version}'" return line + "\n" diff --git a/src/plaud_tools/scripts/update.ps1 b/src/plaud_tools/scripts/update.ps1 index b1f06df..0c3b074 100644 --- a/src/plaud_tools/scripts/update.ps1 +++ b/src/plaud_tools/scripts/update.ps1 @@ -17,7 +17,7 @@ The tray reads this on next launch and surfaces the failure to the user. The tray is restarted in a `finally` block, so the user is never stranded - without a tray icon — even when the update itself fails. + without a tray icon - even when the update itself fails. .PARAMETER TrayPid PID of the running PlaudTools.exe (tray app) to wait for. @@ -35,8 +35,14 @@ .PARAMETER DispatcherPath Optional path to the %TEMP% dispatcher PS1 that invoked this script. Deleted after a successful run so %TEMP% does not accumulate stale .ps1 files. The - bundled update.ps1 itself is NEVER deleted — earlier versions self-deleted + bundled update.ps1 itself is NEVER deleted - earlier versions self-deleted it, which broke subsequent in-app updates. + +.PARAMETER NewVersion + The version being installed (e.g. "0.3.3"). Used to (a) prune stale + plaud_tools-*.dist-info directories left behind by the overlay extraction + so importlib.metadata resolves the NEW version, and (b) write the + plaud_just_updated.txt success sentinel only AFTER a successful extraction. #> param( [Parameter(Mandatory)] @@ -53,14 +59,16 @@ param( [string]$DispatcherPath = "", - [string]$SentinelPath = "" + [string]$SentinelPath = "", + + [string]$NewVersion = "" ) Set-StrictMode -Off $ErrorActionPreference = 'Continue' # --------------------------------------------------------------------------- -# Diagnostics — transcript log + structured failure sentinel +# Diagnostics - transcript log + structured failure sentinel # --------------------------------------------------------------------------- $logPath = Join-Path $env:TEMP "plaud_update_$TrayPid.log" @@ -94,7 +102,7 @@ function Write-FailureSentinel { } | ConvertTo-Json -Compress Set-Content -Path $failSentinel -Value $payload -Encoding UTF8 -ErrorAction Stop } catch { - # Best effort — the reason is still in the transcript log. + # Best effort - the reason is still in the transcript log. } # A failed update must not leave the success sentinel behind, otherwise the # restarted (still-old) tray would falsely announce a successful upgrade. @@ -139,19 +147,43 @@ function Get-ZipExtractDestination { } } +# --------------------------------------------------------------------------- +# Remove orphaned plaud_tools-*.dist-info directories left behind by the +# overlay extraction (Expand-Archive -Force overwrites matching paths but never +# deletes files absent from the zip). If a previous version's dist-info +# survives next to the new one, importlib.metadata.version("plaud-tools") +# resolves the OLD version and the tray keeps reporting the pre-update version +# (and re-offering the same "update available"). Keep only $NewVersion's +# dist-info. No-op when $NewVersion is empty (older callers). +# --------------------------------------------------------------------------- + +function Remove-StaleDistInfo { + param([string]$InstallDir, [string]$NewVersion) + + if (-not $NewVersion) { return } + $keep = "plaud_tools-$NewVersion.dist-info" + $stale = Get-ChildItem -Path $InstallDir -Recurse -Directory ` + -Filter 'plaud_tools-*.dist-info' -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne $keep } + foreach ($d in $stale) { + Write-Host "Removing stale dist-info: $($d.FullName)" + Remove-Item -LiteralPath $d.FullName -Recurse -Force -ErrorAction SilentlyContinue + } +} + # --------------------------------------------------------------------------- # Stop ALL processes whose Path is under $InstallDir (plaud-mcp, ffmpeg, any # other child processes), and confirm they stay dead. Returns $true when no # scoped process has been alive for $StableMs milliseconds. Returns $false if # a supervisor keeps respawning processes after $MaxAttempts attempts. # -# This is the bug the v0.2.0 → 0.2.1 update path hit: when Claude Desktop +# This is the bug the v0.2.0 -> 0.2.1 update path hit: when Claude Desktop # launches plaud-mcp, killing the process just causes Claude to relaunch it # almost immediately, and the respawned exe keeps mcp\_internal\*.dll locked, # causing Expand-Archive to throw and the script to bail. # # Using path-based discovery (rather than name-based) also catches ffmpeg and -# any other child processes that plaud-mcp may have spawned — Stop-Process on +# any other child processes that plaud-mcp may have spawned - Stop-Process on # the parent does NOT kill children on Windows. # --------------------------------------------------------------------------- @@ -173,7 +205,7 @@ function Stop-PlaudMcpScoped { for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { $procs = & $findProcs if (-not $procs) { - # Nothing alive — wait $StableMs to make sure nobody respawns it. + # Nothing alive - wait $StableMs to make sure nobody respawns it. Start-Sleep -Milliseconds $StableMs if (-not (& $findProcs)) { Write-Host "All install-dir processes confirmed stopped (attempt $attempt)" @@ -246,7 +278,7 @@ try { Write-Host "Extraction complete" # 4. Cleanup: remove the zip and the %TEMP% dispatcher. The bundled - # update.ps1 (this very script) is NOT deleted — earlier versions + # update.ps1 (this very script) is NOT deleted - earlier versions # self-deleted via $MyInvocation.MyCommand.Path, which broke subsequent # in-app updates because the script vanished after the first successful # upgrade. @@ -255,6 +287,19 @@ try { Remove-Item $DispatcherPath -ErrorAction SilentlyContinue } + # 5. Prune stale dist-info so the restarted tray resolves the NEW version. + Remove-StaleDistInfo -InstallDir $InstallDir -NewVersion $NewVersion + + # 6. Write the success sentinel ONLY now that extraction has actually + # succeeded. (Earlier the tray pre-wrote this before launching the + # updater, so a silently-failed update - e.g. the updater process being + # killed before it ran - still left the sentinel behind and the old tray + # falsely announced success. The tray additionally verifies the running + # version matches before showing the success banner.) + if ($NewVersion) { + Set-Content -Path $successSentinel -Value $NewVersion -Encoding UTF8 -ErrorAction SilentlyContinue + } + Write-Host "Update succeeded" } catch { @@ -270,7 +315,7 @@ catch { finally { # 5. Always restart the tray so the user is not stranded after a failed # update. If the new tray bundle is in place, we get the new version; - # if extraction failed, we get the old one back — better than nothing. + # if extraction failed, we get the old one back - better than nothing. $trayExe = Join-Path $InstallDir 'PlaudTools.exe' if (Test-Path $trayExe) { try { @@ -280,7 +325,7 @@ finally { Write-Host "Could not restart tray: $($_.Exception.Message)" } } else { - Write-Host "Tray exe missing at $trayExe — cannot restart" + Write-Host "Tray exe missing at $trayExe - cannot restart" } try { Stop-Transcript | Out-Null } catch {} diff --git a/src/plaud_tools/tray/app.py b/src/plaud_tools/tray/app.py index 423dbde..3e25abe 100644 --- a/src/plaud_tools/tray/app.py +++ b/src/plaud_tools/tray/app.py @@ -409,22 +409,43 @@ def _show_update_failure(r: str = reason, lp: str = log_path) -> None: except Exception: logging.warning("Could not read update failure sentinel", exc_info=True) - # If relaunched after an in-app update, open HomeWindow with a success message. + # If relaunched after an in-app update, open HomeWindow with a success + # message — but ONLY if the running version actually matches the version + # the update claimed to install. The sentinel proves the updater ran its + # success path; this guard additionally proves the new bits are actually + # the ones now executing. Without it, a silently-failed or partially + # applied update (stale dist-info, killed updater, etc.) would falsely + # announce success while the tray is still on the old version. sentinel = Path(tempfile.gettempdir()) / "plaud_just_updated.txt" if sentinel.exists(): try: updated_to = sentinel.read_text(encoding="utf-8").strip() sentinel.unlink(missing_ok=True) + version_matches = updated_to == APP_VERSION + if not version_matches: + logging.warning( + "Update sentinel claims v%s but running version is v%s — " + "the update did not take effect; suppressing success banner.", + updated_to, + APP_VERSION, + ) if self._session and self._home_win: - def _show_update_success(v: str = updated_to) -> None: + def _show_update_status(v: str = updated_to, ok: bool = version_matches) -> None: # Re-check for mypy: outer guard `if self._home_win` doesn't # narrow inside a nested def captured by a closure. if self._home_win is not None: self._home_win.show() - self._home_win._set_status(f"Updated to v{v} successfully.", ok=True) - - root.after(500, _show_update_success) + if ok: + self._home_win._set_status(f"Updated to v{v} successfully.", ok=True) + else: + self._home_win._set_status( + f"Update to v{v} did not complete — still on " + f"v{APP_VERSION}. See logs for details.", + ok=False, + ) + + root.after(500, _show_update_status) except Exception: logging.warning("Could not read update sentinel", exc_info=True) diff --git a/src/plaud_tools/tray/updater.py b/src/plaud_tools/tray/updater.py index 667695c..b2bf7b8 100644 --- a/src/plaud_tools/tray/updater.py +++ b/src/plaud_tools/tray/updater.py @@ -46,6 +46,73 @@ } ) +# How long the tray waits for update.ps1's heartbeat file before giving up and +# reporting failure (instead of quitting into a half-applied update). The +# updater writes the heartbeat as its very first action, so this only needs to +# cover PowerShell cold-start (slow under Defender/enterprise scanning). +_UPDATER_HEARTBEAT_TIMEOUT_S: float = 20.0 + +# subprocess.CREATE_BREAKAWAY_FROM_JOB detaches the child from the tray's Job +# Object so it survives the tray exiting. Referenced defensively in case a +# future Python drops the attribute. +_CREATE_BREAKAWAY_FROM_JOB: int = getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0x01000000) + + +def _launch_updater(ps_path: Path) -> subprocess.Popen[bytes]: + """Launch the bundled update dispatcher as a detached PowerShell process. + + The child MUST outlive the tray: the tray quits moments after this returns, + and update.ps1 then waits for the tray to exit before replacing its files. + The tray runs inside a Windows Job Object; without breaking away, a + kill-on-close job kills the child the instant the tray exits — before + update.ps1 runs a single line. We therefore request + ``CREATE_BREAKAWAY_FROM_JOB``. If the job forbids breakaway, ``CreateProcess`` + fails with ``ERROR_ACCESS_DENIED`` (raised as :exc:`OSError`); we retry + without the flag so the update is no worse off than before. + + ``CREATE_NO_WINDOW`` (not ``DETACHED_PROCESS``) plus explicit ``DEVNULL`` + handles is retained: ``DETACHED_PROCESS`` from a no-console frozen app passes + NULL stdio handles to the child, which crashes PowerShell before any script + code runs. + """ + args = [ + _POWERSHELL_EXE, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + str(ps_path), + ] + base_flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP + cwd = tempfile.gettempdir() + try: + return subprocess.Popen( + args, + creationflags=base_flags | _CREATE_BREAKAWAY_FROM_JOB, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=cwd, + ) + except OSError: + # Job forbids breakaway (no JOB_OBJECT_LIMIT_BREAKAWAY_OK). Fall back to + # an in-job launch; the heartbeat gate still ensures honest reporting. + logging.warning( + "in-app update: CREATE_BREAKAWAY_FROM_JOB denied; launching updater in-job", + exc_info=True, + ) + return subprocess.Popen( + args, + creationflags=base_flags, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=cwd, + ) + def _check_download_host(url: str) -> None: """Raise :exc:`ValueError` if *url* does not parse to an allowed update host. @@ -384,22 +451,30 @@ def _on_error(err: Exception) -> None: install_dir = InstallLayout.detect().install_root or Path(sys.executable).parent tray_pid = os.getpid() - sentinel = Path(tempfile.gettempdir()) / "plaud_just_updated.txt" fail_sentinel = Path(tempfile.gettempdir()) / "plaud_update_failed.txt" ps_path = Path(tempfile.gettempdir()) / f"plaud_update_{tray_pid}.ps1" + # Heartbeat update.ps1 writes the instant it starts running. We gate + # the tray's exit on this file's appearance (see below). + alive_path = Path(tempfile.gettempdir()) / f"plaud_update_{tray_pid}.alive.txt" + alive_path.unlink(missing_ok=True) # clear any stale heartbeat from a prior run update_info = self._app._update_info new_version = update_info[0] if update_info else "unknown" + # NOTE: the success sentinel (plaud_just_updated.txt) is intentionally + # NOT written here. update.ps1 writes it only AFTER a successful + # extraction. Pre-writing it meant a silently-failed update (e.g. the + # updater process being killed before it ran) still left the sentinel + # behind, and the restarted old tray falsely announced success. ps_content = render_update_ps1( tray_pid=tray_pid, install_dir=str(install_dir), zip_path=str(zip_path), extract_dir=str(install_dir.parent), dispatcher_path=str(ps_path), + new_version=new_version, ) ps_path.write_text(ps_content, encoding="utf-8") - sentinel.write_text(new_version, encoding="utf-8") logging.info( "in-app update: launching updater for v%s (tray_pid=%s zip=%s dispatcher=%s)", @@ -409,63 +484,73 @@ def _on_error(err: Exception) -> None: ps_path, ) - # CREATE_NO_WINDOW (not DETACHED_PROCESS) + explicit DEVNULL handles: - # DETACHED_PROCESS from a no-console frozen app passes NULL stdio - # handles to the child, which causes PowerShell to crash before any - # script code runs. CREATE_NO_WINDOW suppresses the window without - # detaching, and DEVNULL handles are always valid. - proc = subprocess.Popen( - [ - _POWERSHELL_EXE, - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-WindowStyle", - "Hidden", - "-File", - str(ps_path), - ], - creationflags=subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - cwd=tempfile.gettempdir(), - ) + proc = _launch_updater(ps_path) logging.info("in-app update: PowerShell updater launched (pid=%s)", proc.pid) - # Sanity-check: if PowerShell exits within 0.5 s the script almost - # certainly never ran (invalid handles, policy block, etc.). - _time.sleep(0.5) - rc = proc.poll() - if rc is not None: - import json as _json - - fail_msg = ( - f"PowerShell exited immediately with code {rc} — " - "the update script may have been blocked by an enterprise " - f"policy (AppLocker / WDAC). Dispatcher: {ps_path}" - ) - logging.error("in-app update: %s", fail_msg) - fail_sentinel.write_text( - _json.dumps( - { - "reason": fail_msg, - "log": str(ps_path), - "time": "", - "tray_pid": tray_pid, - } - ), - encoding="utf-8", + # Gate the tray exit on the updater actually RUNNING. The tray is in + # a Windows Job Object; if it quits before the child PowerShell is + # established, a kill-on-close job tears the still-cold-starting + # child down before update.ps1 runs a single line (the root cause of + # "Updated successfully" while staying on the old version). We launch + # with CREATE_BREAKAWAY_FROM_JOB (see _launch_updater) so the child + # escapes the job when permitted, AND we wait here for update.ps1's + # heartbeat file before quitting — so we only exit once the updater + # is confirmed alive, and we report honest failure otherwise. + deadline = _time.monotonic() + _UPDATER_HEARTBEAT_TIMEOUT_S + while _time.monotonic() < deadline: + if alive_path.exists(): + logging.info("in-app update: updater heartbeat seen; quitting tray to hand off") + if self._root: + self._root.after(0, self._app._quit) + return + rc = proc.poll() + if rc is not None: + # Updater exited before writing a heartbeat → it never ran. + self._record_launch_failure(fail_sentinel, ps_path, tray_pid, rc) + _on_error( + RuntimeError( + f"The updater exited (code {rc}) before it could start. " + "Please try again, or download the update from the website." + ) + ) + return + _time.sleep(0.2) + + # Timed out waiting for the heartbeat while the process is still + # alive — PowerShell is wedged or blocked. Do NOT quit the tray + # (the user would be stranded mid-update); surface a failure. + logging.error("in-app update: no updater heartbeat after %ss", _UPDATER_HEARTBEAT_TIMEOUT_S) + self._record_launch_failure(fail_sentinel, ps_path, tray_pid, None) + _on_error( + RuntimeError( + "The updater did not start within the expected time. " + "Please try again, or download the update from the website." ) - sentinel.unlink(missing_ok=True) - - if self._root: - self._root.after(0, self._app._quit) + ) except Exception as exc: _on_error(exc) + @staticmethod + def _record_launch_failure(fail_sentinel: Path, ps_path: Path, tray_pid: int, rc: int | None) -> None: + """Write the failure sentinel so the next tray launch can surface the cause.""" + import json as _json + + if rc is not None: + reason = ( + f"The updater exited with code {rc} before it could run — it may " + "have been blocked by an enterprise policy (AppLocker / WDAC)." + ) + else: + reason = "The updater did not start within the expected time." + try: + fail_sentinel.write_text( + _json.dumps({"reason": reason, "log": str(ps_path), "time": "", "tray_pid": tray_pid}), + encoding="utf-8", + ) + except Exception: + logging.warning("in-app update: could not write failure sentinel", exc_info=True) + __all__ = [ "GITHUB_REPO", diff --git a/tests/test_install_ps1.py b/tests/test_install_ps1.py index 2b5b6b7..7b89712 100644 --- a/tests/test_install_ps1.py +++ b/tests/test_install_ps1.py @@ -30,6 +30,19 @@ def _read_install_ps1() -> str: # --------------------------------------------------------------------------- +def test_install_ps1_is_ascii_only(): + """install.ps1 MUST be pure ASCII. + + A user who saves it to disk and runs it under Windows PowerShell 5.1 + (BOM-less → ANSI codepage) hits the same misdecode that broke update.ps1: + a non-ASCII byte in a string literal can produce a stray quote and a parse + error. Keep it 7-bit clean (issue #131). + """ + raw = INSTALL_PS1.read_bytes() + offenders = [(i, b) for i, b in enumerate(raw) if b > 0x7F] + assert not offenders, f"non-ASCII bytes in install.ps1 at offsets {offenders[:5]}" + + def test_install_ps1_declares_force_switch(): content = _read_install_ps1() assert "[switch]$Force" in content diff --git a/tests/test_ps1_templates.py b/tests/test_ps1_templates.py index d7bc858..3489934 100644 --- a/tests/test_ps1_templates.py +++ b/tests/test_ps1_templates.py @@ -162,6 +162,58 @@ def test_update_ps1_accepts_dispatcher_path_param(): assert "DispatcherPath" in content +def test_update_ps1_accepts_new_version_param(): + content = (scripts_dir() / "update.ps1").read_text(encoding="utf-8") + assert "NewVersion" in content + + +def test_update_ps1_is_ascii_only(): + """update.ps1 MUST be pure ASCII. + + The updater launches Windows PowerShell 5.1, which reads a BOM-less .ps1 + as the system ANSI codepage (Windows-1252), NOT UTF-8. A non-ASCII byte + inside a string literal (e.g. an em-dash) misdecodes — the 0x94 trailing + byte becomes a stray closing quote — and the whole script fails to parse, + so update.ps1 silently never runs and the in-app update appears to do + nothing. Keep this file 7-bit clean (issue #131). + """ + raw = (scripts_dir() / "update.ps1").read_bytes() + offenders = [(i, b) for i, b in enumerate(raw) if b > 0x7F] + assert not offenders, f"non-ASCII bytes in update.ps1 at offsets {offenders[:5]}" + + +def test_uninstall_ps1_is_ascii_only(): + """uninstall.ps1 runs under the same Windows PowerShell 5.1 — keep it ASCII (issue #131).""" + raw = (scripts_dir() / "uninstall.ps1").read_bytes() + offenders = [(i, b) for i, b in enumerate(raw) if b > 0x7F] + assert not offenders, f"non-ASCII bytes in uninstall.ps1 at offsets {offenders[:5]}" + + +def test_update_ps1_prunes_stale_dist_info(): + """Overlay extraction (Expand-Archive -Force) leaves the old version's + plaud_tools-*.dist-info behind, so importlib.metadata resolves the OLD + version. update.ps1 must prune the stale dist-info after extracting. + """ + content = (scripts_dir() / "update.ps1").read_text(encoding="utf-8") + assert "Remove-StaleDistInfo" in content + assert "plaud_tools-*.dist-info" in content + + +def test_update_ps1_writes_success_sentinel_on_success(): + """The success sentinel must be written by update.ps1 AFTER a successful + extraction (inside the try, before the catch), not pre-written by the tray. + Otherwise a silently-failed update leaves the sentinel behind and the old + tray falsely announces success. + """ + content = (scripts_dir() / "update.ps1").read_text(encoding="utf-8") + # Sentinel is written via Set-Content to $successSentinel, and must sit in + # the success path — before the "Update succeeded" marker. + assert "Set-Content -Path $successSentinel" in content + assert content.index("Set-Content -Path $successSentinel") < content.index( + 'Write-Host "Update succeeded"' + ) + + # --------------------------------------------------------------------------- # uninstall.ps1 content — standalone script validation # --------------------------------------------------------------------------- @@ -348,6 +400,38 @@ def test_render_update_ps1_escapes_single_quote_in_dispatcher_path(): assert "It''s_dispatch.ps1" in result +def test_render_update_ps1_omits_new_version_when_not_provided(): + result = render_update_ps1( + tray_pid=1, + install_dir=r"C:\Programs\PlaudTools", + zip_path=r"C:\Temp\update.zip", + extract_dir=r"C:\Programs", + ) + assert "-NewVersion" not in result + + +def test_render_update_ps1_includes_new_version_when_provided(): + result = render_update_ps1( + tray_pid=1, + install_dir=r"C:\Programs\PlaudTools", + zip_path=r"C:\Temp\update.zip", + extract_dir=r"C:\Programs", + new_version="0.3.3", + ) + assert "-NewVersion '0.3.3'" in result + + +def test_render_update_ps1_escapes_single_quote_in_new_version(): + result = render_update_ps1( + tray_pid=1, + install_dir=r"C:\Programs\PlaudTools", + zip_path=r"C:\Temp\update.zip", + extract_dir=r"C:\Programs", + new_version="1.0'rc", + ) + assert "1.0''rc" in result + + # --------------------------------------------------------------------------- # render_uninstall_ps1 — dispatcher string content tests # --------------------------------------------------------------------------- diff --git a/tests/test_updater_launch.py b/tests/test_updater_launch.py new file mode 100644 index 0000000..7b508cb --- /dev/null +++ b/tests/test_updater_launch.py @@ -0,0 +1,88 @@ +"""Tests for the updater's detached-launch behaviour (job breakaway + fallback). + +The in-app updater MUST outlive the tray process. The tray runs inside a +Windows Job Object; without breaking away, a kill-on-close job kills the child +PowerShell the instant the tray exits — before update.ps1 runs a line. These +tests pin the launch flags and the graceful fallback when the job forbids +breakaway (CreateProcess raises OSError / ERROR_ACCESS_DENIED). +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from plaud_tools.tray import updater +from plaud_tools.tray.updater import _CREATE_BREAKAWAY_FROM_JOB, _launch_updater + +# _launch_updater uses Windows-only creation flags (CREATE_NO_WINDOW etc.); the +# in-app updater only ever runs on the frozen Windows bundle. Skip elsewhere. +pytestmark = pytest.mark.skipif( + sys.platform != "win32", reason="updater launch is Windows-only (CREATE_NO_WINDOW)" +) + + +class _FakeProc: + pid = 4321 + + +def test_launch_updater_requests_breakaway(monkeypatch): + """When the job permits breakaway, the child is launched WITH the + CREATE_BREAKAWAY_FROM_JOB flag so it escapes the tray's job object. + """ + seen: dict[str, int] = {} + + def fake_popen(args, creationflags=0, **kwargs): + seen["flags"] = creationflags + return _FakeProc() + + monkeypatch.setattr(updater.subprocess, "Popen", fake_popen) + + proc = _launch_updater(Path("C:/Temp/plaud_update_1.ps1")) + + assert proc.pid == 4321 + assert seen["flags"] & _CREATE_BREAKAWAY_FROM_JOB, "breakaway flag must be set" + assert seen["flags"] & subprocess.CREATE_NO_WINDOW + assert seen["flags"] & subprocess.CREATE_NEW_PROCESS_GROUP + + +def test_launch_updater_falls_back_without_breakaway(monkeypatch): + """When the job forbids breakaway (CreateProcess raises OSError), the + updater retries WITHOUT the flag rather than failing the update outright. + """ + calls: list[int] = [] + + def fake_popen(args, creationflags=0, **kwargs): + calls.append(creationflags) + if len(calls) == 1: + # First attempt (with breakaway) is denied by the job. + raise OSError(5, "Access is denied") + return _FakeProc() + + monkeypatch.setattr(updater.subprocess, "Popen", fake_popen) + + proc = _launch_updater(Path("C:/Temp/plaud_update_1.ps1")) + + assert proc.pid == 4321 + assert len(calls) == 2, "must retry exactly once on breakaway denial" + assert calls[0] & _CREATE_BREAKAWAY_FROM_JOB, "first attempt requests breakaway" + assert not (calls[1] & _CREATE_BREAKAWAY_FROM_JOB), "fallback drops breakaway" + assert calls[1] & subprocess.CREATE_NO_WINDOW + assert calls[1] & subprocess.CREATE_NEW_PROCESS_GROUP + + +def test_launch_updater_propagates_non_breakaway_errors(monkeypatch): + """A failure on the fallback launch (no breakaway) is a genuine error and + must propagate — we do not silently swallow it. + """ + + def fake_popen(args, creationflags=0, **kwargs): + raise OSError(2, "The system cannot find the file specified") + + monkeypatch.setattr(updater.subprocess, "Popen", fake_popen) + + with pytest.raises(OSError): + _launch_updater(Path("C:/Temp/plaud_update_1.ps1"))