diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a5f3f7a..f8360a6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,16 +1,16 @@ -# RemSound v1.6 +# RemSound v1.7 -Three reliability fixes — peer-address recovery, a reconnect crash, and a long-run memory/CPU leak. Wire format and audio pipeline are unchanged from v1.4 / v1.5 — all three releases interoperate. +A self-updater fix. Wire format and audio pipeline are unchanged from v1.4 / v1.5 / v1.6 — all releases interoperate. -## Bug fixes +## Bug fix -- **Peer address recovery.** If the address you connected to stops responding — a peer rebooted onto a new IP, or a computer name resolved (via DNS / a router or Pi-hole record) to a stale address — RemSound now follows the peer to the live address it is still heartbeating from, instead of sending audio to a dead address indefinitely. Recovery is automatic within a few seconds. It only adopts private-network (LAN) addresses, so a relay's public address can never be mistaken for a moved peer. -- **Reconnect crash fixed.** When a peer reconnected — typically after rebooting — the Connectivity tab's peer list could be read mid-rebuild with a stale list index, throwing an `IndexOutOfRangeException` from the 1-second status timer and bringing the app down with a crash dialog. The list reads are now bounds-checked, and the status tick is wrapped so a transient UI hiccup is logged instead of fatal. -- **Long-run memory and CPU leak fixed.** A receiver left running for hours could grow to several gigabytes of memory and climbing CPU. Decoder sessions orphaned by peer reconnects (each reconnect creates a fresh stream identity) were not being reliably reclaimed — they piled up, each holding a multi-megabyte playout buffer and costing render-thread time on every audio callback. Idle sessions are now reaped on their own activity timer, with a hard cap on live sessions as a backstop, so memory and CPU stay bounded however long RemSound runs. +- **Check for updates now reliably finds RemSound releases.** RemSound and the RemSound relay server are published from the same GitHub repository; the relay's releases use `server-` prefixed tags. The updater previously asked GitHub only for the single newest release of *any* kind — so whenever a relay release was the most recent, the updater misread its version and concluded RemSound was already up to date, silently skipping a real client update. The updater now scans the full release list and considers only RemSound client versions, ignoring server releases, drafts and pre-releases. + +If your copy is stuck on an older version because of this, install v1.7 once by hand (below) — from v1.7 onward, **Help → Check for updates** works correctly on its own. ## Install -1. Download `RemSound-v1.6.zip` from this release. +1. Download `RemSound-v1.7.zip` from this release. 2. Extract somewhere with write permission (e.g. `C:\RemSound\`, `Documents\RemSound\`). Avoid `Program Files` unless you grant write permission so the self-updater can replace files in place. 3. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts. 4. Press F1 (or use the Help menu) for the user manual. @@ -19,6 +19,6 @@ Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch i ## Upgrading -v1.3 / v1.4 / v1.5 users on any install location can use **Help → Check for updates** — the hardened updater pulls v1.6 cleanly. +v1.5 / v1.6 users: use **Help → Check for updates** — it pulls v1.7 cleanly (their updater can still see v1.7 as long as it's the newest release at check time). To be certain, the manual install above always works. If you installed RemSound inside a Dropbox-synced (or other file-sync) folder and your install is v1.0 / v1.1 / v1.2, see the [v1.3 release notes](https://github.com/Ednunp/RemSound/releases/tag/v1.3) for one-time manual install steps. From v1.3 onward Check-for-updates handles Dropbox correctly. diff --git a/build-release.ps1 b/build-release.ps1 index 559aaa9..6cbf78a 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -1,42 +1,107 @@ -# Build script for a tagged RemSound release. +# build-release.ps1 — produce the RemSound client release zip, and PROVE it carries +# no personal data before it can ship. # -# .\build-release.ps1 v1.0 +# Why this script exists +# ---------------------- +# v1.5 and v1.6 were released with the developer's logs/, profiles/ and recordings/ +# folders inside the zip — because those releases were hand-zipped from a publish/ +# folder the app had been run from, which had accumulated that runtime data, instead +# of using this script. This version removes the human step that went wrong: +# * It ALWAYS publishes into a fresh, empty staging folder — never a reused dir. +# * It then SCANS the staged files AND the finished zip, and ABORTS (deletes the +# zip, exits non-zero) if anything that could carry personal data is present. +# Never hand-zip publish/ again. Run this. If it aborts, the release does not ship. # -# Produces dist\RemSound-v1.0.zip, ready for `gh release create`. The asset name matches -# what RemSoundUpdater expects on the GitHub Releases page (RemSound-.zip); change -# RemSoundUpdater.AssetNameTemplate if you rename here. +# Usage: +# powershell -ExecutionPolicy Bypass -File build-release.ps1 -Tag v1.7 +# +# The -Tag value must match the GitHub release tag. The zip is named RemSound-.zip +# because the in-app updater downloads exactly that asset name (AssetNameTemplate in +# RemSoundUpdater.cs: "RemSound-{tag}.zip"). [CmdletBinding()] param( - [Parameter(Mandatory=$true, Position=0)] + [Parameter(Mandatory = $true, Position = 0)] + [ValidatePattern('^v[0-9]+\.[0-9]+$')] [string]$Tag ) $ErrorActionPreference = 'Stop' -$repoRoot = $PSScriptRoot -Write-Host "Cleaning publish staging..." -ForegroundColor Cyan -$stage = Join-Path $repoRoot 'src\RemSound.App\bin\Release\net10.0-windows\publish' -if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } +$repo = $PSScriptRoot +$proj = Join-Path $repo 'src\RemSound.App\RemSound.App.csproj' +$distDir = Join-Path $repo 'dist' +$zipPath = Join-Path $distDir "RemSound-$Tag.zip" +$staging = Join-Path ([System.IO.Path]::GetTempPath()) ("remsound-release-" + [guid]::NewGuid().ToString('N')) -Write-Host "Publishing framework-dependent..." -ForegroundColor Cyan -& dotnet publish (Join-Path $repoRoot 'src\RemSound.App\RemSound.App.csproj') -c Release | Out-Null -if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed (exit $LASTEXITCODE)" } +# Anything matching these must NEVER appear in a release. Folders by name; files by +# extension / exact name. RemSound.deps.json and RemSound.runtimeconfig.json are +# legitimate app files and are deliberately NOT matched (different names). +$forbiddenFolders = @('logs', 'profiles', 'recordings') +function Test-Forbidden([string]$path) { + $p = $path -replace '\\', '/' + foreach ($f in $forbiddenFolders) { + if ($p -match "(^|/)$f/") { return $true } + } + if ($p -match '\.log$') { return $true } + if ($p -match '(^|/)remsound\.config\.json$') { return $true } + return $false +} -$distDir = Join-Path $repoRoot 'dist' -if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null } +# 1. Fresh, empty staging folder — the whole point. The app has never run here, so +# there is nothing to leak. +if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } +New-Item -ItemType Directory -Path $staging -Force | Out-Null -$zipName = "RemSound-$Tag.zip" -$zipPath = Join-Path $distDir $zipName -if (Test-Path $zipPath) { Remove-Item $zipPath -Force } +Write-Host "Publishing $Tag to clean staging: $staging" -ForegroundColor Cyan +& dotnet publish $proj -c Release -o $staging | Out-Null +if ($LASTEXITCODE -ne 0) { Remove-Item $staging -Recurse -Force; throw "dotnet publish failed (exit $LASTEXITCODE)" } -Write-Host "Zipping $zipName..." -ForegroundColor Cyan -Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zipPath -CompressionLevel Optimal +# 2. Debug symbols are not personal data, but they don't belong in a release either. +Get-ChildItem -Path $staging -Filter *.pdb -Recurse | Remove-Item -Force +# 3. SAFETY CHECK on the staged files. +$bad = @() +Get-ChildItem -Path $staging -Recurse -Force | ForEach-Object { + $rel = $_.FullName.Substring($staging.Length).TrimStart('\', '/') + if (Test-Forbidden $rel) { $bad += $rel } +} +if ($bad.Count -gt 0) { + Write-Host "" + Write-Host "RELEASE ABORTED - staged folder contains files that must not ship:" -ForegroundColor Red + $bad | Sort-Object -Unique | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + Remove-Item $staging -Recurse -Force + exit 1 +} + +# 4. Zip it. Keep dist/ to a single artefact — drop any prior versioned zip. +New-Item -ItemType Directory -Path $distDir -Force | Out-Null +Get-ChildItem -Path $distDir -Filter 'RemSound-v*.zip' -ErrorAction SilentlyContinue | Remove-Item -Force +Compress-Archive -Path (Join-Path $staging '*') -DestinationPath $zipPath -CompressionLevel Optimal -Force + +# 5. SAFETY CHECK again, on the finished zip itself — belt and braces. +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) +try { + $leaked = @($zip.Entries | Where-Object { Test-Forbidden $_.FullName }) + $entryCount = $zip.Entries.Count +} finally { + $zip.Dispose() +} +if ($leaked.Count -gt 0) { + Write-Host "" + Write-Host "RELEASE ABORTED - finished zip contains forbidden entries:" -ForegroundColor Red + $leaked | ForEach-Object { Write-Host " $($_.FullName)" -ForegroundColor Red } + Remove-Item $zipPath -Force + Remove-Item $staging -Recurse -Force + exit 1 +} + +Remove-Item $staging -Recurse -Force $size = [math]::Round((Get-Item $zipPath).Length / 1MB, 2) Write-Host "" -Write-Host "Built $zipPath ($size MB)" -ForegroundColor Green +Write-Host "OK - clean release zip verified: $zipPath ($size MB, $entryCount entries)" -ForegroundColor Green +Write-Host " No logs / profiles / recordings / config present." -ForegroundColor Green Write-Host "" -Write-Host "Next:" -Write-Host " git add -A; git commit -m 'Release $Tag'; git push" -Write-Host " gh release create $Tag $zipPath --title `"$Tag`" --notes-file RELEASE_NOTES.md" +Write-Host "Next:" -ForegroundColor Cyan +Write-Host " gh release create $Tag `"$zipPath`" --title `"RemSound $Tag`" --notes-file RELEASE_NOTES.md" diff --git a/src/RemSound.App/AboutDialog.cs b/src/RemSound.App/AboutDialog.cs index 78cbf9c..62def6f 100644 --- a/src/RemSound.App/AboutDialog.cs +++ b/src/RemSound.App/AboutDialog.cs @@ -20,6 +20,23 @@ internal sealed class AboutDialog : Form /// updates" path. private const string ReleaseNotes = """ + RemSound v1.7 + + Updater fix. No wire-format or audio-pipeline changes — + v1.5, v1.6 and v1.7 peers interoperate. + + Bug fix: + * Check for updates now reliably finds RemSound + releases. RemSound and the relay server share one + GitHub repository; the server's releases use + "server-" tags. The updater previously looked only + at the single newest release of any kind, so a + server release could derail it (it would misread + the version and report "up to date"). It now scans + the release list and considers only RemSound client + versions, ignoring server releases, drafts and + pre-releases. + RemSound v1.6 Three reliability fixes. No wire-format or audio-pipeline diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj index 1238954..5d52cef 100644 --- a/src/RemSound.App/RemSound.App.csproj +++ b/src/RemSound.App/RemSound.App.csproj @@ -14,7 +14,7 @@ tag_name on the latest GitHub release; bump it on every public release. The AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version is what the About dialog and the updater both read. --> - 1.6.0 + 1.7.0 diff --git a/src/RemSound.App/RemSoundUpdater.cs b/src/RemSound.App/RemSoundUpdater.cs index 4dfb05a..b665f4d 100644 --- a/src/RemSound.App/RemSoundUpdater.cs +++ b/src/RemSound.App/RemSoundUpdater.cs @@ -64,7 +64,13 @@ internal sealed class RemSoundUpdater : IDisposable { try { - var url = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases/latest"; + // List releases — NOT /releases/latest. The repo also hosts the relay server's + // own "server-vX.Y" releases, and /releases/latest is repo-wide: it hands back + // whichever release is newest by date, server or client. A server release would + // then be fed to ParseTag ("server-v2.3" -> a bogus 0.0.3) and the updater would + // wrongly conclude "up to date". We pull the list and consider ONLY releases + // whose tag is a RemSound client tag (see IsClientReleaseTag). 2026-05-18. + var url = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases"; Log?.Invoke($"updater: GET {url}"); using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); @@ -75,14 +81,30 @@ internal sealed class RemSoundUpdater : IDisposable return null; } await using var stream = await resp.Content.ReadAsStreamAsync(token).ConfigureAwait(false); - var release = await JsonSerializer.DeserializeAsync(stream, JsonOpts, token).ConfigureAwait(false); - if (release?.TagName is null) + var releases = await JsonSerializer.DeserializeAsync>(stream, JsonOpts, token).ConfigureAwait(false); + if (releases is null || releases.Count == 0) { - Log?.Invoke("updater: response had no tag_name"); + Log?.Invoke("updater: releases list was empty"); + return null; + } + + // Highest-versioned RemSound client release. Skip drafts, prereleases, and any + // tag that isn't a client tag (notably the server-vX.Y relay releases). + GitHubRelease? release = null; + var latest = new Version(0, 0, 0); + foreach (var r in releases) + { + if (r.TagName is null || r.Draft || r.Prerelease) continue; + if (!IsClientReleaseTag(r.TagName)) continue; + var v = ParseTag(r.TagName); + if (v > latest) { latest = v; release = r; } + } + if (release?.TagName is null) + { + Log?.Invoke("updater: no RemSound client release found in the releases list"); return null; } - var latest = ParseTag(release.TagName); var current = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0); Log?.Invoke($"updater: current={current.ToString(3)} latest={latest.ToString(3)} ({release.TagName})"); if (latest <= current) return null; @@ -259,6 +281,19 @@ internal sealed class RemSoundUpdater : IDisposable /// Parses a release tag like v1.2 or 1.2.3 into a . /// Leading "v" is stripped. Missing minor/build parts get filled with zeros so the result /// always compares meaningfully against .Version. + /// True if is a RemSound client release tag — e.g. + /// v1.6, 1.6, 1.6.0 — rather than something else hosted in the same + /// GitHub repo, notably the relay server's server-vX.Y releases. Test: after an + /// optional leading v, the first character must be a digit. server-v2.3 + /// starts with 's' and is rejected; v1.6 is accepted. The updater must filter on + /// this because it lists all repo releases and the server publishes into the same repo. + public static bool IsClientReleaseTag(string? tag) + { + if (string.IsNullOrWhiteSpace(tag)) return false; + var trimmed = tag.TrimStart('v', 'V').Trim(); + return trimmed.Length > 0 && char.IsDigit(trimmed[0]); + } + public static Version ParseTag(string tag) { if (string.IsNullOrWhiteSpace(tag)) return new Version(0, 0, 0); @@ -298,6 +333,8 @@ internal sealed class RemSoundUpdater : IDisposable [JsonPropertyName("tag_name")] public string? TagName { get; set; } [JsonPropertyName("body")] public string? Body { get; set; } [JsonPropertyName("html_url")] public string? HtmlUrl { get; set; } + [JsonPropertyName("draft")] public bool Draft { get; set; } + [JsonPropertyName("prerelease")] public bool Prerelease { get; set; } [JsonPropertyName("assets")] public List? Assets { get; set; } }