From c4c58bdade24c7646fb06f60b09b8d00e5b9a26a Mon Sep 17 00:00:00 2001 From: Kadin Bullock Date: Mon, 15 Jun 2026 07:39:30 -0600 Subject: [PATCH] fix(updater): tolerate UTF-8 BOM in update sentinels (false "did not complete") (v0.4.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a SUCCESSFUL in-app update, the tray showed "Update to v0.4.0 did not complete — still on v0.4.0". Diagnosed from a user's tray.log + the on-disk sentinel bytes. update.ps1 writes plaud_just_updated.txt via Windows PowerShell 5.1 `Set-Content -Encoding UTF8`, which prepends a UTF-8 BOM (EF BB BF). The tray read it with encoding="utf-8" and .strip() — but str.strip() does NOT remove U+FEFF (it is not whitespace), so updated_to became "0.4.0" and the `updated_to == APP_VERSION` check failed despite a successful update. The BOM also broke json.loads of the failure sentinel, silently swallowing real failure reports. Same family as the v0.3.4 PowerShell 5.1 encoding bug. - Reader (tray/app.py): read both sentinels with utf-8-sig (strips BOM). This is the load-bearing fix — the reader is always the landing version, so any update onto a fixed build reports correctly regardless of source version. - Writer (update.ps1): write all sentinels BOM-less via [System.IO.File]::WriteAllText(..., UTF8Encoding($false)). Validated under real Windows PowerShell 5.1: emits no BOM, script parses with 0 errors. Tests: BOM round-trip for both sentinels + source guard pinning utf-8-sig; PS1 guard that no active line uses BOM-producing -Encoding UTF8. 812 passed, ruff + format + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 25 ++++++++++++++++- pyproject.toml | 2 +- src/plaud_tools/scripts/update.ps1 | 27 ++++++++++++++---- src/plaud_tools/tray/app.py | 16 +++++++++-- tests/test_ps1_templates.py | 25 ++++++++++++++--- tests/test_tray_first_run.py | 45 ++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd24772..cac3626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.1] - 2026-06-15 + +### Fixed + +- **In-app updater falsely reported "Update to vX did not complete — still on + vX" after a successful update.** The updater writes the + `plaud_just_updated.txt` success sentinel via Windows PowerShell 5.1 + `Set-Content -Encoding UTF8`, which prepends a UTF-8 BOM (`EF BB BF`). The + tray read it with `encoding="utf-8"` and `.strip()` — but `str.strip()` does + not remove U+FEFF (it is not whitespace), so the version became `"0.4.0"` + and the `updated_to == APP_VERSION` check failed even though the update had + succeeded. The BOM also broke `json.loads` of the failure sentinel, silently + swallowing genuine failure reports. Same root-cause family as the v0.3.4 + PowerShell 5.1 encoding bug, relocated to the sentinel files. + - Reader: the tray now reads both sentinels with `utf-8-sig`, which strips a + leading BOM. This is the load-bearing fix — the reader is always the + landing version, so any update onto v0.4.1+ reports correctly regardless of + which (BOM-writing) version it came from. + - Writer: `update.ps1` now writes all sentinels (success, failure, heartbeat) + BOM-less via `[System.IO.File]::WriteAllText(..., UTF8Encoding($false))`, + validated to emit no BOM and to parse cleanly under Windows PowerShell 5.1. + ## [0.4.0] - 2026-06-15 ### Added @@ -1286,7 +1308,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.4.0...HEAD +[Unreleased]: https://github.com/massive-value/plaud-tools/compare/v0.4.1...HEAD +[0.4.1]: https://github.com/massive-value/plaud-tools/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/massive-value/plaud-tools/compare/v0.3.4...v0.4.0 [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 diff --git a/pyproject.toml b/pyproject.toml index 16737e2..0a76559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plaud-tools" -version = "0.4.0" +version = "0.4.1" description = "Python rewrite for Plaud CLI and MCP workflows." readme = "README.md" requires-python = ">=3.11" diff --git a/src/plaud_tools/scripts/update.ps1 b/src/plaud_tools/scripts/update.ps1 index 0c3b074..9ae1f9a 100644 --- a/src/plaud_tools/scripts/update.ps1 +++ b/src/plaud_tools/scripts/update.ps1 @@ -75,11 +75,28 @@ $logPath = Join-Path $env:TEMP "plaud_update_$TrayPid.log" $failSentinel = Join-Path $env:TEMP "plaud_update_failed.txt" $successSentinel = Join-Path $env:TEMP "plaud_just_updated.txt" +# Write text as UTF-8 WITHOUT a byte-order mark. +# +# Windows PowerShell 5.1 (what the updater launches) treats `-Encoding UTF8` as +# "UTF-8 WITH BOM" and prepends EF BB BF. The tray reads plaud_just_updated.txt +# and compares its contents to the running version; the leading U+FEFF made an +# otherwise-correct "0.4.0" mismatch "0.4.0", so a successful update was falsely +# reported as "did not complete". It also broke json.loads of the failure +# sentinel. .NET's UTF8Encoding($false) writes BOM-less UTF-8 on every PS +# version. Errors are swallowed to preserve the prior best-effort semantics. +function Write-NoBom { + param([string]$Path, [string]$Value) + try { + [System.IO.File]::WriteAllText($Path, $Value, (New-Object System.Text.UTF8Encoding($false))) + } catch { + # Best effort - the equivalent information is still in the transcript log. + } +} + # Heartbeat: written before Start-Transcript so we can tell whether the script # reached PowerShell at all (vs. PowerShell crashing before running any code). -Set-Content -Path "$env:TEMP\plaud_update_$TrayPid.alive.txt" ` - -Value "update.ps1 reached at $(Get-Date -Format 'o')" ` - -Encoding UTF8 -ErrorAction SilentlyContinue +Write-NoBom -Path "$env:TEMP\plaud_update_$TrayPid.alive.txt" ` + -Value "update.ps1 reached at $(Get-Date -Format 'o')" # Wipe any stale failure sentinel from a previous run so we never surface an # old failure on top of a successful update. @@ -100,7 +117,7 @@ function Write-FailureSentinel { time = (Get-Date).ToString('o') tray_pid = $TrayPid } | ConvertTo-Json -Compress - Set-Content -Path $failSentinel -Value $payload -Encoding UTF8 -ErrorAction Stop + Write-NoBom -Path $failSentinel -Value $payload } catch { # Best effort - the reason is still in the transcript log. } @@ -297,7 +314,7 @@ try { # 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-NoBom -Path $successSentinel -Value $NewVersion } Write-Host "Update succeeded" diff --git a/src/plaud_tools/tray/app.py b/src/plaud_tools/tray/app.py index 3e25abe..26ba07c 100644 --- a/src/plaud_tools/tray/app.py +++ b/src/plaud_tools/tray/app.py @@ -387,7 +387,12 @@ def _run(self) -> None: try: import json as _json - payload = _json.loads(fail_sentinel.read_text(encoding="utf-8")) + # utf-8-sig (not utf-8) so a leading BOM is stripped: update.ps1 + # writes the sentinel via PowerShell 5.1 `Set-Content -Encoding + # UTF8`, which emits a UTF-8 BOM. json.loads rejects a leading + # BOM ("Unexpected UTF-8 BOM"), which would otherwise silently + # swallow a real failure report. + payload = _json.loads(fail_sentinel.read_text(encoding="utf-8-sig")) fail_sentinel.unlink(missing_ok=True) reason = payload.get("reason", "Update failed for an unknown reason.") log_path = payload.get("log", "") @@ -419,7 +424,14 @@ def _show_update_failure(r: str = reason, lp: str = log_path) -> None: sentinel = Path(tempfile.gettempdir()) / "plaud_just_updated.txt" if sentinel.exists(): try: - updated_to = sentinel.read_text(encoding="utf-8").strip() + # utf-8-sig (not utf-8) so a leading BOM is stripped before the + # comparison. update.ps1 writes this sentinel via PowerShell 5.1 + # `Set-Content -Encoding UTF8`, which emits a UTF-8 BOM (EF BB BF). + # str.strip() does NOT remove U+FEFF (it is not whitespace), so a + # plain utf-8 read left `updated_to` as "0.4.0", which never + # equals APP_VERSION ("0.4.0") — the tray then falsely reported a + # successful update as "did not complete". See v0.4.1 fix. + updated_to = sentinel.read_text(encoding="utf-8-sig").strip() sentinel.unlink(missing_ok=True) version_matches = updated_to == APP_VERSION if not version_matches: diff --git a/tests/test_ps1_templates.py b/tests/test_ps1_templates.py index 3489934..1c82919 100644 --- a/tests/test_ps1_templates.py +++ b/tests/test_ps1_templates.py @@ -206,14 +206,31 @@ def test_update_ps1_writes_success_sentinel_on_success(): 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( + # Sentinel is written to $successSentinel, and must sit in the success path + # — before the "Update succeeded" marker. + assert "Write-NoBom -Path $successSentinel" in content + assert content.index("Write-NoBom -Path $successSentinel") < content.index( 'Write-Host "Update succeeded"' ) +def test_update_ps1_writes_sentinels_without_bom(): + """Sentinels MUST be written BOM-less. Windows PowerShell 5.1's + `Set-Content -Encoding UTF8` prepends a UTF-8 BOM (EF BB BF); the tray's + version comparison and json.loads of the failure sentinel both break on a + leading U+FEFF, so a successful update was falsely reported as failed. + update.ps1 must route every sentinel write through Write-NoBom and never + fall back to `Set-Content ... -Encoding UTF8` for them. + """ + content = (scripts_dir() / "update.ps1").read_text(encoding="utf-8") + assert "UTF8Encoding($false)" in content # the BOM-less writer + # No ACTIVE (non-comment) line may use `-Encoding UTF8` (PS 5.1 = BOM). + # Comment lines are allowed to mention it for documentation. + code_lines = [ln for ln in content.splitlines() if not ln.lstrip().startswith("#")] + offenders = [ln for ln in code_lines if "-Encoding UTF8" in ln] + assert not offenders, f"active line uses BOM-producing -Encoding UTF8: {offenders}" + + # --------------------------------------------------------------------------- # uninstall.ps1 content — standalone script validation # --------------------------------------------------------------------------- diff --git a/tests/test_tray_first_run.py b/tests/test_tray_first_run.py index 8972206..e67308b 100644 --- a/tests/test_tray_first_run.py +++ b/tests/test_tray_first_run.py @@ -220,3 +220,48 @@ def test_sentinel_not_present_no_banner_armed(self, tmp_path, monkeypatch): hw.arm_welcome_banner() hw.arm_welcome_banner.assert_not_called() + + +# --------------------------------------------------------------------------- +# Update sentinel BOM tolerance (v0.4.1 — false "did not complete" banner) +# --------------------------------------------------------------------------- + + +class TestUpdateSentinelBomTolerance: + """update.ps1 under Windows PowerShell 5.1 wrote the success sentinel via + `Set-Content -Encoding UTF8`, which prepends a UTF-8 BOM. str.strip() does + NOT remove U+FEFF, so a plain utf-8 read left the version as "0.4.0" and the + tray's `updated_to == APP_VERSION` check failed — falsely reporting a + successful update as "did not complete". The reader now uses utf-8-sig. + """ + + def test_bom_prefixed_success_sentinel_matches_version(self, tmp_path): + version = "0.4.0" + sentinel = tmp_path / "plaud_just_updated.txt" + # Bytes exactly as PowerShell 5.1 `Set-Content -Encoding UTF8` writes them. + sentinel.write_bytes(b"\xef\xbb\xbf" + version.encode("utf-8")) + + # The fix: utf-8-sig strips the BOM so the comparison succeeds. + assert sentinel.read_text(encoding="utf-8-sig").strip() == version + # Regression witness: the old plain-utf-8 read did NOT match. + assert sentinel.read_text(encoding="utf-8").strip() != version + + def test_bom_prefixed_failure_sentinel_parses_as_json(self, tmp_path): + import json + + sentinel = tmp_path / "plaud_update_failed.txt" + payload = json.dumps({"reason": "boom", "log": "C:/t/x.log"}) + sentinel.write_bytes(b"\xef\xbb\xbf" + payload.encode("utf-8")) + + # The fix: utf-8-sig lets json.loads succeed despite the BOM. + assert json.loads(sentinel.read_text(encoding="utf-8-sig"))["reason"] == "boom" + + def test_app_reads_both_sentinels_with_utf8_sig(self): + """Source guard: both sentinel reads in app.py must use utf-8-sig so no + future edit silently reintroduces the BOM-mismatch bug.""" + import plaud_tools.tray.app as app_mod + + src = Path(app_mod.__file__).read_text(encoding="utf-8") + assert 'fail_sentinel.read_text(encoding="utf-8-sig")' in src + assert 'sentinel.read_text(encoding="utf-8-sig")' in src + assert 'read_text(encoding="utf-8")' not in src # no plain-utf-8 sentinel reads