From 6cbde0da127ec0c6eea71af8642fb6e407eb1bee Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Thu, 28 May 2026 19:42:26 +0100 Subject: [PATCH] Updater: distinguish 'no newer version' from 'check failed' on all three paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tech Singer's Windows 7 log from 2026-05-28 had this pattern: updater: GET https://api.github.com/repos/... updater: check failed: HttpRequestException: The SSL connection could not be established ... updater: startup check — up to date (v3.0.1) i.e. the SSL handshake to GitHub failed (Win7's TLS stack missing KB3140245 / KB4474419) but the updater logged 'up to date' and the user-facing message in CheckForUpdatesManually said the same. So a user with a broken update check has no way to distinguish that from genuinely having the latest version. Fix: * CheckForUpdateAsync now returns a discriminated UpdateCheckResult (UpdateAvailable / UpToDate / UpdateCheckFailed) instead of the old UpdateInfo?. Each return-null site is replaced with the appropriate concrete type. * The catch-all 'try { ... } catch (Exception ex) { return null; }' becomes 'return new UpdateCheckFailed(ClassifyFailure(ex), ...)'. ClassifyFailure walks the exception chain and maps to a coarse FailureKind enum: SecureConnection (TLS/auth), Timeout, HttpError, NetworkUnreachable. SecureConnection is broken out separately so the manual-check UI can point Win7 users at the specific Microsoft KBs that fix the issue. * MainForm.CheckForUpdatesManually pattern-matches on the result: UpToDate -> existing 'you're running the latest' message; UpdateAvailable -> existing install confirmation; UpdateCheckFailed -> NEW dialog (ShowUpdateCheckFailedDialog) whose wording is tailored to the FailureKind. The SecureConnection branch explicitly names KB3140245 and KB4474419 and offers the manual zip-install URL as a fallback. All branches keep the technical detail out of the dialog and route it to the log instead. * Background and startup polls stay silent on UpToDate and UpdateCheckFailed (no point nagging the user about something they can't act on from a timer tick), but the startup-poll log now records the failure kind and detail instead of mislabelling the outcome as 'up to date'. No version bump - this rides along with the next feature release (planned v3.2 with the Reaper ReaStream integration). The bug is silent on the affected users today, and shipping a v3.1.2 just for the error-message improvement would mean another update cycle for everyone for marginal benefit. Co-Authored-By: Claude Opus 4.7 --- src/RemSound.App/MainForm.cs | 108 ++++++++++++++++++++++------ src/RemSound.App/RemSoundUpdater.cs | 90 ++++++++++++++++++++--- 2 files changed, 167 insertions(+), 31 deletions(-) diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 8d908bf..ed344ce 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -1840,26 +1840,74 @@ public sealed class MainForm : Form } /// User pressed "Check for updates" (Help menu or Preferences button). Always - /// runs the check noisily — i.e. surfaces "you're up to date" / "v1.x available" via a - /// MessageBox, regardless of the Silently-install setting. Silent install only applies - /// to background polls. Caller is on the UI thread. + /// runs the check noisily — i.e. surfaces "you're up to date", "vX.Y is available", or + /// "couldn't reach the server" via a MessageBox, regardless of the Silently-install + /// setting. Silent install only applies to background polls. Caller is on the UI thread. + /// private async void CheckForUpdatesManually() { - var info = await updater.CheckForUpdateAsync().ConfigureAwait(true); - if (info is null) + var result = await updater.CheckForUpdateAsync().ConfigureAwait(true); + switch (result) { - MessageBox.Show(this, - $"You are running the latest version (v{updater.CurrentVersion}).", - "Check for updates", MessageBoxButtons.OK, MessageBoxIcon.Information); - return; + case UpToDate: + MessageBox.Show(this, + $"You are running the latest version (v{updater.CurrentVersion}).", + "Check for updates", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + + case UpdateCheckFailed failure: + ShowUpdateCheckFailedDialog(failure); + return; + + case UpdateAvailable available: + var info = available.Info; + var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes) + ? $"RemSound {info.Tag} is available. Install now?" + : $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?"; + var choice = MessageBox.Show(this, summary, "Update available", + MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); + if (choice != DialogResult.Yes) return; + await InstallUpdateAsync(info).ConfigureAwait(true); + return; } - var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes) - ? $"RemSound {info.Tag} is available. Install now?" - : $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?"; - var choice = MessageBox.Show(this, summary, "Update available", - MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); - if (choice != DialogResult.Yes) return; - await InstallUpdateAsync(info).ConfigureAwait(true); + } + + /// Show a plain-English MessageBox explaining why an update check couldn't + /// complete. The wording is tailored to the : the SecureConnection + /// case (which is most often Windows 7 lacking TLS 1.2 / SHA-2 updates) gets pointers to + /// the specific Microsoft KBs that fix it; other failures get a friendlier "check your + /// internet" message. The user is also offered the manual zip-install fallback URL so they + /// can recover without us debugging their machine. The technical detail goes to the log, + /// not the dialog. 2026-05-28. + private void ShowUpdateCheckFailedDialog(UpdateCheckFailed failure) + { + var (heading, body) = failure.Kind switch + { + FailureKind.SecureConnection => ( + "Couldn't reach the update server (secure connection failed)", + "RemSound couldn't make a secure connection to GitHub to check for an update.\n\n" + + "This is almost always because the Windows install is missing one or both of these official Microsoft updates that enable modern secure connections:\n\n" + + " • KB3140245 — turns on TLS 1.2 support.\n" + + " • KB4474419 — updates the trusted certificate list (SHA-2 support).\n\n" + + "Both are free and won't break anything else. Run Windows Update (Control Panel → Windows Update) and install whatever it offers. Once those are in, Check for updates should work normally.\n\n" + + "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder. That works regardless of the secure-connection issue."), + + FailureKind.NetworkUnreachable => ( + "Couldn't reach the update server", + "RemSound couldn't reach GitHub to check for an update. This is usually a network problem — check your internet connection, then try Check for updates again.\n\n" + + "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."), + + FailureKind.Timeout => ( + "Update check timed out", + "RemSound's request to GitHub took too long to respond. This is usually a slow or congested network — try Check for updates again in a minute or two.\n\n" + + "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."), + + _ => ( + "Couldn't check for updates", + "RemSound's request to GitHub didn't get the response it expected, so it can't tell whether a newer version is available. Try Check for updates again later.\n\n" + + "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."), + }; + MessageBox.Show(this, body, heading, MessageBoxButtons.OK, MessageBoxIcon.Warning); } /// Background-poll path. Runs on a timer tick; surfaces nothing unless an update @@ -1868,7 +1916,7 @@ public sealed class MainForm : Form /// silent no-op — the user already chose to delegate scheduling to the timer. private async void CheckForUpdatesInBackground() { - var info = await updater.CheckForUpdateAsync().ConfigureAwait(true); + var result = await updater.CheckForUpdateAsync().ConfigureAwait(true); // Persist the timestamp so cross-launch scheduling can space the next poll out. try { @@ -1877,7 +1925,12 @@ public sealed class MainForm : Form cfg.Save(); } catch { /* timestamp persistence is best-effort */ } - if (info is null) return; + // Background polls stay silent on both UpToDate and UpdateCheckFailed — the user + // delegated scheduling to the timer and a failure here isn't actionable from where + // they are. The next poll re-checks. The failure detail has already gone to the log + // via the updater's Log callback. + if (result is not UpdateAvailable available) return; + var info = available.Info; if (AppConfig.Load().SilentlyInstallUpdates) { // Notice the user before the app vanishes and the helper takes over. Hidden from @@ -1904,14 +1957,14 @@ public sealed class MainForm : Form /// stays consistent. private async void CheckForUpdatesOnStartup() { - UpdateInfo? info; + UpdateCheckResult result; try { - info = await updater.CheckForUpdateAsync().ConfigureAwait(true); + result = await updater.CheckForUpdateAsync().ConfigureAwait(true); } catch (Exception ex) { - logFile.Event($"updater: startup check failed: {ex.GetType().Name}: {ex.Message}"); + logFile.Event($"updater: startup check threw unexpectedly: {ex.GetType().Name}: {ex.Message}"); return; } try @@ -1921,11 +1974,22 @@ public sealed class MainForm : Form cfg.Save(); } catch { /* harmless */ } - if (info is null) + // Log each outcome distinctly so a failed check (TLS error, network down, GitHub + // 5xx) doesn't get filed as "up to date" — that's the misclassification Tech Singer's + // log from 2026-05-28 demonstrated, where a Win7 SSL handshake failure quietly logged + // as "up to date" instead of the real cause. + if (result is UpdateCheckFailed failure) + { + logFile.Event($"updater: startup check failed ({failure.Kind}): {failure.TechnicalDetail}"); + return; + } + if (result is UpToDate) { logFile.Event($"updater: startup check — up to date (v{updater.CurrentVersion})"); return; } + if (result is not UpdateAvailable available) return; + var info = available.Info; logFile.Event($"updater: startup check found {info.Tag}"); if (AppConfig.Load().SilentlyInstallUpdates) { diff --git a/src/RemSound.App/RemSoundUpdater.cs b/src/RemSound.App/RemSoundUpdater.cs index a8c3a0c..3964da4 100644 --- a/src/RemSound.App/RemSoundUpdater.cs +++ b/src/RemSound.App/RemSoundUpdater.cs @@ -60,7 +60,7 @@ internal sealed class RemSoundUpdater : IDisposable /// limited, repo not found) or if the latest version is not newer than the running /// assembly. Caller decides whether to surface "you're up to date" vs silently doing /// nothing — both paths get null back. - public async Task CheckForUpdateAsync(CancellationToken token = default) + public async Task CheckForUpdateAsync(CancellationToken token = default) { try { @@ -81,14 +81,14 @@ internal sealed class RemSoundUpdater : IDisposable if (!resp.IsSuccessStatusCode) { Log?.Invoke($"updater: HTTP {(int)resp.StatusCode} from GitHub"); - return null; + return new UpdateCheckFailed(FailureKind.HttpError, $"GitHub responded with HTTP {(int)resp.StatusCode}."); } await using var stream = await resp.Content.ReadAsStreamAsync(token).ConfigureAwait(false); var releases = await JsonSerializer.DeserializeAsync>(stream, JsonOpts, token).ConfigureAwait(false); if (releases is null || releases.Count == 0) { Log?.Invoke("updater: releases list was empty"); - return null; + return new UpdateCheckFailed(FailureKind.HttpError, "GitHub returned an empty release list."); } // Highest-versioned RemSound client release. Skip drafts, prereleases, and any @@ -105,35 +105,61 @@ internal sealed class RemSoundUpdater : IDisposable if (release?.TagName is null) { Log?.Invoke("updater: no RemSound client release found in the releases list"); - return null; + return new UpdateCheckFailed(FailureKind.HttpError, "GitHub returned releases but none looked like a RemSound client release."); } 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; + if (latest <= current) return UpToDate.Instance; var expectedAsset = AssetNameTemplate.Replace("{tag}", release.TagName); var asset = release.Assets?.FirstOrDefault(a => string.Equals(a.Name, expectedAsset, StringComparison.OrdinalIgnoreCase)); if (asset?.BrowserDownloadUrl is null) { Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'"); - return null; + return new UpdateCheckFailed(FailureKind.HttpError, $"The latest release page is missing the expected file '{expectedAsset}'."); } - return new UpdateInfo( + return new UpdateAvailable(new UpdateInfo( Tag: release.TagName, Version: latest, DownloadUrl: asset.BrowserDownloadUrl, ReleaseNotes: release.Body ?? "", - ReleaseUrl: release.HtmlUrl ?? ""); + ReleaseUrl: release.HtmlUrl ?? "")); } catch (Exception ex) { Log?.Invoke($"updater: check failed: {ex.GetType().Name}: {ex.Message}"); - return null; + return new UpdateCheckFailed(ClassifyFailure(ex), ex.Message); } } + /// Maps a thrown exception from the GitHub HTTP call to a coarse-grained + /// the UI can hang an honest plain-English message off without + /// quoting the underlying .NET exception type. Most "couldn't reach the server" errors + /// fall into the network bucket; the SSL bucket is broken out separately because it has + /// a specific cause and fix on Windows 7 (TLS 1.2 / SHA-2 Windows updates) that we want + /// to point users at when we see it. 2026-05-28. + private static FailureKind ClassifyFailure(Exception ex) + { + // Walk the exception chain — HttpRequestException is the outer wrapper; the actual + // cause (System.Net.Security.AuthenticationException, IOException, SocketException, + // etc) is in InnerException. Either layer might carry the diagnostic clue. + for (Exception? e = ex; e is not null; e = e.InnerException) + { + var typeName = e.GetType().Name; + var msg = e.Message ?? ""; + if (typeName.Contains("Authentication", StringComparison.OrdinalIgnoreCase) + || msg.Contains("SSL", StringComparison.OrdinalIgnoreCase) + || msg.Contains("TLS", StringComparison.OrdinalIgnoreCase)) + { + return FailureKind.SecureConnection; + } + } + if (ex is TaskCanceledException) return FailureKind.Timeout; + return FailureKind.NetworkUnreachable; + } + /// Filename of the one-shot "after the update restart, silently load this profile" /// sentinel. Written next to RemSound.exe by /// when the caller supplies a non-empty activeProfileTitle; read and deleted by @@ -445,3 +471,49 @@ internal sealed record UpdateInfo( string DownloadUrl, string ReleaseNotes, string ReleaseUrl); + +/// Discriminated result of an update check. Replaces the v3.1.x-and-earlier +/// "UpdateInfo?" return type, which conflated "no newer version available" with "couldn't +/// reach the server" — the user saw "you are running the latest version" in both cases, +/// even when the check had actually failed because (e.g.) the OS couldn't establish a +/// secure connection to GitHub. The caller pattern-matches on this and shows an honest +/// message for each outcome. 2026-05-28. +internal abstract record UpdateCheckResult; + +/// A newer release is available. Carries the parsed the +/// caller passes to . +internal sealed record UpdateAvailable(UpdateInfo Info) : UpdateCheckResult; + +/// The check completed and the installed version is at or above the latest +/// release. Singleton — there's nothing to carry beyond the result type itself. +internal sealed record UpToDate : UpdateCheckResult +{ + public static readonly UpToDate Instance = new(); + private UpToDate() { } +} + +/// The check could not complete. is a coarse classifier the UI +/// uses to pick a plain-English message; is the raw exception +/// or HTTP-status message intended for log output and "what to send the developer" cases — +/// never put it in a user-facing dialog verbatim. +internal sealed record UpdateCheckFailed(FailureKind Kind, string TechnicalDetail) : UpdateCheckResult; + +/// Why the update check couldn't complete. Lets the UI distinguish "your TLS stack +/// is too old to reach modern HTTPS servers" (a known and fixable Windows 7 issue) from +/// "your internet is down" so the message and any pointers we offer match the actual +/// problem. +internal enum FailureKind +{ + /// The HTTPS handshake itself failed — usually means the OS's TLS or + /// certificate stack is too old. Most commonly seen on Windows 7 installs without + /// the TLS 1.2 enablement update (KB3140245) and SHA-2 code signing support + /// (KB4474419). + SecureConnection, + /// The HTTP call reached GitHub but got back an unexpected response (4xx / + /// 5xx HTTP status, malformed JSON, empty release list, etc). + HttpError, + /// The HTTP call timed out. + Timeout, + /// Generic "couldn't reach the server" — DNS, socket, no internet. + NetworkUnreachable, +}