using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using RemSound.Core; namespace RemSound.App; /// /// Self-updater. Polls the GitHub Releases API for the latest published version, compares it /// to the running assembly's version, and (optionally) downloads and installs the new build. /// /// Update install flow (Windows-only): RemSound.exe can't overwrite itself while it's running, /// so a successful install hands off to a SEPARATE copy of the new RemSound.exe (see /// ): /// /// Download the release ZIP and extract it to a per-user temp stage OFF the install /// folder (<LocalAppData>\RemSound\update\<guid>\app). /// Launch the staged RemSound.exe --apply-update … from there, then exit. /// That process waits for this one to fully exit, then back-up-and-swaps the new files /// over the install in plain C# (retry + rename-aside, rolling back on any failure), /// restarts RemSound, and the next launch clears the temp stage. /// /// Running the installer from temp means nothing in the install folder is locked by the updater /// itself; doing the copy in C# rather than a generated batch + robocopy removes the whole class /// of silent batch/robocopy failures the old helper hit on some machines. /// /// The GitHub repo to poll is hard-coded — the App was designed to be redistributed from a /// single canonical release stream, not to be re-pointed at a fork. If you need to publish /// from a different repo, change / . /// internal sealed class RemSoundUpdater { public const string RepoOwner = "Ednunp"; public const string RepoName = "RemSound"; /// Asset name on the GitHub release that the updater downloads. The release /// publisher's gh release create command must attach exactly this filename for /// the auto-install path to work; other assets in the release are ignored. The literal /// "{tag}" placeholder is replaced with the release's tag_name at runtime. public const string AssetNameTemplate = "RemSound-{tag}.zip"; private static readonly HttpClient http = CreateClient(); /// Sink for diagnostic lines — the App wires this to logFile.Event so an /// admin can see what the updater did (which version it saw, whether it downloaded, why /// an install attempt failed). Updater output never goes to a popup unless the user /// triggered a manual check. public Action? Log { get; set; } public string CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; /// Hit the GitHub Releases API, parse the latest release, return a struct /// describing what was found. Returns null if the request fails (network down, rate /// 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) { try { // 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. // per_page=100 (vs the API default of 30): the repo holds both client (vX.Y) and // relay-server (server-vX.Y) releases, so a burst of server releases could push the // newest client release off a 30-item first page. 100 keeps it comfortably in view. var url = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases?per_page=100"; Log?.Invoke($"updater: GET {url}"); using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); using var resp = await http.SendAsync(req, token).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) { Log?.Invoke($"updater: HTTP {(int)resp.StatusCode} from GitHub"); 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 new UpdateCheckFailed(FailureKind.HttpError, "GitHub returned an empty release list."); } // 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 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 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 new UpdateCheckFailed(FailureKind.HttpError, $"The latest release page is missing the expected file '{expectedAsset}'."); } return new UpdateAvailable(new UpdateInfo( Tag: release.TagName, Version: latest, DownloadUrl: asset.BrowserDownloadUrl, ReleaseNotes: release.Body ?? "", ReleaseUrl: release.HtmlUrl ?? "")); } catch (Exception ex) { Log?.Invoke($"updater: check failed: {ex.GetType().Name}: {ex.Message}"); 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 into the install folder by just before it /// relaunches RemSound (the profile title is handed to it via --resume-profile); read /// and deleted by Program.Main on the next startup, so a silent or mid-session update /// drops the user back into the same profile they were running rather than at the picker. public const string ResumeProfileSentinelName = "_resume-after-update.txt"; /// Filename of the one-shot "an update just succeeded — show what's new once" marker. /// Written into the install folder by ONLY on a successful update /// (never on a failed/rolled-back one); read and deleted by MainForm on the next startup. This is /// the positive signal that drives the "what's new after an update" popup — so a FAILED update can't /// trigger it. (The old running-version-vs-saved-version compare could re-fire after a failure when /// its best-effort flag save lost a race during the update churn — that was the bug.) public const string WhatsNewMarkerName = "_whats-new-after-update.txt"; /// Download the update ZIP, stage it to a per-user temp folder, and launch the new /// version's in-app installer () to take over once this process /// exits. Returns true if the installer was launched (caller should Application.Exit /// immediately afterwards); false on any failure earlier in the pipeline. A false return /// leaves the running instance — and the install — untouched. /// /// — when non-empty, it's passed to the installer via /// --resume-profile; the installer writes the one-shot /// sentinel into the install folder just before it relaunches RemSound. On the next startup, /// Program.Main reads it, loads that profile silently (skipping the picker), and deletes the /// sentinel — so a silent / mid-session update behaves like the session never ended. When /// null/empty, no sentinel is written and the post-update launch uses whatever startup /// behaviour AppConfig has configured. public async Task DownloadAndStageInstallAsync(UpdateInfo info, string? activeProfileTitle = null, CancellationToken token = default) { try { var installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // Stage the new version into a LOCAL, per-user temp folder OUTSIDE the install (and off any // Dropbox/OneDrive folder the install might sit in). The new RemSound.exe is launched FROM // here to do the swap, so nothing in the install folder is locked by the updater itself, and // a sync engine can't hold the staged files mid-update. var stageRoot = Path.Combine(UpdateStageParentDir, Guid.NewGuid().ToString("N")); var appDir = Path.Combine(stageRoot, "app"); var zipPath = Path.Combine(stageRoot, $"RemSound-update-{info.Tag}.zip"); Directory.CreateDirectory(appDir); // This attempt starts clean: clear any stale failure marker / resume sentinel / what's-new // marker in the install. TryDelete(Path.Combine(installDir, "update-failed.txt")); TryDelete(Path.Combine(installDir, ResumeProfileSentinelName)); TryDelete(Path.Combine(installDir, WhatsNewMarkerName)); Log?.Invoke($"updater: downloading {info.DownloadUrl}"); await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false)) await using (var dst = File.Create(zipPath)) { await src.CopyToAsync(dst, token).ConfigureAwait(false); } Log?.Invoke($"updater: extracting to {appDir}"); System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, appDir, overwriteFiles: true); // Some release zips wrap everything in a single top-level folder (e.g. "RemSound-v1.1/"). // Flatten to the level that actually holds RemSound.exe. var appRoot = ResolveStagingRoot(appDir); var stagedExe = Path.Combine(appRoot, "RemSound.exe"); if (!File.Exists(stagedExe)) { Log?.Invoke("updater: staged RemSound.exe not found — aborting, install left untouched"); TryDeleteDirectory(stageRoot); return false; } // Hand off to the NEW version's in-app installer (UpdateApplier). It waits for THIS process // to exit, then back-up-and-swaps the files over the install in C# and restarts RemSound. // ArgumentList quotes paths with spaces/odd characters correctly for us — no batch escaping. var pid = System.Environment.ProcessId; var psi = new System.Diagnostics.ProcessStartInfo { FileName = stagedExe, UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = appRoot, }; psi.ArgumentList.Add("--apply-update"); psi.ArgumentList.Add("--update-source"); psi.ArgumentList.Add(appRoot); psi.ArgumentList.Add("--update-target"); psi.ArgumentList.Add(installDir); psi.ArgumentList.Add("--update-wait-pid"); psi.ArgumentList.Add(pid.ToString()); psi.ArgumentList.Add("--update-stage-root"); psi.ArgumentList.Add(stageRoot); if (!string.IsNullOrWhiteSpace(activeProfileTitle)) { psi.ArgumentList.Add("--resume-profile"); psi.ArgumentList.Add(activeProfileTitle); } Log?.Invoke($"updater: launching in-app installer from {appRoot}, parent PID {pid}"); using (System.Diagnostics.Process.Start(psi)) { } return true; } catch (Exception ex) { Log?.Invoke($"updater: install failed: {ex.GetType().Name}: {ex.Message}"); return false; } } /// If the zip extracted to a single subfolder (typical when GitHub zips a tag), /// return that subfolder so the copy works from the inner level. Otherwise return the /// staging dir itself. private static string ResolveStagingRoot(string stagingDir) { var subdirs = Directory.GetDirectories(stagingDir); var files = Directory.GetFiles(stagingDir); if (files.Length == 0 && subdirs.Length == 1) return subdirs[0]; return stagingDir; } /// Per-user, local temp parent for update staging: <LocalAppData>\RemSound\update. /// Deliberately OFF the install folder (which may be Dropbox/OneDrive-synced) and writable /// without admin, so the new RemSound.exe can run from here to swap files over the install. internal static string UpdateStageParentDir { get { var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); if (string.IsNullOrWhiteSpace(local)) local = Path.GetTempPath(); return Path.Combine(local, "RemSound", "update"); } } /// Best-effort cleanup of leftover update artefacts, called on a normal launch. The /// in-app installer can't delete the temp stage it's running from, so the next launch clears it; /// this also sweeps away relics of the OLD (pre-3.6) batch updater that staged into the install /// folder (the _update tree, _apply-update.cmd, _update-helper.log). public static void CleanUpUpdateStages() { try { var parent = UpdateStageParentDir; if (Directory.Exists(parent)) foreach (var dir in Directory.GetDirectories(parent)) TryDeleteDirectory(dir); } catch { /* best-effort */ } try { var installDir = AppContext.BaseDirectory; TryDeleteDirectory(Path.Combine(installDir, "_update")); TryDelete(Path.Combine(installDir, "_apply-update.cmd")); TryDelete(Path.Combine(installDir, "_update-helper.log")); } catch { /* best-effort */ } } /// 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); var trimmed = tag.TrimStart('v', 'V').Trim(); var parts = trimmed.Split('.', '-', '+'); var nums = new int[3]; for (var i = 0; i < 3 && i < parts.Length; i++) { int.TryParse(parts[i], out nums[i]); } return new Version(nums[0], nums[1], nums[2]); } private static HttpClient CreateClient() { var c = new HttpClient { Timeout = TimeSpan.FromSeconds(20), }; // GitHub rejects API requests without a User-Agent. The header doubles as a way for // their abuse team to contact us if our polling misbehaves at scale. c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("RemSound-Updater", "1.0")); return c; } private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { /* ignore */ } } private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { /* ignore */ } } private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true, }; private sealed class GitHubRelease { [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; } } private sealed class GitHubAsset { [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; } } } /// What returns when there's a /// newer release available. is the raw Markdown body of the /// release on GitHub — show it directly in a confirmation dialog if the install isn't /// silent. internal sealed record UpdateInfo( string Tag, Version Version, 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, }