Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 22 additions & 5 deletions src/plaud_tools/scripts/update.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
}
Expand Down Expand Up @@ -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"
Expand Down
16 changes: 14 additions & 2 deletions src/plaud_tools/tray/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 21 additions & 4 deletions tests/test_ps1_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
45 changes: 45 additions & 0 deletions tests/test_tray_first_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading