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
39 changes: 38 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
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.3.3"
version = "0.3.4"
description = "Python rewrite for Plaud CLI and MCP workflows."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
22 changes: 11 additions & 11 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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) {
Expand All @@ -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'
Expand Down Expand Up @@ -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`""
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/plaud_tools/ps1_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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"
Expand All @@ -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"


Expand Down
67 changes: 56 additions & 11 deletions src/plaud_tools/scripts/update.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)]
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
# ---------------------------------------------------------------------------

Expand All @@ -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)"
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {}
Expand Down
31 changes: 26 additions & 5 deletions src/plaud_tools/tray/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading