diff --git a/src/MultiSeat.Service/Configuration/MultiSeatOptions.cs b/src/MultiSeat.Service/Configuration/MultiSeatOptions.cs
index ffe2caf..462b7c6 100644
--- a/src/MultiSeat.Service/Configuration/MultiSeatOptions.cs
+++ b/src/MultiSeat.Service/Configuration/MultiSeatOptions.cs
@@ -494,6 +494,20 @@ public sealed class MultiSeatOptions
// C:\Users\{AccountName}\AppData\Roaming\RetroArch\retroarch.cfg.
public string RetroArchConfigPath { get; set; } = string.Empty;
+ // ── Update notifications ─────────────────────────────────────────
+ ///
+ /// Ask GitHub's public releases API whether newer MultiSeat, ApolloVibe and MoonlightVibe
+ /// releases exist, and show the answer in the dashboard. Default false: this is the only
+ /// internet connection the service would make, so it is opt-in.
+ /// It sends an unauthenticated GET to api.github.com with a generic User-Agent and nothing
+ /// else - no version, host name, user name or seat data. It never downloads or installs
+ /// anything. Set in appsettings.local.json, or turn it on from the dashboard System page.
+ ///
+ public bool UpdateCheckEnabled { get; set; } = false;
+
+ /// Hours between checks. Clamped to 1..168. Ignored while UpdateCheckEnabled is false.
+ public int UpdateCheckIntervalHours { get; set; } = Shared.Constants.DefaultUpdateCheckIntervalHours;
+
// ── Rebuild ───────────────────────────────────────────────────────
// Absolute path to the repo root. Required for the dashboard Rebuild button.
// Example: C:\MultiSeat-Development
diff --git a/src/MultiSeat.Service/Updates/ApolloHashTable.cs b/src/MultiSeat.Service/Updates/ApolloHashTable.cs
new file mode 100644
index 0000000..6a59698
--- /dev/null
+++ b/src/MultiSeat.Service/Updates/ApolloHashTable.cs
@@ -0,0 +1,110 @@
+using System.Text.RegularExpressions;
+
+namespace MultiSeat.Service.Updates;
+
+///
+/// The ApolloVibe hash table: the SHA-256 of each published release's sunshine.exe,
+/// read from the release notes, so an installed binary can be matched to a release by its bytes.
+///
+/// A match means the identical file, so it cannot be a false positive; a miss says nothing
+/// (a hand-built exe simply is not in the table). The notes are free text written by a person,
+/// so parsing is deliberately narrow and a failure to find a hash means "no entry", never a guess.
+///
+/// The real notes carry a hash for the release ZIP as well, usually right before the exe's:
+///
+/// SHA-256 of `apollovibe-windows-x64.zip`:
+/// `B39BDC29...`
+///
+/// SHA-256 of `sunshine.exe`:
+/// `7E9FA157...`
+///
+/// so the parser anchors on the label sunshine.exe and takes the first 64-hex value AFTER it,
+/// stopping at the next file name (so a zip hash that follows is not taken either).
+///
+public static class ApolloHashTable
+{
+ private const int MaxNotesLength = 512 * 1024;
+
+ private static readonly Regex Label = new(@"sunshine\.exe", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+
+ // A file name with an extension that a release note would name. Ends the text a label owns.
+ private static readonly Regex AnyFileName = new(
+ @"[A-Za-z0-9_.\-]+\.(?:zip|exe|msi|7z|dll|json|ps1|txt)(?![A-Za-z0-9])",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+
+ private const int MaxLabelReach = 300;
+ private const int MaxLabelsTried = 200;
+ // "sunshine.exe SHA-256: " puts the word right after the label; that is part of the label.
+ private const int LabelSuffixAllowance = 16;
+
+ private static readonly Regex HashStatement = new(@"sha-?\s?256|hash|checksum", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+
+ private static readonly Regex Sha256Hex = new(
+ @"(?
+ /// The lowercase sunshine.exe SHA-256 named in , or null.
+ /// Tolerates CRLF, upper or lower case, backticks, the hash on the next line, and a
+ /// differently labelled hash for another file in the same text. If several labels each have
+ /// a hash, the first wins.
+ ///
+ public static string? ExtractSunshineHash(string? notes)
+ {
+ if (string.IsNullOrEmpty(notes)) return null;
+ if (notes.Length > MaxNotesLength) notes = notes[..MaxNotesLength];
+
+ var tried = 0;
+ foreach (Match label in Label.Matches(notes))
+ {
+ // Notes that name sunshine.exe hundreds of times are not release notes.
+ if (++tried > MaxLabelsTried) break;
+
+ var start = label.Index + label.Length;
+
+ // The label owns the text after it, up to the first of: the next file name (so a zip
+ // hash that follows is not borrowed), the next "SHA-256" or "hash" statement (so a
+ // hash for something not named by a file is not borrowed), or 300 characters.
+ // Every search is confined to that window, so the work per label does not grow with
+ // the length of the notes.
+ var windowEnd = Math.Min(notes.Length, start + MaxLabelReach);
+ var end = windowEnd;
+
+ var nextFile = AnyFileName.Match(notes, start, windowEnd - start);
+ if (nextFile.Success) end = nextFile.Index;
+
+ var statementFrom = start + LabelSuffixAllowance;
+ if (statementFrom < windowEnd)
+ {
+ var nextStatement = HashStatement.Match(notes, statementFrom, windowEnd - statementFrom);
+ if (nextStatement.Success && nextStatement.Index < end) end = nextStatement.Index;
+ }
+
+ var hash = Sha256Hex.Match(notes, start, end - start);
+ if (hash.Success) return hash.Value.ToLowerInvariant();
+ }
+ return null;
+ }
+
+ ///
+ /// Hash to the highest-version candidate that published it. If the same bytes appear in two
+ /// releases, the later one is reported so an identical binary is never called "behind".
+ /// Candidates whose tag does not parse are skipped.
+ ///
+ public static IReadOnlyDictionary Build(IEnumerable candidates)
+ {
+ var table = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var c in candidates)
+ {
+ if (!IsSha256(c.SunshineSha256)) continue;
+ var v = ReleaseVersion.TryParse(UpdateComponent.ApolloVibe, c.Tag);
+ if (v is null) continue;
+
+ if (!table.TryGetValue(c.SunshineSha256!, out var existing) || v.CompareTo(existing.Version) > 0)
+ table[c.SunshineSha256!] = (c, v);
+ }
+ return table.ToDictionary(kv => kv.Key, kv => kv.Value.Candidate, StringComparer.OrdinalIgnoreCase);
+ }
+
+ public static bool IsSha256(string? s) => s is { Length: 64 } && Sha256Hex.IsMatch(s);
+}
diff --git a/src/MultiSeat.Service/Updates/GitHubReleaseClient.cs b/src/MultiSeat.Service/Updates/GitHubReleaseClient.cs
new file mode 100644
index 0000000..72c187b
--- /dev/null
+++ b/src/MultiSeat.Service/Updates/GitHubReleaseClient.cs
@@ -0,0 +1,405 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace MultiSeat.Service.Updates;
+
+public enum FetchOutcome
+{
+ /// 200 and a well-formed list.
+ Ok,
+ /// 304: the stored ETag is still current.
+ NotModified,
+ /// 403/429 with a rate-limit signal. says when to try again.
+ RateLimited,
+ /// Anything else that went wrong. Recorded, never thrown.
+ Failed,
+}
+
+///
+/// What one fetch produced. is a short fixed-vocabulary string: no stack
+/// trace, no URL, no exception message (those can carry host or proxy names).
+///
+public sealed record ReleaseFetchResult(
+ FetchOutcome Outcome,
+ IReadOnlyList Releases,
+ string? ETag,
+ string? Error,
+ DateTimeOffset? RetryNotBefore,
+ int? HttpStatus)
+{
+ internal static ReleaseFetchResult Fail(string error, int? status = null) =>
+ new(FetchOutcome.Failed, [], null, error, null, status);
+}
+
+///
+/// Asks GitHub's public API for one repository's release list. The only code in the service
+/// that would talk to the internet, and nothing starts it yet.
+///
+/// What it sends: GET https://api.github.com/repos/vibesoftwarecoder/{repo}/releases?per_page=30
+/// with Accept, X-GitHub-Api-Version, a generic User-Agent (no version, no host
+/// name) and, when known, If-None-Match with the stored ETag verbatim. Never an
+/// Authorization header and never cookies: the constructor refuses an
+/// that would add either. The repository names and host are constants, not settings.
+///
+/// Failure contract: every network, HTTP, size, encoding and JSON failure comes back as a
+/// ; only cancellation requested by the caller propagates.
+/// Redirects are followed by hand (at most 3) and only to https://api.github.com/repos/... or
+/// /repositories/...; the supplied client must have automatic redirects off, see
+/// . No static state.
+///
+public sealed class GitHubReleaseClient
+{
+ public const int MaxRedirects = 3;
+ public const long MaxBodyBytes = 2 * 1024 * 1024;
+ public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(10);
+
+ private const int MaxTagLength = 128;
+ private const int MaxBodyChars = 256 * 1024;
+ private const int MaxReleases = 100;
+ private const int MaxAssets = 100;
+ private static readonly TimeSpan MaxRetryAfter = TimeSpan.FromHours(24);
+
+ // A weak or strong entity tag exactly as HTTP writes it. Anything else is not sent back, which
+ // also keeps a corrupted state file from putting control characters into a header.
+ private static readonly Regex ETagShape = new("^(W/)?\"[\\x21\\x23-\\x7E]*\"\\z", RegexOptions.CultureInvariant);
+
+ private readonly HttpClient _http;
+ private readonly TimeSpan _timeout;
+ private readonly TimeProvider _time;
+
+ public GitHubReleaseClient(HttpClient http, TimeSpan? requestTimeout = null, TimeProvider? timeProvider = null)
+ {
+ ArgumentNullException.ThrowIfNull(http);
+ var d = http.DefaultRequestHeaders;
+ if (d.Authorization is not null || d.ProxyAuthorization is not null || d.Contains("Cookie") || d.UserAgent.Count > 0)
+ throw new ArgumentException(
+ "The HttpClient adds Authorization, Cookie or User-Agent headers by default; the update check must send none of them.",
+ nameof(http));
+
+ _http = http;
+ _timeout = requestTimeout ?? DefaultRequestTimeout;
+ _time = timeProvider ?? TimeProvider.System;
+ }
+
+ ///
+ /// A handler for the HttpClient this class is given: no automatic redirects (they are
+ /// followed here, with a host check), no cookies, the system's default proxy and normal
+ /// certificate validation.
+ ///
+ public static SocketsHttpHandler CreateHandler() => new()
+ {
+ AllowAutoRedirect = false,
+ UseCookies = false,
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
+ };
+
+ ///
+ /// Fetch one repository's releases. Pass the stored ETag (or null) to get a cheap 304.
+ /// Never throws except when is cancelled.
+ ///
+ public async Task FetchAsync(UpdateComponent component, string? etag, CancellationToken ct)
+ {
+ try
+ {
+ return await FetchCoreAsync(component, etag, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Whatever slipped past the specific handlers below: still recorded, never thrown.
+ return ReleaseFetchResult.Fail("unexpected error (" + ex.GetType().Name + ")");
+ }
+ }
+
+ private async Task FetchCoreAsync(UpdateComponent component, string? etag, CancellationToken ct)
+ {
+ var sendEtag = etag is not null && ETagShape.IsMatch(etag) ? etag : null;
+ var uri = UpdateRepos.ReleaseListUri(component);
+ var visited = new HashSet(StringComparer.Ordinal);
+
+ for (var hop = 0; ; hop++)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (!visited.Add(uri.AbsoluteUri)) return ReleaseFetchResult.Fail("redirect loop");
+
+ using var request = BuildRequest(uri, sendEtag);
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(_timeout);
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ return ReleaseFetchResult.Fail("timeout");
+ }
+ catch (HttpRequestException ex)
+ {
+ return ReleaseFetchResult.Fail("network error (" + ex.HttpRequestError + ")");
+ }
+
+ using (response)
+ {
+ var status = (int)response.StatusCode;
+
+ if (status is 301 or 302 or 303 or 307 or 308)
+ {
+ if (hop >= MaxRedirects) return ReleaseFetchResult.Fail("too many redirects", status);
+ var next = ResolveRedirect(uri, response);
+ if (next is null) return ReleaseFetchResult.Fail("redirect refused", status);
+ uri = next;
+ continue;
+ }
+
+ if (response.StatusCode == HttpStatusCode.NotModified)
+ {
+ if (sendEtag is null) return ReleaseFetchResult.Fail("unexpected 304", status);
+ return new ReleaseFetchResult(FetchOutcome.NotModified, [], ReadETag(response) ?? sendEtag, null, null, status);
+ }
+
+ if (response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests)
+ {
+ var notBefore = RateLimitNotBefore(response);
+ if (notBefore is not null)
+ return new ReleaseFetchResult(FetchOutcome.RateLimited, [], null, "rate limited", notBefore, status);
+ return ReleaseFetchResult.Fail("HTTP " + status, status);
+ }
+
+ if (response.StatusCode != HttpStatusCode.OK)
+ return ReleaseFetchResult.Fail("HTTP " + status, status);
+
+ // 200: read a capped body, then parse it.
+ byte[] body;
+ try
+ {
+ var read = await ReadCappedAsync(response, timeout.Token).ConfigureAwait(false);
+ if (read.Error is not null) return ReleaseFetchResult.Fail(read.Error, status);
+ body = read.Bytes!;
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ return ReleaseFetchResult.Fail("timeout", status);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException)
+ {
+ return ReleaseFetchResult.Fail("body read failed", status);
+ }
+
+ var parsed = ParseReleases(body, out var parseError);
+ return parsed is null
+ ? ReleaseFetchResult.Fail(parseError ?? "invalid response", status)
+ : new ReleaseFetchResult(FetchOutcome.Ok, parsed, ReadETag(response), null, null, status);
+ }
+ }
+ }
+
+ private static HttpRequestMessage BuildRequest(Uri uri, string? etag)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, uri);
+ request.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json");
+ request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
+ request.Headers.TryAddWithoutValidation("User-Agent", Shared.Constants.UpdateUserAgent);
+ if (etag is not null) request.Headers.TryAddWithoutValidation("If-None-Match", etag);
+ return request;
+ }
+
+ ///
+ /// The target of a redirect, or null when it must be refused. Only https on the API host's
+ /// default port, no credentials in the URL, and only the /repos/ and /repositories/ trees.
+ ///
+ private static Uri? ResolveRedirect(Uri current, HttpResponseMessage response)
+ {
+ var location = response.Headers.Location;
+ if (location is null) return null;
+
+ var target = location.IsAbsoluteUri ? location : new Uri(current, location);
+ if (target.Scheme != Uri.UriSchemeHttps) return null;
+ if (!string.Equals(target.Host, Shared.Constants.UpdateApiHost, StringComparison.OrdinalIgnoreCase)) return null;
+ if (!target.IsDefaultPort) return null;
+ if (!string.IsNullOrEmpty(target.UserInfo)) return null;
+
+ var path = target.AbsolutePath;
+ if (!path.StartsWith("/repos/", StringComparison.Ordinal) &&
+ !path.StartsWith("/repositories/", StringComparison.Ordinal))
+ return null;
+
+ return target;
+ }
+
+ private static string? ReadETag(HttpResponseMessage response)
+ {
+ if (!response.Headers.TryGetValues("ETag", out var values)) return null;
+ var value = values.FirstOrDefault();
+ return value is not null && ETagShape.IsMatch(value) ? value : null;
+ }
+
+ ///
+ /// When a 403/429 may be retried, or null if the response carries no rate-limit signal.
+ /// Retry-After (seconds or an HTTP date) wins; otherwise
+ /// X-RateLimit-Remaining: 0 with X-RateLimit-Reset (epoch seconds).
+ /// Capped at 24 hours so a bad header cannot park the check indefinitely.
+ ///
+ private DateTimeOffset? RateLimitNotBefore(HttpResponseMessage response)
+ {
+ var now = _time.GetUtcNow();
+ DateTimeOffset? notBefore = null;
+
+ var retryAfter = response.Headers.RetryAfter;
+ if (retryAfter?.Delta is { } delta)
+ notBefore = now + delta;
+ else if (retryAfter?.Date is { } date)
+ notBefore = date;
+ else if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining) &&
+ remaining.FirstOrDefault()?.Trim() == "0" &&
+ response.Headers.TryGetValues("X-RateLimit-Reset", out var reset) &&
+ long.TryParse(reset.FirstOrDefault(), NumberStyles.None, CultureInfo.InvariantCulture, out var epoch) &&
+ epoch is >= 0 and <= 253402300799)
+ notBefore = DateTimeOffset.FromUnixTimeSeconds(epoch);
+
+ if (notBefore is null) return null;
+ if (notBefore < now) notBefore = now;
+ var latest = now + MaxRetryAfter;
+ return notBefore > latest ? latest : notBefore;
+ }
+
+ private readonly record struct BodyRead(byte[]? Bytes, string? Error);
+
+ private static async Task ReadCappedAsync(HttpResponseMessage response, CancellationToken ct)
+ {
+ var declared = response.Content.Headers.ContentLength;
+ if (declared is > MaxBodyBytes) return new BodyRead(null, "response too large");
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ using var buffer = new MemoryStream();
+ var chunk = new byte[16 * 1024];
+ while (true)
+ {
+ var n = await stream.ReadAsync(chunk, ct).ConfigureAwait(false);
+ if (n == 0) break;
+ if (buffer.Length + n > MaxBodyBytes) return new BodyRead(null, "response too large");
+ buffer.Write(chunk, 0, n);
+ }
+
+ if (declared is { } d && d != buffer.Length) return new BodyRead(null, "truncated response");
+ return new BodyRead(buffer.ToArray(), null);
+ }
+
+ ///
+ /// Parse GitHub's JSON array. Returns null (with a reason) for anything that is not a
+ /// list of release objects. Drafts and pre-releases are skipped, and so is any release whose
+ /// flags or tag are not what GitHub documents: when in doubt a release is NOT offered.
+ ///
+ internal static List? ParseReleases(byte[] utf8, out string? error)
+ {
+ error = null;
+
+ string text;
+ try
+ {
+ // A byte order mark is legal at the start of a UTF-8 document but is not part of its text.
+ var start = utf8.Length >= 3 && utf8[0] == 0xEF && utf8[1] == 0xBB && utf8[2] == 0xBF ? 3 : 0;
+ text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true).GetString(utf8, start, utf8.Length - start);
+ }
+ catch (DecoderFallbackException)
+ {
+ error = "invalid UTF-8";
+ return null;
+ }
+
+ JsonDocument doc;
+ try
+ {
+ doc = JsonDocument.Parse(text, new JsonDocumentOptions { MaxDepth = 32 });
+ }
+ catch (JsonException)
+ {
+ error = "invalid JSON";
+ return null;
+ }
+
+ using (doc)
+ {
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Array)
+ {
+ error = "unexpected JSON shape";
+ return null;
+ }
+
+ var releases = new List();
+ var objects = 0;
+ foreach (var item in root.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.Object) continue;
+ objects++;
+ if (releases.Count >= MaxReleases) continue;
+ if (ParseRelease(item) is { } release) releases.Add(release);
+ }
+
+ // A non-empty array with no objects in it is some other API's answer, not an empty list.
+ if (objects == 0 && root.GetArrayLength() > 0)
+ {
+ error = "unexpected JSON shape";
+ return null;
+ }
+ return releases;
+ }
+ }
+
+ private static GitHubRelease? ParseRelease(JsonElement e)
+ {
+ if (!TryBool(e, "draft", out var draft) || !TryBool(e, "prerelease", out var prerelease)) return null;
+ if (draft || prerelease) return null;
+
+ var tag = String(e, "tag_name");
+ if (string.IsNullOrWhiteSpace(tag) || tag.Length > MaxTagLength) return null;
+
+ DateTimeOffset? published = null;
+ if (String(e, "published_at") is { } p &&
+ DateTimeOffset.TryParse(p, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
+ published = parsed;
+
+ var target = String(e, "target_commitish");
+ if (target is { Length: > MaxTagLength }) target = null;
+
+ var body = String(e, "body");
+ if (body is { Length: > MaxBodyChars }) body = body[..MaxBodyChars];
+
+ var assets = new List();
+ if (e.TryGetProperty("assets", out var a) && a.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var asset in a.EnumerateArray())
+ {
+ if (assets.Count >= MaxAssets) break;
+ if (asset.ValueKind != JsonValueKind.Object) continue;
+ var name = String(asset, "name");
+ if (string.IsNullOrEmpty(name) || name.Length > 256) continue;
+ var digest = String(asset, "digest");
+ assets.Add(new GitHubAsset(name, digest is { Length: <= 128 } ? digest : null));
+ }
+ }
+
+ return new GitHubRelease(tag, published, target, body, assets, draft, prerelease);
+ }
+
+ private static string? String(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+
+ // The flag must be a real boolean; a missing or odd value makes the release unusable.
+ private static bool TryBool(JsonElement e, string name, out bool value)
+ {
+ value = false;
+ if (!e.TryGetProperty(name, out var v)) return false;
+ if (v.ValueKind == JsonValueKind.True) { value = true; return true; }
+ return v.ValueKind == JsonValueKind.False;
+ }
+}
diff --git a/src/MultiSeat.Service/Updates/InstalledVersions.cs b/src/MultiSeat.Service/Updates/InstalledVersions.cs
new file mode 100644
index 0000000..587ca0b
--- /dev/null
+++ b/src/MultiSeat.Service/Updates/InstalledVersions.cs
@@ -0,0 +1,273 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace MultiSeat.Service.Updates;
+
+/// What an ApolloVibe check found, plus the (possibly refreshed) hash cache to store.
+public sealed record ApolloDetectionResult(InstalledDetection Detection, InstalledExeHash? HashCache);
+
+///
+/// Works out which release of each component is installed on this host. Local only: nothing
+/// here touches the network, and the hash of sunshine.exe is never sent anywhere.
+///
+/// MultiSeat reads its own assembly. ApolloVibe cannot: its exe reports 2026.6.1 for ms3
+/// through ms6 (the -msN is never compiled in) and /serverinfo reports a constant,
+/// so it is identified by the bytes of sunshine.exe. MoonlightVibe runs on other devices
+/// and has no detector at all.
+///
+public static class InstalledVersions
+{
+ private const long MaxMarkerBytes = 64 * 1024;
+
+ // "2026.6.1", "2026.6.1.681e36a8", "2026.6.1.dirty", "2026.6.1.681e36a8.dirty". The hash is at
+ // least 7 hex digits so a numeric fourth part (a Windows file version "2026.6.1.0") is not one.
+ private static readonly Regex PeVersion = new(
+ @"^([0-9]+\.[0-9]+\.[0-9]+)(?:\.([0-9A-Fa-f]{7,40}))?(?:\.dirty)?\z",
+ RegexOptions.CultureInvariant);
+
+ private static readonly Regex FullSha = new("^[0-9A-Fa-f]{40}\\z", RegexOptions.CultureInvariant);
+
+ // ── MultiSeat ────────────────────────────────────────────────────
+
+ ///
+ /// The informational version of the RUNNING service assembly (for example
+ /// 0.6.19+b7e6d9539), the same attribute --config prints. The running binary is
+ /// what matters: after an upgrade that has not restarted the service, it is the old one.
+ ///
+ public static string? ReadRunningInformationalVersion() =>
+ typeof(InstalledVersions).Assembly.GetCustomAttribute()?.InformationalVersion;
+
+ ///
+ /// Parse a MultiSeat informational version. 1.0.0 is what a build without
+ /// version.txt reports and matches no release, so it is "unknown", as is anything
+ /// that does not parse.
+ ///
+ public static InstalledDetection DetectMultiSeat(string? informationalVersion)
+ {
+ var v = ReleaseVersion.TryParse(UpdateComponent.MultiSeat, informationalVersion);
+ if (v is null)
+ return new InstalledDetection(DetectionOutcome.Unknown, null, null,
+ "MultiSeat's own version could not be read.");
+
+ if (v.Display == "1.0.0")
+ return new InstalledDetection(DetectionOutcome.Unknown, null, null,
+ "This MultiSeat build carries the placeholder version 1.0.0, so it cannot be matched to a release.");
+
+ var shortBuild = v.Build is { Length: > 0 } b ? b[..Math.Min(7, b.Length)] : null;
+ var display = shortBuild is null ? v.Display : $"{v.Display} ({shortBuild})";
+ return new InstalledDetection(DetectionOutcome.Identified, v,
+ new InstalledInfo(v.Display, display, InstalledSource.Assembly, null), null);
+ }
+
+ // ── ApolloVibe ───────────────────────────────────────────────────
+
+ ///
+ /// Identify the installed ApolloVibe, in this order (first hit wins):
+ /// not installed; marker release.json whose recorded hash equals the real one;
+ /// a marker that no longer matches (marker-modified); SHA-256 equal to a release's;
+ /// same source commit as a release (commit-match, never an exact release); else unknown.
+ /// Never throws: a file that cannot be read or hashed is "unknown" with a note.
+ ///
+ /// The caller passes MultiSeat:ApolloExePath.
+ /// Parsed candidates, with and .
+ /// The stored hash cache, reused while path, size and mtime match.
+ /// Reads the PE ProductVersion; injected for tests. Null uses .
+ public static ApolloDetectionResult DetectApollo(
+ string exePath,
+ IReadOnlyCollection releases,
+ InstalledExeHash? cachedHash,
+ Func? readProductVersion = null)
+ {
+ try
+ {
+ return DetectApolloCore(exePath, releases, cachedHash, readProductVersion ?? ReadProductVersion);
+ }
+ catch (Exception ex)
+ {
+ return new ApolloDetectionResult(
+ new InstalledDetection(DetectionOutcome.Unknown, null, null,
+ "Could not check the installed ApolloVibe (" + ex.GetType().Name + ")."),
+ cachedHash);
+ }
+ }
+
+ private static ApolloDetectionResult DetectApolloCore(
+ string exePath, IReadOnlyCollection releases, InstalledExeHash? cachedHash,
+ Func readProductVersion)
+ {
+ if (string.IsNullOrWhiteSpace(exePath) || !File.Exists(exePath))
+ {
+ return new ApolloDetectionResult(
+ new InstalledDetection(DetectionOutcome.NotInstalled, null, null, "Not found at " + exePath + "."),
+ cachedHash);
+ }
+
+ var hashResult = GetHash(exePath, cachedHash);
+ if (hashResult.Hash is null)
+ {
+ return new ApolloDetectionResult(
+ new InstalledDetection(DetectionOutcome.Unknown, null, null,
+ "Could not read sunshine.exe to identify it (" + hashResult.Error + ")."),
+ cachedHash);
+ }
+
+ var hash = hashResult.Hash;
+ var cache = hashResult.Cache;
+
+ // 1 and 2: the marker. Its tag is trusted only while the exe still hashes to what it recorded.
+ ReleaseVersion? modified = null;
+ var marker = ReadMarker(exePath);
+ if (marker is not null)
+ {
+ var tagVersion = ReleaseVersion.TryParse(UpdateComponent.ApolloVibe, marker.Tag);
+ if (tagVersion is not null)
+ {
+ if (string.Equals(marker.Sha256, hash, StringComparison.OrdinalIgnoreCase))
+ {
+ return Done(DetectionOutcome.Identified, tagVersion, InstalledSource.Marker, null, cache);
+ }
+ // The marker is refuted, not discarded: exact release bytes or a source-commit match
+ // below say more about THIS binary than a stale marker does, so they are tried first.
+ modified = tagVersion;
+ }
+ }
+
+ // 3: exact bytes published in a release's notes.
+ var table = ApolloHashTable.Build(releases);
+ if (table.TryGetValue(hash, out var byHash) &&
+ ReleaseVersion.TryParse(UpdateComponent.ApolloVibe, byHash.Tag) is { } hashVersion)
+ {
+ return Done(DetectionOutcome.Identified, hashVersion, InstalledSource.ReleaseHash, null, cache);
+ }
+
+ // 4: same source commit. Says nothing about the binary, so it is labelled and never "release-hash".
+ var pe = readProductVersion(exePath);
+ var commit = ParsePeCommit(pe);
+ if (commit is not null)
+ {
+ ReleaseVersion? best = null;
+ foreach (var r in releases)
+ {
+ if (r.TargetCommit is null || !FullSha.IsMatch(r.TargetCommit)) continue;
+ if (!r.TargetCommit.StartsWith(commit, StringComparison.OrdinalIgnoreCase)) continue;
+ var v = ReleaseVersion.TryParse(UpdateComponent.ApolloVibe, r.Tag);
+ if (v is not null && (best is null || v.CompareTo(best) > 0)) best = v;
+ }
+ if (best is not null)
+ {
+ return Done(DetectionOutcome.Identified, best, InstalledSource.CommitMatch,
+ $"Built from the same source commit as {best.Display}. The exe itself is not that release.", cache);
+ }
+ }
+
+ // 5: the marker names a release but the exe is no longer that file and nothing else identifies it.
+ if (modified is not null)
+ {
+ return Done(DetectionOutcome.Identified, modified, InstalledSource.MarkerModified,
+ $"Based on {modified.Display}, but sunshine.exe was replaced after install.", cache);
+ }
+
+ // 6: installed, but not tied to anything published. The PE "2026.6.1" is deliberately not
+ // used: it is the same for ms3 to ms6.
+ return new ApolloDetectionResult(
+ new InstalledDetection(DetectionOutcome.Unknown, null, null,
+ "Built locally or not a published release (no hash match)."),
+ cache);
+ }
+
+ private static ApolloDetectionResult Done(
+ DetectionOutcome outcome, ReleaseVersion version, InstalledSource source, string? note, InstalledExeHash? cache) =>
+ new(new InstalledDetection(outcome, version,
+ new InstalledInfo(version.Display, version.Display, source, note), note),
+ cache);
+
+ /// The commit prefix in a PE ProductVersion like 2026.6.1.681e36a8.dirty, or null when it has none.
+ internal static string? ParsePeCommit(string? productVersion)
+ {
+ if (string.IsNullOrWhiteSpace(productVersion)) return null;
+ var m = PeVersion.Match(productVersion.Trim());
+ return m.Success && m.Groups[2].Success ? m.Groups[2].Value : null;
+ }
+
+ private static string? ReadProductVersion(string path)
+ {
+ try { return FileVersionInfo.GetVersionInfo(path).ProductVersion; }
+ catch { return null; }
+ }
+
+ private sealed record Marker(string Tag, string Sha256);
+
+ // release.json beside sunshine.exe: {"tag": "...", "commit": "...", "sunshineSha256": "..."}.
+ // Read as untrusted: size-capped, tolerant of extra fields, ignored when malformed.
+ private static Marker? ReadMarker(string exePath)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(Path.GetFullPath(exePath));
+ if (dir is null) return null;
+ var path = Path.Combine(dir, Shared.Constants.ApolloReleaseMarkerFileName);
+ var info = new FileInfo(path);
+ if (!info.Exists || info.Length > MaxMarkerBytes) return null;
+
+ using var doc = JsonDocument.Parse(File.ReadAllBytes(path), new JsonDocumentOptions { MaxDepth = 8, AllowTrailingCommas = true });
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
+ var tag = Str(doc.RootElement, "tag");
+ var sha = Str(doc.RootElement, "sunshineSha256");
+ if (tag is null || !ApolloHashTable.IsSha256(sha)) return null;
+ return new Marker(tag, sha!);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static string? Str(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+
+ private readonly record struct HashResult(string? Hash, InstalledExeHash? Cache, string? Error);
+
+ ///
+ /// SHA-256 of the exe, from the cache when path, size and mtime are unchanged. The file is
+ /// opened with FileShare.ReadWrite | FileShare.Delete because a running Apollo holds it.
+ ///
+ private static HashResult GetHash(string path, InstalledExeHash? cached)
+ {
+ try
+ {
+ var full = Path.GetFullPath(path);
+ var before = new FileInfo(full);
+ var size = before.Length;
+ var mtime = before.LastWriteTimeUtc;
+
+ if (cached is not null &&
+ string.Equals(cached.Path, full, StringComparison.OrdinalIgnoreCase) &&
+ cached.Size == size && cached.MtimeUtc == mtime &&
+ ApolloHashTable.IsSha256(cached.Sha256))
+ {
+ return new HashResult(cached.Sha256.ToLowerInvariant(), cached, null);
+ }
+
+ string hex;
+ using (var stream = new FileStream(full, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite | FileShare.Delete, bufferSize: 81920))
+ {
+ hex = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
+ }
+
+ // If the file changed while it was being read, the hash is still the best answer for
+ // this check but must not be cached against stats it no longer matches.
+ var after = new FileInfo(full);
+ var stable = after.Length == size && after.LastWriteTimeUtc == mtime;
+ var cache = stable ? new InstalledExeHash { Path = full, Size = size, MtimeUtc = mtime, Sha256 = hex } : null;
+ return new HashResult(hex, cache, null);
+ }
+ catch (Exception ex)
+ {
+ return new HashResult(null, null, ex.GetType().Name);
+ }
+ }
+}
diff --git a/src/MultiSeat.Service/Updates/ReleaseSelector.cs b/src/MultiSeat.Service/Updates/ReleaseSelector.cs
new file mode 100644
index 0000000..c454e74
--- /dev/null
+++ b/src/MultiSeat.Service/Updates/ReleaseSelector.cs
@@ -0,0 +1,86 @@
+namespace MultiSeat.Service.Updates;
+
+///
+/// Picks the release to compare against, and classifies installed versus latest. Pure logic.
+///
+/// The newest release is the one with the highest parsed version, never the first in the list
+/// and never the most recently created: GitHub's list is ordered by creation date, so a hotfix
+/// cut for an older line would otherwise become "latest" and read as a downgrade.
+///
+public static class ReleaseSelector
+{
+ ///
+ /// Turn GitHub's releases into the compact candidates the state file keeps. Drafts and
+ /// pre-releases are dropped here as well as in the client, and so are tags that do not match
+ /// the component's grammar (test-*, legacy moonlightvibe-win-*). For ApolloVibe
+ /// the sunshine.exe hash is read from the notes; the notes themselves are not kept.
+ ///
+ public static List BuildCandidates(UpdateComponent component, IEnumerable releases)
+ {
+ var result = new List();
+ foreach (var r in releases)
+ {
+ if (r.Draft || r.PreRelease) continue;
+ if (ReleaseVersion.TryParse(component, r.TagName) is null) continue;
+
+ result.Add(new ReleaseCandidate
+ {
+ Tag = r.TagName,
+ PublishedAt = r.PublishedAt,
+ TargetCommit = r.TargetCommitish,
+ SunshineSha256 = component == UpdateComponent.ApolloVibe
+ ? ApolloHashTable.ExtractSunshineHash(r.Body)
+ : null,
+ });
+ }
+ return result;
+ }
+
+ ///
+ /// The candidate with the highest version, or null when none parses. Ties (the same version
+ /// under two tags) go to the later publish date, then to the earlier list position, so the
+ /// answer does not depend on hash-table or sort stability.
+ ///
+ public static ReleaseCandidate? SelectLatest(UpdateComponent component, IEnumerable candidates)
+ {
+ ReleaseCandidate? best = null;
+ ReleaseVersion? bestVersion = null;
+
+ foreach (var c in candidates)
+ {
+ var v = ReleaseVersion.TryParse(component, c.Tag);
+ if (v is null) continue;
+
+ if (bestVersion is null)
+ {
+ best = c;
+ bestVersion = v;
+ continue;
+ }
+
+ var cmp = v.CompareTo(bestVersion);
+ if (cmp > 0 || (cmp == 0 && (c.PublishedAt ?? DateTimeOffset.MinValue) > (best!.PublishedAt ?? DateTimeOffset.MinValue)))
+ {
+ best = c;
+ bestVersion = v;
+ }
+ }
+ return best;
+ }
+
+ ///
+ /// Compare an installed version with the newest release. No latest means
+ /// whatever is installed; an installed version that
+ /// could not be identified is , never an update.
+ /// Installed greater than latest is .
+ ///
+ public static UpdateStatus Classify(ReleaseVersion? installed, ReleaseVersion? latest)
+ {
+ if (latest is null) return UpdateStatus.Unavailable;
+ if (installed is null) return UpdateStatus.UnknownInstalled;
+
+ var cmp = installed.CompareTo(latest);
+ if (cmp < 0) return UpdateStatus.UpdateAvailable;
+ return cmp > 0 ? UpdateStatus.Ahead : UpdateStatus.UpToDate;
+ }
+}
diff --git a/src/MultiSeat.Service/Updates/ReleaseVersion.cs b/src/MultiSeat.Service/Updates/ReleaseVersion.cs
new file mode 100644
index 0000000..7078602
--- /dev/null
+++ b/src/MultiSeat.Service/Updates/ReleaseVersion.cs
@@ -0,0 +1,213 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace MultiSeat.Service.Updates;
+
+///
+/// A release version parsed under a closed, per-component grammar and compared as a numeric
+/// tuple. Anything that does not match the grammar is "not a version" (
+/// returns null); nothing here throws on garbage input.
+///
+/// Why not strings or a SemVer library: 0.6.9 sorts above 0.6.19 as a string, and
+/// under SemVer the pre-release identifiers ms10 and ms9 compare as text, which puts
+/// ms10 BELOW ms9. ApolloVibe's -msN and the legacy -multiseat.N are therefore parsed
+/// to an integer and compared as the fourth tuple element. Only MultiSeat has real SemVer
+/// pre-releases (0.7.0-rc1 sorts below 0.7.0). Build metadata (+sha) is
+/// kept for display and ignored when comparing.
+///
+/// Grammars (after trimming, an optional lowercase v):
+/// MultiSeat major.minor.patch[-prerelease][+build]
+/// ApolloVibe year.monthday.patch-ms{N} or year.monthday.patch-multiseat.{N}
+/// MoonlightVibe 2 to 4 dot-separated numbers, missing parts count as 0
+/// Numbers are parsed as ; a number that overflows rejects the whole version.
+///
+public sealed class ReleaseVersion : IComparable, IEquatable
+{
+ private const int MaxLength = 128;
+ private const int ComparedParts = 4;
+
+ private static readonly Regex MultiSeatGrammar = new(
+ @"^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?\z",
+ RegexOptions.CultureInvariant);
+
+ private static readonly Regex ApolloGrammar = new(
+ @"^v?([0-9]+)\.([0-9]+)\.([0-9]+)-(ms|multiseat\.)([0-9]+)\z",
+ RegexOptions.CultureInvariant);
+
+ private static readonly Regex MoonlightGrammar = new(
+ @"^v?([0-9]+(?:\.[0-9]+){1,3})\z",
+ RegexOptions.CultureInvariant);
+
+ private readonly long[] _parts;
+ private readonly string[] _preRelease;
+
+ private ReleaseVersion(UpdateComponent component, long[] parts, string display, string[] preRelease, string? build)
+ {
+ Component = component;
+ _parts = parts;
+ Display = display;
+ _preRelease = preRelease;
+ Build = build;
+ }
+
+ public UpdateComponent Component { get; }
+
+ /// The version without a v prefix or build metadata, as shown to a person: 0.6.19, 2026.6.1-ms6, 6.3.9.
+ public string Display { get; }
+
+ /// MultiSeat build metadata after + (usually a commit), or null. Never compared.
+ public string? Build { get; }
+
+ /// True for a MultiSeat pre-release such as 0.7.0-rc1.
+ public bool IsPreRelease => _preRelease.Length > 0;
+
+ /// The numeric key, padded to four elements.
+ public IReadOnlyList Key => _parts;
+
+ /// Parse under the grammar of , or return null.
+ public static ReleaseVersion? TryParse(UpdateComponent component, string? text)
+ {
+ if (text is null) return null;
+ var s = text.Trim();
+ if (s.Length == 0 || s.Length > MaxLength) return null;
+
+ return component switch
+ {
+ UpdateComponent.MultiSeat => ParseMultiSeat(s),
+ UpdateComponent.ApolloVibe => ParseApollo(s),
+ UpdateComponent.MoonlightVibe => ParseMoonlight(s),
+ _ => null,
+ };
+ }
+
+ private static ReleaseVersion? ParseMultiSeat(string s)
+ {
+ var m = MultiSeatGrammar.Match(s);
+ if (!m.Success) return null;
+
+ if (!TryNumbers(m, 1, 3, out var nums)) return null;
+
+ string[] pre = [];
+ if (m.Groups[4].Success)
+ {
+ pre = m.Groups[4].Value.Split('.');
+ // "1.0.0-" and "1.0.0-a..b": an empty identifier is not a pre-release.
+ if (pre.Any(p => p.Length == 0)) return null;
+ }
+
+ var build = m.Groups[5].Success ? m.Groups[5].Value : null;
+ var display = $"{nums[0]}.{nums[1]}.{nums[2]}" + (pre.Length > 0 ? "-" + string.Join('.', pre) : "");
+ return new ReleaseVersion(UpdateComponent.MultiSeat, Pad(nums), display, pre, build);
+ }
+
+ private static ReleaseVersion? ParseApollo(string s)
+ {
+ var m = ApolloGrammar.Match(s);
+ if (!m.Success) return null;
+ if (!TryNumbers(m, 1, 3, out var nums)) return null;
+ if (!long.TryParse(m.Groups[5].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var n)) return null;
+
+ // Both spellings feed N; the legacy one keeps its own spelling for display.
+ var suffix = m.Groups[4].Value == "ms" ? $"ms{n}" : $"multiseat.{n}";
+ return new ReleaseVersion(UpdateComponent.ApolloVibe, [nums[0], nums[1], nums[2], n],
+ $"{nums[0]}.{nums[1]}.{nums[2]}-{suffix}", [], null);
+ }
+
+ private static ReleaseVersion? ParseMoonlight(string s)
+ {
+ var m = MoonlightGrammar.Match(s);
+ if (!m.Success) return null;
+
+ var pieces = m.Groups[1].Value.Split('.');
+ var nums = new long[pieces.Length];
+ for (var i = 0; i < pieces.Length; i++)
+ {
+ if (!long.TryParse(pieces[i], NumberStyles.None, CultureInfo.InvariantCulture, out nums[i])) return null;
+ }
+ return new ReleaseVersion(UpdateComponent.MoonlightVibe, Pad(nums), string.Join('.', nums), [], null);
+ }
+
+ private static bool TryNumbers(Match m, int firstGroup, int count, out long[] nums)
+ {
+ nums = new long[count];
+ for (var i = 0; i < count; i++)
+ {
+ if (!long.TryParse(m.Groups[firstGroup + i].Value, NumberStyles.None, CultureInfo.InvariantCulture, out nums[i]))
+ return false;
+ }
+ return true;
+ }
+
+ private static long[] Pad(long[] nums)
+ {
+ var padded = new long[ComparedParts];
+ Array.Copy(nums, padded, Math.Min(nums.Length, ComparedParts));
+ return padded;
+ }
+
+ ///
+ /// Numeric tuple order; a MultiSeat pre-release sorts below the same triple. Versions of
+ /// different components are not comparable and throw : that is
+ /// a programming error, not bad input.
+ ///
+ public int CompareTo(ReleaseVersion? other)
+ {
+ if (other is null) return 1;
+ if (other.Component != Component)
+ throw new ArgumentException("Versions of different components cannot be compared.", nameof(other));
+
+ for (var i = 0; i < ComparedParts; i++)
+ {
+ var c = _parts[i].CompareTo(other._parts[i]);
+ if (c != 0) return c;
+ }
+ return ComparePreRelease(_preRelease, other._preRelease);
+ }
+
+ // SemVer section 11: no pre-release outranks any pre-release; otherwise identifier by
+ // identifier, numbers numerically and below text, text in ASCII order, fewer fields lower.
+ private static int ComparePreRelease(string[] a, string[] b)
+ {
+ if (a.Length == 0 && b.Length == 0) return 0;
+ if (a.Length == 0) return 1;
+ if (b.Length == 0) return -1;
+
+ for (var i = 0; i < Math.Min(a.Length, b.Length); i++)
+ {
+ var aNum = IsDigits(a[i]);
+ var bNum = IsDigits(b[i]);
+ int c;
+ if (aNum && bNum) c = CompareDigitStrings(a[i], b[i]);
+ else if (aNum) c = -1;
+ else if (bNum) c = 1;
+ else c = string.CompareOrdinal(a[i], b[i]);
+ if (c != 0) return Math.Sign(c);
+ }
+ return a.Length.CompareTo(b.Length);
+ }
+
+ private static bool IsDigits(string s) => s.All(ch => ch is >= '0' and <= '9');
+
+ // Digit strings can be longer than a long, so compare by magnitude without converting.
+ private static int CompareDigitStrings(string a, string b)
+ {
+ a = a.TrimStart('0');
+ b = b.TrimStart('0');
+ return a.Length != b.Length ? a.Length.CompareTo(b.Length) : string.CompareOrdinal(a, b);
+ }
+
+ public bool Equals(ReleaseVersion? other) =>
+ other is not null && other.Component == Component && CompareTo(other) == 0;
+
+ public override bool Equals(object? obj) => obj is ReleaseVersion v && Equals(v);
+
+ public override int GetHashCode() => HashCode.Combine(Component, _parts[0], _parts[1], _parts[2], _parts[3],
+ string.Join('.', _preRelease.Select(p => IsDigits(p) ? p.TrimStart('0') : p)));
+
+ public override string ToString() => Display;
+
+ public static bool operator <(ReleaseVersion a, ReleaseVersion b) => a.CompareTo(b) < 0;
+ public static bool operator >(ReleaseVersion a, ReleaseVersion b) => a.CompareTo(b) > 0;
+ public static bool operator <=(ReleaseVersion a, ReleaseVersion b) => a.CompareTo(b) <= 0;
+ public static bool operator >=(ReleaseVersion a, ReleaseVersion b) => a.CompareTo(b) >= 0;
+}
diff --git a/src/MultiSeat.Service/Updates/UpdateModels.cs b/src/MultiSeat.Service/Updates/UpdateModels.cs
new file mode 100644
index 0000000..18249a6
--- /dev/null
+++ b/src/MultiSeat.Service/Updates/UpdateModels.cs
@@ -0,0 +1,171 @@
+using System.Text.Json.Serialization;
+
+namespace MultiSeat.Service.Updates;
+
+/// The three projects the update check knows about. Closed on purpose: see .
+public enum UpdateComponent
+{
+ MultiSeat,
+ ApolloVibe,
+ MoonlightVibe,
+}
+
+///
+/// What the dashboard is told about one component. The member names are spelled out because the
+/// API's global enum converter writes C# names as they are (PascalCase), and the dashboard
+/// contract is upToDate, updateAvailable and so on.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum UpdateStatus
+{
+ /// Installed equals the newest published release.
+ [JsonStringEnumMemberName("upToDate")] UpToDate,
+ /// A newer published release exists.
+ [JsonStringEnumMemberName("updateAvailable")] UpdateAvailable,
+ /// Installed is newer than the newest published release (a local build before release).
+ [JsonStringEnumMemberName("ahead")] Ahead,
+ /// The component is installed but its release could not be identified. Never an alarm.
+ [JsonStringEnumMemberName("unknownInstalled")] UnknownInstalled,
+ /// The component is not installed where MultiSeat looks for it.
+ [JsonStringEnumMemberName("notInstalled")] NotInstalled,
+ /// MoonlightVibe: the host cannot see the client version, so only the latest release is shown.
+ [JsonStringEnumMemberName("latestOnly")] LatestOnly,
+ /// No data yet, or no release tag parsed.
+ [JsonStringEnumMemberName("unavailable")] Unavailable,
+ /// Update checks are off.
+ [JsonStringEnumMemberName("disabled")] Disabled,
+}
+
+/// How an installed version was learned. Serialized as assembly, marker, marker-modified, release-hash, commit-match.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum InstalledSource
+{
+ /// MultiSeat's own running assembly.
+ [JsonStringEnumMemberName("assembly")] Assembly,
+ /// A release.json marker whose recorded hash equals the real sunshine.exe.
+ [JsonStringEnumMemberName("marker")] Marker,
+ /// A marker exists but sunshine.exe no longer matches it: the binary was replaced.
+ [JsonStringEnumMemberName("marker-modified")] MarkerModified,
+ /// The SHA-256 of sunshine.exe equals the one published in a release's notes.
+ [JsonStringEnumMemberName("release-hash")] ReleaseHash,
+ /// Built from the same source commit as a release. Says nothing about the binary itself.
+ [JsonStringEnumMemberName("commit-match")] CommitMatch,
+}
+
+/// The installed side of one component, in the shape of the API's installed object.
+public sealed record InstalledInfo(string? Version, string? Display, InstalledSource Source, string? Note);
+
+/// The newest release, in the shape of the API's latest object. is built by us, never taken from GitHub.
+public sealed record LatestInfo(string Version, string Tag, DateTimeOffset? PublishedAt, string ReleaseUrl);
+
+///
+/// One component of GET /api/system/updates, field for field. Nothing in it comes
+/// straight from GitHub's JSON: versions are re-rendered from the parsed tag and the URL is built
+/// from the repository constant, so a hostile release cannot put text on the dashboard.
+///
+public sealed record ComponentUpdateStatus(
+ string Id,
+ string Name,
+ UpdateStatus Status,
+ InstalledInfo? Installed,
+ string? InstalledNote,
+ LatestInfo? Latest,
+ bool Announce,
+ DateTimeOffset? CheckedAt,
+ string? Error);
+
+/// Whether an installed component could be identified.
+public enum DetectionOutcome
+{
+ /// is set and can be compared with the latest release.
+ Identified,
+ /// It is installed but could not be tied to a release. Maps to .
+ Unknown,
+ /// Not there. Maps to .
+ NotInstalled,
+}
+
+/// What an installed-version detector found.
+public sealed record InstalledDetection(
+ DetectionOutcome Outcome,
+ ReleaseVersion? Version,
+ InstalledInfo? Installed,
+ string? Note);
+
+///
+/// A release as the state file keeps it: only what later steps need. No release notes and no
+/// URL from GitHub. is the ApolloVibe sunshine.exe hash found
+/// in the notes.
+///
+public sealed class ReleaseCandidate
+{
+ public string Tag { get; set; } = string.Empty;
+ public DateTimeOffset? PublishedAt { get; set; }
+ public string? TargetCommit { get; set; }
+ public string? SunshineSha256 { get; set; }
+}
+
+/// One asset of a GitHub release. is GitHub's sha256:... for the asset (the zip, not the files inside it).
+public sealed record GitHubAsset(string Name, string? Digest);
+
+///
+/// The parts of a GitHub release this feature reads. is needed only to find
+/// the ApolloVibe sunshine.exe hash and is never forwarded to the dashboard or the state
+/// file. Every string is untrusted text from the internet.
+///
+public sealed record GitHubRelease(
+ string TagName,
+ DateTimeOffset? PublishedAt,
+ string? TargetCommitish,
+ string? Body,
+ IReadOnlyList Assets,
+ bool Draft,
+ bool PreRelease);
+
+/// Repository names and URLs. Constants only: see Constants.cs for why nothing here is configurable.
+public static class UpdateRepos
+{
+ public static string Id(UpdateComponent c) => c switch
+ {
+ UpdateComponent.MultiSeat => "multiseat",
+ UpdateComponent.ApolloVibe => "apollovibe",
+ UpdateComponent.MoonlightVibe => "moonlightvibe",
+ _ => throw new ArgumentOutOfRangeException(nameof(c)),
+ };
+
+ public static string DisplayName(UpdateComponent c) => c switch
+ {
+ UpdateComponent.MultiSeat => "MultiSeat",
+ UpdateComponent.ApolloVibe => "ApolloVibe",
+ UpdateComponent.MoonlightVibe => "MoonlightVibe",
+ _ => throw new ArgumentOutOfRangeException(nameof(c)),
+ };
+
+ public static string Repo(UpdateComponent c) => c switch
+ {
+ UpdateComponent.MultiSeat => Shared.Constants.UpdateRepoMultiSeat,
+ UpdateComponent.ApolloVibe => Shared.Constants.UpdateRepoApolloVibe,
+ UpdateComponent.MoonlightVibe => Shared.Constants.UpdateRepoMoonlightVibe,
+ _ => throw new ArgumentOutOfRangeException(nameof(c)),
+ };
+
+ /// The one URL the client ever requests for a component.
+ public static Uri ReleaseListUri(UpdateComponent c) =>
+ new($"https://{Shared.Constants.UpdateApiHost}/repos/{Shared.Constants.UpdateRepoOwner}/{Repo(c)}/releases?per_page=30");
+
+ ///
+ /// Link to a release page, built from the repository constant and the tag, URL-encoded.
+ /// GitHub's own html_url is never used.
+ ///
+ public static string ReleaseUrl(UpdateComponent c, string tag) =>
+ $"https://github.com/{Shared.Constants.UpdateRepoOwner}/{Repo(c)}/releases/tag/{Uri.EscapeDataString(tag)}";
+}
+
+/// Clamping for UpdateCheckIntervalHours, applied where the value is used.
+public static class UpdateIntervals
+{
+ public static int ClampHours(int hours) =>
+ Math.Clamp(hours, Shared.Constants.MinUpdateCheckIntervalHours, Shared.Constants.MaxUpdateCheckIntervalHours);
+
+ public static TimeSpan ToInterval(int hours) => TimeSpan.FromHours(ClampHours(hours));
+}
diff --git a/src/MultiSeat.Service/Updates/UpdateStateStore.cs b/src/MultiSeat.Service/Updates/UpdateStateStore.cs
new file mode 100644
index 0000000..bf40389
--- /dev/null
+++ b/src/MultiSeat.Service/Updates/UpdateStateStore.cs
@@ -0,0 +1,244 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+using MultiSeat.Service.Configuration;
+
+namespace MultiSeat.Service.Updates;
+
+/// Backoff after failed checks, kept so a restart does not reset it.
+public sealed class BackoffState
+{
+ public int ConsecutiveFailures { get; set; }
+
+ /// No request before this time. Null means "not held back".
+ public DateTimeOffset? NotBefore { get; set; }
+}
+
+/// What the cache remembers about one repository.
+public sealed class RepoState
+{
+ /// The ETag of the last 200, verbatim (including a leading W/), sent back as If-None-Match.
+ public string? ETag { get; set; }
+
+ public DateTimeOffset? CheckedAt { get; set; }
+
+ public List Candidates { get; set; } = [];
+
+ ///
+ /// The newest version seen when the feature first worked, for components whose installed
+ /// version is unknown: only a release newer than this is worth a notice.
+ ///
+ public string? Baseline { get; set; }
+
+ public BackoffState Backoff { get; set; } = new();
+
+ /// The last failure, short and fixed-vocabulary. Null after a success.
+ public string? LastError { get; set; }
+}
+
+/// Cache of one hashed sunshine.exe, valid while path, size and modification time are unchanged.
+public sealed class InstalledExeHash
+{
+ public string Path { get; set; } = string.Empty;
+ public long Size { get; set; }
+ public DateTime MtimeUtc { get; set; }
+ public string Sha256 { get; set; } = string.Empty;
+}
+
+///
+/// The whole contents of update-check.json. Holds no secrets and no host or user data
+/// beyond the path of the file that was hashed.
+///
+public sealed class UpdateState
+{
+ public const int CurrentSchema = 1;
+
+ public int Schema { get; set; } = CurrentSchema;
+
+ /// Keyed by : multiseat, apollovibe, moonlightvibe.
+ public Dictionary Repos { get; set; } = new(StringComparer.Ordinal);
+
+ public InstalledExeHash? ApolloExeHash { get; set; }
+
+ public RepoState GetOrAdd(UpdateComponent component)
+ {
+ var id = UpdateRepos.Id(component);
+ if (!Repos.TryGetValue(id, out var repo))
+ Repos[id] = repo = new RepoState();
+ return repo;
+ }
+}
+
+///
+/// Reads and writes the update-check cache at a path it is given
+/// (Constants.DefaultUpdateStatePath in production). The cache is disposable: anything
+/// wrong with the file means "start empty", never an error, and a failed write is reported and
+/// swallowed so the check keeps running with what it has in memory.
+///
+/// Writes go through (a reader sees the old or the new file, never half)
+/// and are then restricted to SYSTEM and Administrators the way api-key.txt is. The
+/// restriction is applied after every write because the rename creates a new file object that
+/// inherits the folder's ACL.
+///
+public sealed class UpdateStateStore
+{
+ private const long MaxFileBytes = 4 * 1024 * 1024;
+ private const int MaxCandidatesPerRepo = 100;
+ private const int MaxStringLength = 512;
+
+ private static readonly Regex ETagShape = new("^(W/)?\"[\\x21\\x23-\\x7E]*\"\\z", RegexOptions.CultureInvariant);
+ private static readonly Regex Hex64 = new("^[0-9A-Fa-f]{64}\\z", RegexOptions.CultureInvariant);
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ MaxDepth = 16,
+ AllowTrailingCommas = true,
+ };
+
+ private readonly string _path;
+ private readonly Func, bool> _restrict;
+ private readonly Action? _warn;
+ private readonly object _writeLock = new();
+
+ /// Full path of the JSON file; its folder is created on the first save.
+ /// Applies the permissions; defaults to SecureFile.TryRestrictToSystemAndAdmins. Injected so tests can see it called.
+ /// Receives one short line when a load or save problem is swallowed.
+ public UpdateStateStore(string path, Func, bool>? restrictAcl = null, Action? warn = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ _path = path;
+ _restrict = restrictAcl ?? Storage.SecureFile.TryRestrictToSystemAndAdmins;
+ _warn = warn;
+ }
+
+ public string Path => _path;
+
+ ///
+ /// The stored state, or an empty one when the file is missing, unreadable, corrupt, holds the
+ /// wrong types, or carries a schema other than the current one (including a newer one: it
+ /// is not ours to interpret). Never throws.
+ ///
+ public UpdateState Load()
+ {
+ try
+ {
+ var info = new FileInfo(_path);
+ if (!info.Exists) return new UpdateState();
+ if (info.Length > MaxFileBytes)
+ {
+ Warn("update-check.json is too large; ignoring it");
+ return new UpdateState();
+ }
+
+ var bytes = File.ReadAllBytes(_path);
+ using var doc = JsonDocument.Parse(bytes, new JsonDocumentOptions { MaxDepth = 16, AllowTrailingCommas = true });
+ if (doc.RootElement.ValueKind != JsonValueKind.Object ||
+ !doc.RootElement.TryGetProperty("schema", out var schema) ||
+ schema.ValueKind != JsonValueKind.Number ||
+ !schema.TryGetInt32(out var version) ||
+ version != UpdateState.CurrentSchema)
+ {
+ Warn("update-check.json has an unknown schema; ignoring it");
+ return new UpdateState();
+ }
+
+ var state = doc.RootElement.Deserialize(JsonOptions);
+ return state is null ? new UpdateState() : Sanitize(state);
+ }
+ catch (Exception ex)
+ {
+ Warn("update-check.json could not be read (" + ex.GetType().Name + "); starting empty");
+ return new UpdateState();
+ }
+ }
+
+ ///
+ /// Write atomically and restrict the file. Returns false (after a
+ /// warning) if anything fails; never throws.
+ ///
+ public bool Save(UpdateState state)
+ {
+ try
+ {
+ state.Schema = UpdateState.CurrentSchema;
+ var json = JsonSerializer.Serialize(state, JsonOptions);
+
+ lock (_writeLock)
+ {
+ var dir = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(_path));
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+
+ AtomicFile.WriteAllText(_path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+ _restrict(_path, ex => Warn("could not restrict permissions on update-check.json (" + ex.GetType().Name + ")"));
+ }
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Warn("update-check.json could not be written (" + ex.GetType().Name + ")");
+ return false;
+ }
+ }
+
+ // Deserialized data is untrusted too (a hand-edited or damaged file): keep only what is the
+ // right shape, so later code never meets a header-injecting ETag, a 10 MB string or a
+ // component id it does not know.
+ private static UpdateState Sanitize(UpdateState state)
+ {
+ var clean = new UpdateState();
+ foreach (var component in Enum.GetValues())
+ {
+ if (!state.Repos.TryGetValue(UpdateRepos.Id(component), out var repo) || repo is null) continue;
+
+ var r = clean.GetOrAdd(component);
+ r.ETag = repo.ETag is not null && ETagShape.IsMatch(repo.ETag) ? repo.ETag : null;
+ r.CheckedAt = repo.CheckedAt;
+ r.Baseline = Cap(repo.Baseline);
+ r.LastError = Cap(repo.LastError);
+ r.Backoff = new BackoffState
+ {
+ ConsecutiveFailures = Math.Clamp(repo.Backoff?.ConsecutiveFailures ?? 0, 0, 1000),
+ NotBefore = repo.Backoff?.NotBefore,
+ };
+
+ foreach (var c in (repo.Candidates ?? []).Where(c => c is not null).Take(MaxCandidatesPerRepo))
+ {
+ if (string.IsNullOrWhiteSpace(c.Tag) || c.Tag.Length > MaxStringLength) continue;
+ r.Candidates.Add(new ReleaseCandidate
+ {
+ Tag = c.Tag,
+ PublishedAt = c.PublishedAt,
+ TargetCommit = Cap(c.TargetCommit),
+ SunshineSha256 = c.SunshineSha256 is not null && Hex64.IsMatch(c.SunshineSha256)
+ ? c.SunshineSha256.ToLowerInvariant() : null,
+ });
+ }
+ }
+
+ var h = state.ApolloExeHash;
+ if (h is not null && !string.IsNullOrWhiteSpace(h.Path) && h.Path.Length <= 1024 &&
+ h.Size >= 0 && Hex64.IsMatch(h.Sha256 ?? string.Empty))
+ {
+ clean.ApolloExeHash = new InstalledExeHash
+ {
+ Path = h.Path,
+ Size = h.Size,
+ MtimeUtc = h.MtimeUtc,
+ Sha256 = h.Sha256!.ToLowerInvariant(),
+ };
+ }
+ return clean;
+ }
+
+ private static string? Cap(string? s) => s is null ? null : s.Length <= MaxStringLength ? s : s[..MaxStringLength];
+
+ private void Warn(string message)
+ {
+ try { _warn?.Invoke(message); }
+ catch { /* a logger must never break the cache */ }
+ }
+}
diff --git a/src/MultiSeat.Service/appsettings.json b/src/MultiSeat.Service/appsettings.json
index d35ae28..0b28beb 100644
--- a/src/MultiSeat.Service/appsettings.json
+++ b/src/MultiSeat.Service/appsettings.json
@@ -49,7 +49,13 @@
"SharedGameLibraryDir": "C:\\MultiSeatGames",
"EnableEmulatorNetplay": true,
"SeedRetroArchNetplayConfig": false,
- "RetroArchConfigPath": ""
+ "RetroArchConfigPath": "",
+ // Update notifications. False means MultiSeat makes NO connection to the internet. True lets
+ // the service ask api.github.com, about every 12 hours, whether newer MultiSeat, ApolloVibe and
+ // MoonlightVibe releases exist: an unauthenticated GET with a generic User-Agent, nothing about
+ // this host. It only reads - it never downloads or installs anything. Opt in from
+ // appsettings.local.json, which outranks this file.
+ "UpdateCheckEnabled": false
},
"Logging": {
"LogLevel": {
diff --git a/src/MultiSeat.Shared/Constants.cs b/src/MultiSeat.Shared/Constants.cs
index 2305046..616069c 100644
--- a/src/MultiSeat.Shared/Constants.cs
+++ b/src/MultiSeat.Shared/Constants.cs
@@ -60,6 +60,24 @@ public static class Constants
public const string DefaultApolloConfigDir = @"C:\ProgramData\MultiSeat\apollo";
public const string DefaultMultiSeatConfigPath = @"C:\ProgramData\MultiSeat\multiseat-host.json";
+ // ── Update notifications ─────────────────────────────────────────
+ // Fixed on purpose and NOT configurable: a setting that could point the update check at
+ // another host or repository would turn an update notice into a generic outbound request
+ // primitive. See MultiSeat.Service.Updates.
+ public const string UpdateApiHost = "api.github.com";
+ public const string UpdateRepoOwner = "vibesoftwarecoder";
+ public const string UpdateRepoMultiSeat = "MultiSeat";
+ public const string UpdateRepoApolloVibe = "ApolloVibe";
+ public const string UpdateRepoMoonlightVibe = "MoonlightVibe";
+ // Generic on purpose: no version, no host name. GitHub rejects a request with no User-Agent.
+ public const string UpdateUserAgent = "MultiSeat-update-check";
+ public const string DefaultUpdateStatePath = @"C:\ProgramData\MultiSeat\update-check.json";
+ // File an ApolloVibe release package may carry beside sunshine.exe (tag, commit, sunshineSha256).
+ public const string ApolloReleaseMarkerFileName = "release.json";
+ public const int DefaultUpdateCheckIntervalHours = 12;
+ public const int MinUpdateCheckIntervalHours = 1;
+ public const int MaxUpdateCheckIntervalHours = 168;
+
// ── Account naming ───────────────────────────────────────────────
public const string AccountPrefix = "MultiSeatSeat"; // e.g., MultiSeatSeat01
public const string AccountGroup = "Users";
diff --git a/src/MultiSeat.Tests/MultiSeat.Tests.csproj b/src/MultiSeat.Tests/MultiSeat.Tests.csproj
index 9656f16..8f992f3 100644
--- a/src/MultiSeat.Tests/MultiSeat.Tests.csproj
+++ b/src/MultiSeat.Tests/MultiSeat.Tests.csproj
@@ -20,6 +20,11 @@
+
+
+
+
+
diff --git a/src/MultiSeat.Tests/Updates/ApolloHashTableTests.cs b/src/MultiSeat.Tests/Updates/ApolloHashTableTests.cs
new file mode 100644
index 0000000..98dae15
--- /dev/null
+++ b/src/MultiSeat.Tests/Updates/ApolloHashTableTests.cs
@@ -0,0 +1,265 @@
+using MultiSeat.Service.Updates;
+using Xunit;
+
+namespace MultiSeat.Tests.Updates;
+
+///
+/// The release-notes parser behind ApolloVibe identification. The fixtures apollovibe-ms3..ms6.json
+/// are the real notes of the published releases, fetched with `gh release view --json`.
+///
+public class ApolloHashTableTests
+{
+ // The sunshine.exe hashes as the real notes print them (upper case), lower-cased here.
+ // ms6's notes also carry the hash of the zip, FIRST: b39bdc29...ad5c. That must not be returned.
+ private const string Ms3 = "75467310fcabb262e47d707df79c105415d3a5ebcd7ce3f5225180ca14bc9056";
+ private const string Ms4 = "ccc693f69f0f7db12ab10fe22858b064b4db699a035753bf1a8dad628ddf3bff";
+ private const string Ms5 = "95083822664bca479c895b935b5a83efe0d2960986360cb773e8794cd02cccdf";
+ private const string Ms6 = "7e9fa1572579879fa98716c74753da7ec070f0c8209fe5177f2d73cd0c1b33cc";
+ private const string Ms6Zip = "b39bdc299988a712fbf5dad68c0847e05e65baf733b55194297c6b2c9374ad5c";
+
+ private static string H(char c) => new(c, 64);
+
+ [Theory]
+ [InlineData("apollovibe-ms3.json", Ms3)]
+ [InlineData("apollovibe-ms4.json", Ms4)]
+ [InlineData("apollovibe-ms5.json", Ms5)]
+ [InlineData("apollovibe-ms6.json", Ms6)]
+ public void RealReleaseNotes_YieldTheSunshineExeHash(string fixture, string expected)
+ {
+ Assert.Equal(expected, ApolloHashTable.ExtractSunshineHash(Fixtures.Notes(fixture)));
+ }
+
+ [Fact]
+ public void RealMs6Notes_NameTheZipFirst_AndTheParserStillTakesTheExe()
+ {
+ var notes = Fixtures.Notes("apollovibe-ms6.json");
+
+ // The fixture really has both, zip first: otherwise this test would prove nothing.
+ Assert.Contains(Ms6Zip, notes, StringComparison.OrdinalIgnoreCase);
+ Assert.True(notes.IndexOf(Ms6Zip, StringComparison.OrdinalIgnoreCase) < notes.IndexOf(Ms6, StringComparison.OrdinalIgnoreCase));
+
+ var hash = ApolloHashTable.ExtractSunshineHash(notes);
+
+ Assert.NotEqual(Ms6Zip, hash);
+ Assert.Equal(Ms6, hash);
+ }
+
+ [Fact]
+ public void RealNotes_ToleratedWithCrlfLineEndings()
+ {
+ var crlf = Fixtures.Notes("apollovibe-ms6.json").Replace("\r\n", "\n").Replace("\n", "\r\n");
+
+ Assert.Equal(Ms6, ApolloHashTable.ExtractSunshineHash(crlf));
+ }
+
+ [Fact]
+ public void RealNotes_ToleratedInUpperAndLowerCase()
+ {
+ var notes = Fixtures.Notes("apollovibe-ms6.json");
+
+ Assert.Equal(Ms6, ApolloHashTable.ExtractSunshineHash(notes.ToUpperInvariant()));
+ Assert.Equal(Ms6, ApolloHashTable.ExtractSunshineHash(notes.ToLowerInvariant()));
+ }
+
+ [Fact]
+ public void RealNotes_ThatLackAHash_GiveNoEntry()
+ {
+ // ms6's notes with the exe's hash line removed: only the zip's hash is left.
+ var notes = Fixtures.Notes("apollovibe-ms6.json");
+ var cut = notes[..notes.IndexOf("SHA-256 of `sunshine.exe`", StringComparison.Ordinal)];
+ Assert.Contains(Ms6Zip, cut, StringComparison.OrdinalIgnoreCase);
+
+ Assert.Null(ApolloHashTable.ExtractSunshineHash(cut));
+ }
+
+ [Fact]
+ public void NotesWithNoHashAtAll_GiveNoEntry()
+ {
+ // ms3 to ms6 have all four fixtures; strip every hash from the longest one.
+ var notes = System.Text.RegularExpressions.Regex.Replace(Fixtures.Notes("apollovibe-ms6.json"), "[0-9A-Fa-f]{64}", "");
+
+ Assert.Null(ApolloHashTable.ExtractSunshineHash(notes));
+ }
+
+ // ── Shapes of the label and the hash ──────────────────────────────
+
+ [Theory]
+ [InlineData("SHA-256 of `sunshine.exe`:\n`{0}`\n")]
+ [InlineData("SHA-256 of `sunshine.exe`:\r\n`{0}`\r\n")]
+ [InlineData("SHA-256 of sunshine.exe: {0}")]
+ [InlineData("sunshine.exe SHA-256: {0}")]
+ [InlineData("sunshine.exe (SHA-256): {0}")]
+ [InlineData("sunshine.exe: {0}")]
+ [InlineData("**sunshine.exe** `{0}`")]
+ [InlineData("SHA256 of SUNSHINE.EXE\n\n{0}")]
+ [InlineData("- sunshine.exe | {0} |")]
+ public void LabelledHash_IsFound_InCommonShapes(string template)
+ {
+ var hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+ Assert.Equal(hash, ApolloHashTable.ExtractSunshineHash(string.Format(template, hash)));
+ Assert.Equal(hash, ApolloHashTable.ExtractSunshineHash(string.Format(template, hash.ToUpperInvariant())));
+ }
+
+ [Theory]
+ // two hashes on one line, exe first and zip first
+ [InlineData("sunshine.exe: {E}, apollovibe-windows-x64.zip: {Z}")]
+ [InlineData("apollovibe-windows-x64.zip: {Z}, sunshine.exe: {E}")]
+ [InlineData("zip {Z} | sunshine.exe {E}")]
+ [InlineData("sunshine.exe {E} | zip SHA-256 {Z}")]
+ [InlineData("SHA-256 of `apollovibe-windows-x64.zip`:\n`{Z}`\n\nSHA-256 of `sunshine.exe`:\n`{E}`\n")]
+ [InlineData("Verify the download. SHA-256 of `sunshine.exe`:\n`{E}`\n\nSHA-256 of `apollovibe-windows-x64.zip`:\n`{Z}`\n")]
+ [InlineData("The sunshine.exe in this release changed.\n\nSHA-256 of `apollovibe-windows-x64.zip`: {Z}\nSHA-256 of `sunshine.exe`: {E}")]
+ public void TwoHashes_TheExeOneIsChosen_NotTheZip(string template)
+ {
+ var e = H('e');
+ var z = H('2');
+
+ var text = template.Replace("{E}", e).Replace("{Z}", z);
+
+ Assert.Equal(e, ApolloHashTable.ExtractSunshineHash(text));
+ }
+
+ [Fact]
+ public void AMentionOfTheExeWithoutAHash_DoesNotBorrowTheZipHashThatFollows()
+ {
+ var z = H('2');
+
+ // "sunshine.exe" appears in prose, then a hash for something that is not named by a file.
+ Assert.Null(ApolloHashTable.ExtractSunshineHash($"sunshine.exe changed in this release. SHA-256 of the release zip: {z}"));
+ Assert.Null(ApolloHashTable.ExtractSunshineHash($"sunshine.exe now waits for the desktop.\n\nChecksum: {z}"));
+ // and one named by its file
+ Assert.Null(ApolloHashTable.ExtractSunshineHash($"sunshine.exe is rebuilt.\nSHA-256 of `apollovibe-windows-x64.zip`: {z}"));
+ }
+
+ [Fact]
+ public void AProseMentionBeforeTheRealLabel_IsSkipped()
+ {
+ var e = H('e');
+ var notes = $"This build keeps sunshine.exe small.\n\nSHA-256 of `sunshine.exe`:\n`{e}`";
+
+ Assert.Equal(e, ApolloHashTable.ExtractSunshineHash(notes));
+ }
+
+ [Fact]
+ public void ALabelWithoutAnythingAfterIt_GivesNoEntry()
+ {
+ Assert.Null(ApolloHashTable.ExtractSunshineHash("see sunshine.exe"));
+ Assert.Null(ApolloHashTable.ExtractSunshineHash("sunshine.exe"));
+ Assert.Null(ApolloHashTable.ExtractSunshineHash("sunshine.exe:"));
+ }
+
+ [Theory]
+ [InlineData(63)]
+ [InlineData(65)]
+ [InlineData(32)]
+ [InlineData(128)]
+ public void ARunOfHexThatIsNot64Long_IsNotAHash(int length)
+ {
+ Assert.Null(ApolloHashTable.ExtractSunshineHash($"SHA-256 of `sunshine.exe`:\n`{new string('a', length)}`"));
+ }
+
+ [Fact]
+ public void NonHexCharacters_AreNotAHash()
+ {
+ Assert.Null(ApolloHashTable.ExtractSunshineHash("sunshine.exe: " + new string('g', 64)));
+ Assert.Null(ApolloHashTable.ExtractSunshineHash("sunshine.exe: " + new string('a', 63) + "z"));
+ }
+
+ [Fact]
+ public void ALabelFarFromTheHash_IsNotTrusted()
+ {
+ var far = "SHA-256 of sunshine.exe is below.\n" + new string('\n', 400) + H('a');
+
+ Assert.Null(ApolloHashTable.ExtractSunshineHash(far));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("just some prose")]
+ public void NothingToParse_GivesNoEntry(string? notes)
+ {
+ Assert.Null(ApolloHashTable.ExtractSunshineHash(notes));
+ }
+
+ [Fact]
+ public void HugeNotes_AreHandledQuickly_AndDoNotThrow()
+ {
+ // 2.8 MB of nothing but the label: every label has to be examined without rescanning the rest.
+ var notes = string.Concat(Enumerable.Repeat("sunshine.exe and more text. ", 100_000));
+
+ var clock = System.Diagnostics.Stopwatch.StartNew();
+ Assert.Null(ApolloHashTable.ExtractSunshineHash(notes));
+ Assert.True(clock.Elapsed < TimeSpan.FromSeconds(2), $"took {clock.Elapsed}");
+ }
+
+ // ── The table ─────────────────────────────────────────────────────
+
+ private static ReleaseCandidate Cand(string tag, string? sha) => new() { Tag = tag, SunshineSha256 = sha };
+
+ [Fact]
+ public void Build_MapsEachHashToItsRelease_CaseInsensitively()
+ {
+ var table = ApolloHashTable.Build([Cand("v2026.6.1-ms5", H('a')), Cand("v2026.6.1-ms6", H('b'))]);
+
+ Assert.Equal("v2026.6.1-ms5", table[H('a')].Tag);
+ Assert.Equal("v2026.6.1-ms6", table[new string('B', 64)].Tag);
+ Assert.False(table.ContainsKey(H('c')));
+ }
+
+ [Fact]
+ public void Build_SameBytesInTwoReleases_ReportsTheHigherVersion_InAnyOrder()
+ {
+ var a = ApolloHashTable.Build([Cand("v2026.6.1-ms5", H('a')), Cand("v2026.6.1-ms6", H('a'))]);
+ var b = ApolloHashTable.Build([Cand("v2026.6.1-ms6", H('a')), Cand("v2026.6.1-ms5", H('a'))]);
+ var c = ApolloHashTable.Build([Cand("v2026.6.1-ms9", H('a')), Cand("v2026.6.1-ms10", H('a'))]);
+
+ Assert.Equal("v2026.6.1-ms6", a[H('a')].Tag);
+ Assert.Equal("v2026.6.1-ms6", b[H('a')].Tag);
+ Assert.Equal("v2026.6.1-ms10", c[H('a')].Tag);
+ }
+
+ [Fact]
+ public void Build_SkipsCandidatesWithoutAUsableHashOrTag()
+ {
+ var table = ApolloHashTable.Build(
+ [
+ Cand("v2026.6.1-ms6", null),
+ Cand("v2026.6.1-ms5", "short"),
+ Cand("v2026.6.1-ms4", new string('z', 64)),
+ Cand("not-a-version", H('a')),
+ Cand("v2026.6.1-ms3", H('d')),
+ ]);
+
+ Assert.Equal([H('d')], table.Keys);
+ }
+
+ [Fact]
+ public void Build_FromTheRealReleases_HasOneEntryPerRelease()
+ {
+ var releases = new[] { "apollovibe-ms3.json", "apollovibe-ms4.json", "apollovibe-ms5.json", "apollovibe-ms6.json" }
+ .Select((f, i) => Cand($"v2026.6.1-ms{i + 3}", ApolloHashTable.ExtractSunshineHash(Fixtures.Notes(f))))
+ .ToList();
+
+ var table = ApolloHashTable.Build(releases);
+
+ Assert.Equal(4, table.Count);
+ Assert.Equal("v2026.6.1-ms6", table[Ms6].Tag);
+ Assert.Equal("v2026.6.1-ms3", table[Ms3].Tag);
+ Assert.False(table.ContainsKey(Ms6Zip)); // the zip's hash identifies nothing installed
+ }
+
+ [Fact]
+ public void IsSha256_AcceptsOnly64HexDigits()
+ {
+ Assert.True(ApolloHashTable.IsSha256(H('a')));
+ Assert.True(ApolloHashTable.IsSha256(H('F')));
+ Assert.False(ApolloHashTable.IsSha256(null));
+ Assert.False(ApolloHashTable.IsSha256(""));
+ Assert.False(ApolloHashTable.IsSha256(new string('a', 63)));
+ Assert.False(ApolloHashTable.IsSha256(new string('a', 64) + "\n"));
+ Assert.False(ApolloHashTable.IsSha256(new string('g', 64)));
+ }
+}
diff --git a/src/MultiSeat.Tests/Updates/Fixtures.cs b/src/MultiSeat.Tests/Updates/Fixtures.cs
new file mode 100644
index 0000000..38c7136
--- /dev/null
+++ b/src/MultiSeat.Tests/Updates/Fixtures.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+
+namespace MultiSeat.Tests.Updates;
+
+///
+/// Test data under Updates/Fixtures, copied beside the test assembly. The apollovibe-ms*.json
+/// files are the real `gh release view --json` output of the published releases (notes included),
+/// and github-releases-apollovibe.json is a real `releases?per_page=30` response, so the parsers
+/// are tested against what GitHub and the release author actually wrote.
+///
+internal static class Fixtures
+{
+ public static string Path(string name) =>
+ System.IO.Path.Combine(AppContext.BaseDirectory, "Updates", "Fixtures", name);
+
+ /// The release-notes text of a real ApolloVibe release fixture.
+ public static string Notes(string fixture)
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(Path(fixture)));
+ return doc.RootElement.GetProperty("body").GetString()!;
+ }
+}
diff --git a/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms3.json b/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms3.json
new file mode 100644
index 0000000..b70c821
--- /dev/null
+++ b/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms3.json
@@ -0,0 +1 @@
+{"body":"Fixes a hang that made ApolloVibe unusable on **every AMD GPU**.\n\n## The bug\n\nOn AMD hardware ApolloVibe wedged during encoder probing, at startup, before any\nclient connected. One CPU core pinned at 100% and the process never recovered.\nNVIDIA systems were unaffected, which is why this went unreported for so long.\n\n**Found and confirmed by [@treghart](https://github.com/treghart)** in\n[#2](https://github.com/vibesoftwarecoder/ApolloVibe/issues/2). He reported it, ran\nbuild after build on his own hardware across five failed hypotheses, uninstalled his\nworking setup to test a stock Sunshine control, and finally ran an instrumented build\nwhose log located the defect in a single run. Every one of my own theories before that\nwas wrong. This fix is his as much as anyone's.\n\n## The cause\n\nUpstream Sunshine commit `02036920`, \"build(deps): Update to FFmpeg 8.0 branch\n(#4143)\", changed two things at once: it added a drain to the encode session\ndestructor, and it bumped `third-party/build-deps` to the FFmpeg 8.0 the drain was\nwritten for.\n\nApollo took both halves correctly and carried them for seven weeks. Then merge\n`10fd290b` (2025-09-27) resolved `third-party/build-deps` back to `a9a7f863`, a pin\nfrom 2025-07-12 that neither parent of that merge points at. So every Apollo-derived\nbuild since then has run a drain written for FFmpeg 8.0 against a pre-8.0 FFmpeg. On\nAMF, `avcodec_send_frame(ctx, nullptr)` never returns. On NVENC it returns\nimmediately.\n\nThis release removes the drain. That costs nothing: it received packets into a local\nthat was discarded on the next line, so it moved no data — its only effect was letting\nthe encoder drain before a teardown that destroys everything anyway.\n\n## This is an upstream bug\n\nIt is inherited from Apollo, not introduced here. Filed upstream as\n[ClassicOldSong/Apollo#1588](https://github.com/ClassicOldSong/Apollo/issues/1588),\nwhere the correct fix is restoring the submodule pin rather than deleting the drain.\n\n## Testing\n\n- **AMD / AMF** — confirmed by @treghart on a Vega iGPU: encoder probe completes and\n Apollo reaches `Registered Apollo mDNS service` with the CPU idle.\n- **NVIDIA / NVENC** — regression-checked here on an RTX 3080. Six encode sessions\n created and destroyed during probe, including one deliberately failing session, with\n no hang. Full seat provision/serve/teardown passed 24 of 24 checks.\n- **Not tested:** teardown of a live client stream on this build. Both test hosts\n verified the probe path, which is where the fault was.\n\n## Install\n\n`apollovibe-windows-x64.zip` is the complete portable build — `sunshine.exe`,\n`assets/` and `tools/`. MultiSeat's installer pulls it from\n`releases/latest/download/` and needs no change.\n\nSHA-256 of `sunshine.exe`:\n`75467310FCABB262E47D707DF79C105415D3A5EBCD7CE3F5225180CA14BC9056`\n","isDraft":false,"isPrerelease":false,"publishedAt":"2026-09-06T21:12:40Z","tagName":"v2026.6.1-ms3","targetCommitish":"master"}
diff --git a/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms4.json b/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms4.json
new file mode 100644
index 0000000..5dd0e01
--- /dev/null
+++ b/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms4.json
@@ -0,0 +1 @@
+{"body":"Apollo now leaves a trace when its config fails at startup, and it exits with a nonzero code.\n\n## The problem\n\nWhen the config could not be applied, Apollo logged the reason before its log file existed. Started as a service or injected into a session, it slept about ten seconds and exited with no log. It also exited with code 0, so a supervisor saw a clean exit.\n\nThis is [#3](https://github.com/vibesoftwarecoder/ApolloVibe/issues/3). It came out of MultiSeat [#63](https://github.com/vibesoftwarecoder/MultiSeat/issues/63), where an unwritable `config\\` directory caused exactly this.\n\n## The fix\n\n- A config failure is now appended to `sunshine-startup-error.log` next to `sunshine.exe`. If that directory is not writable, the file goes in the temp directory.\n- A failed config now makes Apollo exit with code 2.\n- Exit code 0 stays for `--help`, service start and shortcut launch.\n\n## Testing\n\n- Runtime test with a control, using a config that fails with a filesystem error:\n - This build exited with code 2 and wrote the log, including `Failed to apply config: filesystem error: ...` and `Config was not loaded; exiting`.\n - The previous installed build exited with code 0 and wrote no log.\n- **Not tested:** the ApolloVibe unit tests. The test build already fails on drift in `tests/unit/test_http_pairing.cpp`, which this release does not touch.\n\n## Limits\n\n- Only config failure paths are covered. Other early exits are unchanged.\n- The log has no timestamps and never rotates.\n- A service's temp directory is `C:\\Windows\\Temp`.\n- MultiSeat reads no Apollo exit code, so exit 2 changes nothing there. It shows in ProcessInjector's log.\n\n## Install\n\n`apollovibe-windows-x64.zip` is the complete portable build: `sunshine.exe`, `assets/` and `tools/`. MultiSeat's installer pulls it from `releases/latest/download/` and needs no change.\n\nSHA-256 of `sunshine.exe`:\n`CCC693F69F0F7DB12AB10FE22858B064B4DB699A035753BF1A8DAD628DDF3BFF`\n","isDraft":false,"isPrerelease":false,"publishedAt":"2026-09-25T14:14:42Z","tagName":"v2026.6.1-ms4","targetCommitish":"master"}
diff --git a/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms5.json b/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms5.json
new file mode 100644
index 0000000..03f021a
--- /dev/null
+++ b/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms5.json
@@ -0,0 +1 @@
+{"body":"This release fixes a permission bypass, a display-mode remapping bug, and rounds out the test suite and docs. Everything from ms4 is included.\n\n## Security fix\n\nA client paired with only `view` permission — the default for every device paired after the first — could terminate the app another user was running. `launch()`'s permission-downgrade check was meant to exclude terminate requests, but its exclusion clause missed two paths into the terminate branch. A single predicate now covers both, and terminating always requires `launch`, the same requirement `/cancel` already had.\n\nFiled upstream as [ClassicOldSong/Apollo#1606](https://github.com/ClassicOldSong/Apollo/issues/1606), since the same code exists there unchanged.\n\n**Behaviour change:** a view-only client can no longer terminate an app it had joined, by any route.\n\n## Display mode remapping fix\n\nA remapping entry with a Requested FPS never matched, so it was silently skipped. Apollo stores the session refresh rate in millihertz, and the comparison used exact struct equality against the entry's plain fps value, which could never match. Remapping now compares by value.\n\n**Behaviour change:** entries with a Requested FPS that were silently skipped will now apply. An earlier entry can win over a later one, since the first match is used. Clients streaming at a fractional rate such as 59.94 still will not match an integer entry.\n\n## Documentation\n\n16 config options that Apollo added over 2024-2025 had no entry in `docs/configuration.md`, because upstream Apollo never documented them. They are documented now: `global_state_cmd`, `hide_tray_controls`, `enable_pairing`, `enable_discovery`, `enable_input_only_mode`, `forward_rumble`, `keep_sink_default`, `auto_capture_sink`, `fallback_mode`, `headless_mode`, `double_refreshrate`, `limit_framerate`, `envvar_compatibility_mode`, `legacy_ordering`, `ignore_encoder_probe_failure` and `nvenc_intra_refresh`.\n\n## Also from ms4\n\nEarly config failures write `sunshine-startup-error.log` next to the executable and exit with code 2 instead of 0. See the [ms4 notes](https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.6.1-ms4).\n\n## How this was checked\n\nThe full unit test suite passes: 237 tests, 233 passed, 4 skipped (the Windows-only mouse tests), 0 failed, with audio, encoder, download and external-command tests excluded.\n\nThe security and remapping fixes were confirmed in the binary: both changed source files (`src/nvhttp.cpp`, `src/display_device.cpp`) were recompiled and linked into this build, checked by object and link timestamps.\n\n**Not tested by execution.** The permission fix has no existing test harness for `launch()`; it was verified by hand-tracing every branch, not by running a live server. Nobody has run this build against a real client or display.\n\n## Install\n\n`apollovibe-windows-x64.zip` is the complete portable build: `sunshine.exe`, `assets/` and `tools/`. MultiSeat's installer pulls it from `releases/latest/download/` and needs no change.\n\nSHA-256 of `sunshine.exe`:\n`95083822664BCA479C895B935B5A83EFE0D2960986360CB773E8794CD02CCCDF`\n","isDraft":false,"isPrerelease":false,"publishedAt":"2026-09-28T18:00:14Z","tagName":"v2026.6.1-ms5","targetCommitish":"master"}
diff --git a/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms6.json b/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms6.json
new file mode 100644
index 0000000..4fe07ef
--- /dev/null
+++ b/src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms6.json
@@ -0,0 +1 @@
+{"body":"This release makes Apollo wait for the input desktop before it starts capturing, so a session that is still signing in or is locked no longer ends in a failed start. Everything from ms5 is included.\n\n## The input desktop wait\n\nWhen a session starts while Windows is on the Winlogon (secure) desktop, for example at the lock screen or just after a login, Apollo used to start capture right away, fail to open the desktop, and give up. Now it waits for the input desktop to become available first. It listens for desktop-switch events and also polls every 250 ms as a backstop.\n\nTwo new options control it. Both are in the Advanced tab of the web UI and documented in `docs/configuration.md`:\n\n- `input_desktop_wait_timeout`: seconds the capture thread waits before it starts capturing. Default 180. Range 0 to 3600. 0 turns every wait off.\n- `input_desktop_wait_request_timeout`: seconds an HTTP launch or resume request waits for the desktop before Apollo answers 503. Default 3. Range 0 to 60. It is never longer than `input_desktop_wait_timeout`. 0 turns the wait off for requests.\n\nThe request wait is short on purpose. Apollo's HTTPS server handles one request at a time, so a long wait inside a request would stall the client's status polls, which time out after 5 seconds in Moonlight.\n\nNew log lines you can look for: `Input desktop wait: ...`, `Input desktop became accessible after N ms (...)`, `Input desktop still not accessible after N ms`, and `Input desktop wait cancelled after N ms`. If the desktop-switch listener cannot be installed, Apollo logs `desktop switch hook unavailable, polling only` and polls instead.\n\n**Behaviour change:** a launch or resume request now waits up to 3 seconds (`input_desktop_wait_request_timeout`) for the input desktop. If it is still unavailable, Apollo answers 503 with the message \"The Windows desktop is not ready yet (the session is still signing in or is locked). Please try again in a few seconds.\" Before, the same situation returned the generic \"Failed to initialize video capture/encoding\" 503 immediately. moonlight-qt does not retry a refused launch by itself, so retry from the client. We did not check other clients.\n\nThe two encoder probes Apollo runs when it starts do not wait, so a locked desktop at boot cannot hold up the web UI. The probe that a launch or resume request runs waits only as long as the request limit above.\n\n## How this was checked\n\nBuilt from the merged master (PR #10, `681e36a8`) with the repo's own packaging script. The unit tests pass: 257 run, 253 passed, 4 skipped (the Windows-only mouse tests), 0 failed, with the audio, encoder, download-file and external-command tests excluded. That includes the new wait and wait-policy suites, a suite that runs against the real desktop and the real listener thread, and the config and locale consistency suites. The new option names and log phrases are present in the built `sunshine.exe` and absent from ms5's.\n\nOn the reference host, MultiSeat started this build's Apollo in fresh seat sessions and again after a session reconnect, and each start was normal.\n\n## Not verified\n\n- The wait itself has not run against a lock screen or a login that holds the Winlogon desktop for a long time. It is covered by unit tests with a fake clock, not by a live lock screen.\n- The 503 retry path has not been exercised by a real Moonlight client.\n- Whether the desktop-switch listener delivers events inside Apollo on a real host is unconfirmed in Apollo itself. (MultiSeat's own copy of the same listener did fire inside a seat session on the reference host.) If it cannot be installed, Apollo falls back to polling.\n- Not run against a real client, display or encoder beyond the normal start described above.\n\n## Install\n\n`apollovibe-windows-x64.zip` is the complete portable build: `sunshine.exe`, `assets/` and `tools/`. MultiSeat's installer pulls it from `releases/latest/download/` and needs no change.\n\nSHA-256 of `apollovibe-windows-x64.zip`:\n`B39BDC299988A712FBF5DAD68C0847E05E65BAF733B55194297C6B2C9374AD5C`\n\nSHA-256 of `sunshine.exe`:\n`7E9FA1572579879FA98716C74753DA7EC070F0C8209FE5177F2D73CD0C1B33CC`\n","isDraft":false,"isPrerelease":false,"publishedAt":"2026-10-09T10:56:31Z","tagName":"v2026.6.1-ms6","targetCommitish":"681e36a885fa2adc6232152fa099db80bdc0a976"}
diff --git a/src/MultiSeat.Tests/Updates/Fixtures/github-releases-apollovibe.json b/src/MultiSeat.Tests/Updates/Fixtures/github-releases-apollovibe.json
new file mode 100644
index 0000000..12419f4
--- /dev/null
+++ b/src/MultiSeat.Tests/Updates/Fixtures/github-releases-apollovibe.json
@@ -0,0 +1 @@
+[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/407825944","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/407825944/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/407825944/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.6.1-ms6","id":407825944,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84YTu4Y","tag_name":"v2026.6.1-ms6","target_commitish":"681e36a885fa2adc6232152fa099db80bdc0a976","name":"ApolloVibe 2026.6.1-ms6","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-10-08T20:27:06Z","updated_at":"2026-10-09T10:56:31Z","published_at":"2026-10-09T10:56:31Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/624702591","id":624702591,"node_id":"RA_kwDOSQ7rp84lPDR_","name":"apollovibe-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":23414694,"digest":"sha256:b39bdc299988a712fbf5dad68c0847e05e65baf733b55194297c6b2c9374ad5c","download_count":5,"created_at":"2026-10-09T10:56:29Z","updated_at":"2026-10-09T10:56:30Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.6.1-ms6/apollovibe-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.6.1-ms6","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.6.1-ms6","body":"This release makes Apollo wait for the input desktop before it starts capturing, so a session that is still signing in or is locked no longer ends in a failed start. Everything from ms5 is included.\n\n## The input desktop wait\n\nWhen a session starts while Windows is on the Winlogon (secure) desktop, for example at the lock screen or just after a login, Apollo used to start capture right away, fail to open the desktop, and give up. Now it waits for the input desktop to become available first. It listens for desktop-switch events and also polls every 250 ms as a backstop.\n\nTwo new options control it. Both are in the Advanced tab of the web UI and documented in `docs/configuration.md`:\n\n- `input_desktop_wait_timeout`: seconds the capture thread waits before it starts capturing. Default 180. Range 0 to 3600. 0 turns every wait off.\n- `input_desktop_wait_request_timeout`: seconds an HTTP launch or resume request waits for the desktop before Apollo answers 503. Default 3. Range 0 to 60. It is never longer than `input_desktop_wait_timeout`. 0 turns the wait off for requests.\n\nThe request wait is short on purpose. Apollo's HTTPS server handles one request at a time, so a long wait inside a request would stall the client's status polls, which time out after 5 seconds in Moonlight.\n\nNew log lines you can look for: `Input desktop wait: ...`, `Input desktop became accessible after N ms (...)`, `Input desktop still not accessible after N ms`, and `Input desktop wait cancelled after N ms`. If the desktop-switch listener cannot be installed, Apollo logs `desktop switch hook unavailable, polling only` and polls instead.\n\n**Behaviour change:** a launch or resume request now waits up to 3 seconds (`input_desktop_wait_request_timeout`) for the input desktop. If it is still unavailable, Apollo answers 503 with the message \"The Windows desktop is not ready yet (the session is still signing in or is locked). Please try again in a few seconds.\" Before, the same situation returned the generic \"Failed to initialize video capture/encoding\" 503 immediately. moonlight-qt does not retry a refused launch by itself, so retry from the client. We did not check other clients.\n\nThe two encoder probes Apollo runs when it starts do not wait, so a locked desktop at boot cannot hold up the web UI. The probe that a launch or resume request runs waits only as long as the request limit above.\n\n## How this was checked\n\nBuilt from the merged master (PR #10, `681e36a8`) with the repo's own packaging script. The unit tests pass: 257 run, 253 passed, 4 skipped (the Windows-only mouse tests), 0 failed, with the audio, encoder, download-file and external-command tests excluded. That includes the new wait and wait-policy suites, a suite that runs against the real desktop and the real listener thread, and the config and locale consistency suites. The new option names and log phrases are present in the built `sunshine.exe` and absent from ms5's.\n\nOn the reference host, MultiSeat started this build's Apollo in fresh seat sessions and again after a session reconnect, and each start was normal.\n\n## Not verified\n\n- The wait itself has not run against a lock screen or a login that holds the Winlogon desktop for a long time. It is covered by unit tests with a fake clock, not by a live lock screen.\n- The 503 retry path has not been exercised by a real Moonlight client.\n- Whether the desktop-switch listener delivers events inside Apollo on a real host is unconfirmed in Apollo itself. (MultiSeat's own copy of the same listener did fire inside a seat session on the reference host.) If it cannot be installed, Apollo falls back to polling.\n- Not run against a real client, display or encoder beyond the normal start described above.\n\n## Install\n\n`apollovibe-windows-x64.zip` is the complete portable build: `sunshine.exe`, `assets/` and `tools/`. MultiSeat's installer pulls it from `releases/latest/download/` and needs no change.\n\nSHA-256 of `apollovibe-windows-x64.zip`:\n`B39BDC299988A712FBF5DAD68C0847E05E65BAF733B55194297C6B2C9374AD5C`\n\nSHA-256 of `sunshine.exe`:\n`7E9FA1572579879FA98716C74753DA7EC070F0C8209FE5177F2D73CD0C1B33CC`\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/398493654","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/398493654/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/398493654/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.6.1-ms5","id":398493654,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84XwIfW","tag_name":"v2026.6.1-ms5","target_commitish":"master","name":"ApolloVibe 2026.6.1-ms5","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-09-28T15:23:20Z","updated_at":"2026-09-28T18:00:14Z","published_at":"2026-09-28T18:00:14Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/596046215","id":596046215,"node_id":"RA_kwDOSQ7rp84jhvGH","name":"apollovibe-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24065110,"digest":"sha256:2e005d75f0194daf5bda3c38ea0878838d041b99419970643d020a178add68b7","download_count":65,"created_at":"2026-09-28T18:00:12Z","updated_at":"2026-09-28T18:00:13Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.6.1-ms5/apollovibe-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.6.1-ms5","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.6.1-ms5","body":"This release fixes a permission bypass, a display-mode remapping bug, and rounds out the test suite and docs. Everything from ms4 is included.\n\n## Security fix\n\nA client paired with only `view` permission — the default for every device paired after the first — could terminate the app another user was running. `launch()`'s permission-downgrade check was meant to exclude terminate requests, but its exclusion clause missed two paths into the terminate branch. A single predicate now covers both, and terminating always requires `launch`, the same requirement `/cancel` already had.\n\nFiled upstream as [ClassicOldSong/Apollo#1606](https://github.com/ClassicOldSong/Apollo/issues/1606), since the same code exists there unchanged.\n\n**Behaviour change:** a view-only client can no longer terminate an app it had joined, by any route.\n\n## Display mode remapping fix\n\nA remapping entry with a Requested FPS never matched, so it was silently skipped. Apollo stores the session refresh rate in millihertz, and the comparison used exact struct equality against the entry's plain fps value, which could never match. Remapping now compares by value.\n\n**Behaviour change:** entries with a Requested FPS that were silently skipped will now apply. An earlier entry can win over a later one, since the first match is used. Clients streaming at a fractional rate such as 59.94 still will not match an integer entry.\n\n## Documentation\n\n16 config options that Apollo added over 2024-2025 had no entry in `docs/configuration.md`, because upstream Apollo never documented them. They are documented now: `global_state_cmd`, `hide_tray_controls`, `enable_pairing`, `enable_discovery`, `enable_input_only_mode`, `forward_rumble`, `keep_sink_default`, `auto_capture_sink`, `fallback_mode`, `headless_mode`, `double_refreshrate`, `limit_framerate`, `envvar_compatibility_mode`, `legacy_ordering`, `ignore_encoder_probe_failure` and `nvenc_intra_refresh`.\n\n## Also from ms4\n\nEarly config failures write `sunshine-startup-error.log` next to the executable and exit with code 2 instead of 0. See the [ms4 notes](https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.6.1-ms4).\n\n## How this was checked\n\nThe full unit test suite passes: 237 tests, 233 passed, 4 skipped (the Windows-only mouse tests), 0 failed, with audio, encoder, download and external-command tests excluded.\n\nThe security and remapping fixes were confirmed in the binary: both changed source files (`src/nvhttp.cpp`, `src/display_device.cpp`) were recompiled and linked into this build, checked by object and link timestamps.\n\n**Not tested by execution.** The permission fix has no existing test harness for `launch()`; it was verified by hand-tracing every branch, not by running a live server. Nobody has run this build against a real client or display.\n\n## Install\n\n`apollovibe-windows-x64.zip` is the complete portable build: `sunshine.exe`, `assets/` and `tools/`. MultiSeat's installer pulls it from `releases/latest/download/` and needs no change.\n\nSHA-256 of `sunshine.exe`:\n`95083822664BCA479C895B935B5A83EFE0D2960986360CB773E8794CD02CCCDF`\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/396666832","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/396666832/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/396666832/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.6.1-ms4","id":396666832,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84XpKfQ","tag_name":"v2026.6.1-ms4","target_commitish":"master","name":"ApolloVibe 2026.6.1-ms4","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-09-25T14:14:10Z","updated_at":"2026-09-25T14:14:42Z","published_at":"2026-09-25T14:14:42Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/588506735","id":588506735,"node_id":"RA_kwDOSQ7rp84jE-Zv","name":"apollovibe-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":23406120,"digest":"sha256:4dc12ab6101214219a3252d8c2a42192a464ea4bfa5c03d9ae43c0d5f58c2176","download_count":31,"created_at":"2026-09-25T14:14:40Z","updated_at":"2026-09-25T14:14:42Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.6.1-ms4/apollovibe-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.6.1-ms4","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.6.1-ms4","body":"Apollo now leaves a trace when its config fails at startup, and it exits with a nonzero code.\n\n## The problem\n\nWhen the config could not be applied, Apollo logged the reason before its log file existed. Started as a service or injected into a session, it slept about ten seconds and exited with no log. It also exited with code 0, so a supervisor saw a clean exit.\n\nThis is [#3](https://github.com/vibesoftwarecoder/ApolloVibe/issues/3). It came out of MultiSeat [#63](https://github.com/vibesoftwarecoder/MultiSeat/issues/63), where an unwritable `config\\` directory caused exactly this.\n\n## The fix\n\n- A config failure is now appended to `sunshine-startup-error.log` next to `sunshine.exe`. If that directory is not writable, the file goes in the temp directory.\n- A failed config now makes Apollo exit with code 2.\n- Exit code 0 stays for `--help`, service start and shortcut launch.\n\n## Testing\n\n- Runtime test with a control, using a config that fails with a filesystem error:\n - This build exited with code 2 and wrote the log, including `Failed to apply config: filesystem error: ...` and `Config was not loaded; exiting`.\n - The previous installed build exited with code 0 and wrote no log.\n- **Not tested:** the ApolloVibe unit tests. The test build already fails on drift in `tests/unit/test_http_pairing.cpp`, which this release does not touch.\n\n## Limits\n\n- Only config failure paths are covered. Other early exits are unchanged.\n- The log has no timestamps and never rotates.\n- A service's temp directory is `C:\\Windows\\Temp`.\n- MultiSeat reads no Apollo exit code, so exit 2 changes nothing there. It shows in ProcessInjector's log.\n\n## Install\n\n`apollovibe-windows-x64.zip` is the complete portable build: `sunshine.exe`, `assets/` and `tools/`. MultiSeat's installer pulls it from `releases/latest/download/` and needs no change.\n\nSHA-256 of `sunshine.exe`:\n`CCC693F69F0F7DB12AB10FE22858B064B4DB699A035753BF1A8DAD628DDF3BFF`\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383718790","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383718790/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383718790/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.6.1-ms3","id":383718790,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84W3xWG","tag_name":"v2026.6.1-ms3","target_commitish":"master","name":"ApolloVibe 2026.6.1-ms3","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-09-06T20:47:58Z","updated_at":"2026-09-06T21:12:40Z","published_at":"2026-09-06T21:12:40Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/547692841","id":547692841,"node_id":"RA_kwDOSQ7rp84gpSEp","name":"apollovibe-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":23405590,"digest":"sha256:1034dc33741c730cdfaca0cf44355bef3d65852111e1e42ca58f5e81956a25d6","download_count":118,"created_at":"2026-09-06T21:12:38Z","updated_at":"2026-09-06T21:12:39Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.6.1-ms3/apollovibe-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.6.1-ms3","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.6.1-ms3","body":"Fixes a hang that made ApolloVibe unusable on **every AMD GPU**.\n\n## The bug\n\nOn AMD hardware ApolloVibe wedged during encoder probing, at startup, before any\nclient connected. One CPU core pinned at 100% and the process never recovered.\nNVIDIA systems were unaffected, which is why this went unreported for so long.\n\n**Found and confirmed by [@treghart](https://github.com/treghart)** in\n[#2](https://github.com/vibesoftwarecoder/ApolloVibe/issues/2). He reported it, ran\nbuild after build on his own hardware across five failed hypotheses, uninstalled his\nworking setup to test a stock Sunshine control, and finally ran an instrumented build\nwhose log located the defect in a single run. Every one of my own theories before that\nwas wrong. This fix is his as much as anyone's.\n\n## The cause\n\nUpstream Sunshine commit `02036920`, \"build(deps): Update to FFmpeg 8.0 branch\n(#4143)\", changed two things at once: it added a drain to the encode session\ndestructor, and it bumped `third-party/build-deps` to the FFmpeg 8.0 the drain was\nwritten for.\n\nApollo took both halves correctly and carried them for seven weeks. Then merge\n`10fd290b` (2025-09-27) resolved `third-party/build-deps` back to `a9a7f863`, a pin\nfrom 2025-07-12 that neither parent of that merge points at. So every Apollo-derived\nbuild since then has run a drain written for FFmpeg 8.0 against a pre-8.0 FFmpeg. On\nAMF, `avcodec_send_frame(ctx, nullptr)` never returns. On NVENC it returns\nimmediately.\n\nThis release removes the drain. That costs nothing: it received packets into a local\nthat was discarded on the next line, so it moved no data — its only effect was letting\nthe encoder drain before a teardown that destroys everything anyway.\n\n## This is an upstream bug\n\nIt is inherited from Apollo, not introduced here. Filed upstream as\n[ClassicOldSong/Apollo#1588](https://github.com/ClassicOldSong/Apollo/issues/1588),\nwhere the correct fix is restoring the submodule pin rather than deleting the drain.\n\n## Testing\n\n- **AMD / AMF** — confirmed by @treghart on a Vega iGPU: encoder probe completes and\n Apollo reaches `Registered Apollo mDNS service` with the CPU idle.\n- **NVIDIA / NVENC** — regression-checked here on an RTX 3080. Six encode sessions\n created and destroyed during probe, including one deliberately failing session, with\n no hang. Full seat provision/serve/teardown passed 24 of 24 checks.\n- **Not tested:** teardown of a live client stream on this build. Both test hosts\n verified the probe path, which is where the fault was.\n\n## Install\n\n`apollovibe-windows-x64.zip` is the complete portable build — `sunshine.exe`,\n`assets/` and `tools/`. MultiSeat's installer pulls it from\n`releases/latest/download/` and needs no change.\n\nSHA-256 of `sunshine.exe`:\n`75467310FCABB262E47D707DF79C105415D3A5EBCD7CE3F5225180CA14BC9056`\n","mentions_count":1},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383631177","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383631177/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383631177/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/test-no-priority-hack-2026-09-06","id":383631177,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84W3b9J","tag_name":"test-no-priority-hack-2026-09-06","target_commitish":"test/no-gpu-priority-hack","name":"TEST BUILD — no absolute GPU thread priority (not a release)","draft":false,"immutable":false,"prerelease":true,"created_at":"2026-09-06T14:34:49Z","updated_at":"2026-09-06T15:26:49Z","published_at":"2026-09-06T15:26:49Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/547348638","id":547348638,"node_id":"RA_kwDOSQ7rp84gn-Ce","name":"apollovibe-TEST-no-priority-hack-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24064388,"digest":"sha256:93f755287b3cd0a459d26800cf4de10401c72e0d1e05cd15fc35bd6c0a478358","download_count":2,"created_at":"2026-09-06T15:26:47Z","updated_at":"2026-09-06T15:26:49Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/test-no-priority-hack-2026-09-06/apollovibe-TEST-no-priority-hack-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/test-no-priority-hack-2026-09-06","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/test-no-priority-hack-2026-09-06","body":"Not a release. A diagnostic build for [ApolloVibe#2](https://github.com/vibesoftwarecoder/ApolloVibe/issues/2).\n\nUpstream Apollo commit `5af771d2` (\"Priority hack\", 2025-08-12) added `SetGPUThreadPriority(0x4000001E)` ahead of the existing relative request, at two call sites — the capture device in `display_base.cpp` and the encoding device in `display_vram.cpp`. `0x4000001E` is 1,073,741,854, far outside the -7..7 range `IDXGIDevice::SetGPUThreadPriority` documents. Apollo `v0.4.6`, which predates the commit, issues only `SetGPUThreadPriority(7)`.\n\nThis build reverts both call sites to the `v0.4.6` behaviour. Nothing else is changed.\n\nBuilt from `f0fefda5` on branch `test/no-gpu-priority-hack`. `sunshine.exe` SHA256 begins `cf1e76139f81d944`.\n\n⚠️ **Pre-release on purpose.** Nothing that installs ApolloVibe automatically will pick this up, and the asset name is distinct so it cannot be mistaken for a real release.\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383682530","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383682530/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383682530/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/test-no-encoder-flush-2026-09-06","id":383682530,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84W3ofi","tag_name":"test-no-encoder-flush-2026-09-06","target_commitish":"fix/no-encoder-flush-on-destroy","name":"TEST BUILD — root-cause fix for the AMD/AMF hang (not yet on master)","draft":false,"immutable":false,"prerelease":true,"created_at":"2026-09-06T18:42:24Z","updated_at":"2026-09-06T18:47:08Z","published_at":"2026-09-06T18:47:08Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/547549643","id":547549643,"node_id":"RA_kwDOSQ7rp84govHL","name":"apollovibe-TEST-no-encoder-flush-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24064658,"digest":"sha256:9442c20672e1df48d0ce8bbafb3e6c29ed35e5110ca941a06ed0a57edfe93652","download_count":2,"created_at":"2026-09-06T18:47:06Z","updated_at":"2026-09-06T18:47:07Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/test-no-encoder-flush-2026-09-06/apollovibe-TEST-no-encoder-flush-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/test-no-encoder-flush-2026-09-06","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/test-no-encoder-flush-2026-09-06","body":"Candidate fix for [ApolloVibe#2](https://github.com/vibesoftwarecoder/ApolloVibe/issues/2), held on a branch pending confirmation.\n\nUpstream Sunshine commit `02036920`, \"build(deps): Update to FFmpeg 8.0 branch (#4143)\", changed two things in one commit: it added a drain to `~avcodec_encode_session_t()` **and** bumped `third-party/build-deps` from `94369e63` to `a21ef2e3`. The drain was written for FFmpeg 8.0.\n\nApollo merged the source half and kept its own older build-deps pin. The version strings prove it — ours and upstream Apollo carry Windows FFmpeg `\"6400860\"`; Sunshine after that commit carries `\"git-2025-08-09-7eaa0f7\"`. On AMD/AMF, `avcodec_send_frame(ctx, nullptr)` then never returns.\n\nThis build removes the drain, restoring Apollo `v0.4.6` behaviour. It moves no data — the packets were discarded on the next line.\n\nBuilt from `a358bba8` on `fix/no-encoder-flush-on-destroy`. `sunshine.exe` SHA256 begins `aa16f9bcb0c9dcc3`.\n\n⚠️ Pre-release on purpose. The properly aligned fix is bumping build-deps, which belongs upstream.\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383653833","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383653833/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383653833/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/test-amf-unforced-2026-09-06","id":383653833,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84W3hfJ","tag_name":"test-amf-unforced-2026-09-06","target_commitish":"fix/amf-dont-force-coder-profile","name":"TEST BUILD — AMF options no longer forced (not a release)","draft":false,"immutable":false,"prerelease":true,"created_at":"2026-09-06T16:47:12Z","updated_at":"2026-09-06T16:52:44Z","published_at":"2026-09-06T16:52:44Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/547433473","id":547433473,"node_id":"RA_kwDOSQ7rp84goSwB","name":"apollovibe-TEST-amf-unforced-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24065099,"digest":"sha256:045b24b12fe33fc74a7e2f2f4515197a6177071b8e921b6f1c4747cd991dcf3e","download_count":3,"created_at":"2026-09-06T16:52:42Z","updated_at":"2026-09-06T16:52:44Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/test-amf-unforced-2026-09-06/apollovibe-TEST-amf-unforced-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/test-amf-unforced-2026-09-06","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/test-amf-unforced-2026-09-06","body":"Not a release. Built for [ApolloVibe#2](https://github.com/vibesoftwarecoder/ApolloVibe/issues/2), held on a branch pending confirmation.\n\nUnlike the two earlier diagnostic builds, this is a real candidate fix rather than a probe.\n\n- **`coder`** is no longer forced. `amd_coder` was the only setting in the AMD config block declared as a plain `int` while every sibling is `std::optional`; parsed with `int_f` it always held a value (`auto` when unconfigured), so the option was passed on every AMF encode. It is now optional and set only when configured. **Setting `amd_coder` in config still works exactly as before.**\n- **`profile: high`** is no longer set for h264 AMF. Sunshine sets a profile for nvenc and qsv but deliberately not for AMF, and Apollo `v0.4.6` didn't either — both are confirmed working on the reporter's hardware.\n\nBuilt from `7304b18c` on `fix/amf-dont-force-coder-profile`. `sunshine.exe` SHA256 begins `6c2339cef79ac81d`.\n\n⚠️ Pre-release on purpose — nothing that auto-installs ApolloVibe will pick this up.\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383674136","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383674136/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383674136/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/debug-probe-instrumented-2026-09-06","id":383674136,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84W3mcY","tag_name":"debug-probe-instrumented-2026-09-06","target_commitish":"debug/probe-instrumentation","name":"DEBUG BUILD — probe path instrumented (not a release, not a fix)","draft":false,"immutable":false,"prerelease":true,"created_at":"2026-09-06T18:04:37Z","updated_at":"2026-09-06T18:13:01Z","published_at":"2026-09-06T18:13:01Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/547516638","id":547516638,"node_id":"RA_kwDOSQ7rp84gonDe","name":"apollovibe-DEBUG-probe-instrumented-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24067263,"digest":"sha256:17f529da5ad97c422cfbd4ea86b015780d35e9a9fd1a41329f1f08099eb9edf1","download_count":1,"created_at":"2026-09-06T18:12:58Z","updated_at":"2026-09-06T18:13:00Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/debug-probe-instrumented-2026-09-06/apollovibe-DEBUG-probe-instrumented-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/debug-probe-instrumented-2026-09-06","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/debug-probe-instrumented-2026-09-06","body":"Diagnostic build for [ApolloVibe#2](https://github.com/vibesoftwarecoder/ApolloVibe/issues/2). It does not attempt to fix anything — it reports where the hang happens.\n\nAdds `[PROBE]` markers at **info** level (not verbose, which enables AMF's own debug output) around `init_codec_options`, `avcodec_open2` entry and success, and every stage of `~avcodec_encode_session_t()`.\n\nThat destructor's drain loop is `while (avcodec_receive_packet(...) == 0);` — an empty-bodied spin, which is what a pinned core looks like. It is **bounded at 200,000 packets** here with an explicit abort line, so if it is the fault this build escapes it rather than hanging.\n\nBuilt from `55231bdf` on `debug/probe-instrumentation`. `sunshine.exe` SHA256 begins `076ddfee820851fe`.\n\n⚠️ Pre-release on purpose — nothing that auto-installs ApolloVibe will pick this up.\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383386712","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383386712/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383386712/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.6.1-ms2","id":383386712,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84W2gRY","tag_name":"v2026.6.1-ms2","target_commitish":"master","name":"ApolloVibe 2026.6.1-ms2","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-09-05T21:57:10Z","updated_at":"2026-09-05T22:01:12Z","published_at":"2026-09-05T22:01:12Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/546359930","id":546359930,"node_id":"RA_kwDOSQ7rp84gkMp6","name":"apollovibe-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24066629,"digest":"sha256:9e56e0f1c691efcae7c7c0a7b6d01ee2c54ecbbfcbdaab8fceea39bbf6e7c753","download_count":9,"created_at":"2026-09-05T22:01:10Z","updated_at":"2026-09-05T22:01:12Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.6.1-ms2/apollovibe-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.6.1-ms2","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.6.1-ms2","body":"First release since June. Four source fixes have been sitting unreleased since then, so anyone installing MultiSeat has been getting the June build.\n\n## Fixes\n\n| | |\n|---|---|\n| `30cc3859` | **input:** keep the input desktop handle alive instead of closing the one in use |\n| `fb05b1ab` | **sudovda:** log why virtual display creation fails instead of failing silently |\n| `61f72e57` | **wol_mac** option to override the MAC advertised to clients |\n| `84d4ef18` | **sudovda:** match added virtual display by adapter LUID and target id |\n\n`fb05b1ab` is the one worth knowing about if you are debugging seats. Virtual display creation used to fail with nothing in the log. Now it says why.\n\n## The download name changed\n\nThe asset is now **`apollovibe-windows-x64.zip`**, with no version in it. It can be fetched from a permanent URL that always resolves to the newest release:\n\n```\nhttps://github.com/vibesoftwarecoder/ApolloVibe/releases/latest/download/apollovibe-windows-x64.zip\n```\n\nThe version used to be part of the filename, so MultiSeat's installer had to hardcode both the tag and the filename. Every release needed a matching edit in another repository. That is exactly why this release is three months late. It will not happen again.\n\n## Identifying this build\n\n```\nsunshine.exe SHA256 9DE593107687F956830E37BB2361A07E...\n```\n\n⚠️ Do not rely on the version string to tell builds apart. Since both branches were collapsed to `master`, the commit hash is no longer appended, so every build reports `2026.6.1.dirty`. Use the SHA256.\n\n## Base and testing\n\nBuilt on upstream `ClassicOldSong/Apollo` at `adc5c5a0`, which has been dormant since 2026-05-21. We are 47 commits ahead and 0 behind.\n\nSmoke-tested on the reference host: a seat provisions, gets its own session, listens on all three ports and completes a TLS handshake, then tears down cleanly.\n\n⚠️ That proves the binary is not broken. It does not prove `30cc3859`, which changes input handling and needs a real client sending input to exercise.\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383421630","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383421630/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/383421630/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/test-no-async-depth-2026-09-05","id":383421630,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84W2oy-","tag_name":"test-no-async-depth-2026-09-05","target_commitish":"master","name":"TEST BUILD — h264_amf without async_depth (not a release)","draft":false,"immutable":false,"prerelease":true,"created_at":"2026-09-05T21:57:10Z","updated_at":"2026-09-06T01:02:15Z","published_at":"2026-09-06T01:02:15Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/546540520","id":546540520,"node_id":"RA_kwDOSQ7rp84gk4vo","name":"apollovibe-TEST-no-async-depth-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24064393,"digest":"sha256:3f383f17d108ef7fe9af73afca5570849186fd5378fe433863896f24ed5a9e7c","download_count":3,"created_at":"2026-09-06T01:02:13Z","updated_at":"2026-09-06T01:02:14Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/test-no-async-depth-2026-09-05/apollovibe-TEST-no-async-depth-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/test-no-async-depth-2026-09-05","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/test-no-async-depth-2026-09-05","body":"⚠️ **This is a diagnostic build, not a release. Do not use it as your normal install.**\n\nIt exists to test one thing for [issue #2](https://github.com/vibesoftwarecoder/ApolloVibe/issues/2), where the AMD H.264 encoder encodes a single frame and then spins one CPU core forever.\n\n## What is different\n\nExactly one line is removed. The AMD H.264 encoder is no longer given the `async_depth` option:\n\n```cpp\n{\"async_depth\"s, 1}, // removed in this build only\n```\n\nEverything else is identical to `v2026.6.1-ms2`.\n\n## Why that line\n\nThe Apollo release that does **not** show the problem, `v0.4.6` from July 2025, passes three fewer options to the AMD H.264 encoder than current builds do: `async_depth`, `coder`, and `profile`. `async_depth` controls how many frames may be in flight before the encoder waits. That is the one whose behaviour matches the symptom, so it is being tested first and on its own.\n\nThe other two remain in this build. If the problem persists, they are next.\n\n## Notes\n\n- Windows x64 only.\n- Not marked as the latest release, so nothing that installs ApolloVibe automatically will pick it up.\n- It will be deleted once the question is answered.\n"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/333067055","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/333067055/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/333067055/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.6.1-multiseat.1","id":333067055,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84T2jMv","tag_name":"v2026.6.1-multiseat.1","target_commitish":"apollovibe-dev","name":"ApolloVibe v2026.6.1-multiseat.1","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-06-02T11:21:44Z","updated_at":"2026-06-08T16:27:20Z","published_at":"2026-06-02T11:22:23Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/441909230","id":441909230,"node_id":"RA_kwDOSQ7rp84aVv_u","name":"apollovibe-v2026.6.1-multiseat.1-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24052496,"digest":"sha256:96800507f0dd72ea8c89a857b30882933d151a90fa22dab85dd8433180ed39a9","download_count":165,"created_at":"2026-06-08T16:27:18Z","updated_at":"2026-06-08T16:27:20Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.6.1-multiseat.1/apollovibe-v2026.6.1-multiseat.1-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.6.1-multiseat.1","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.6.1-multiseat.1","body":"Catch-up rebase release. The branch was rebased onto `ClassicOldSong/Apollo` HEAD on 2026-06-01.\n\n## What's new vs `v2026.5.15-multiseat.1`\n\n- **Security:** picks up upstream merge of `WatchKitty/Apollo#1496` — backport of fix for GHSA-ph75-mgxh-mv57.\n- All MultiSeat-specific work (mic passthrough via Steam Streaming Microphone, `disable_rtsp_encryption` config option with RTSP at port 48010, NVENC perf tuning, ApolloVibe rebrand) is replayed on top — no behavioural changes from `v2026.5.15-multiseat.1` apart from the upstream merges.\n\n## Build info\n\n- Source: branch `apollovibe-dev`, commit `6b98d106`\n- Built 2026-06-01 with MSYS2 UCRT64 + GCC 15.2 + Ninja\n- Binary: `2026.6.1.6b98d106.dirty`\n\n## Install\n\nExtract over an existing Apollo install:\n\n```powershell\nStop-Service ApolloService -ErrorAction SilentlyContinue\nGet-Process sunshine -ErrorAction SilentlyContinue | Stop-Process -Force\nExpand-Archive apollovibe-v2026.6.1-multiseat.1-windows-x64.zip -DestinationPath \"C:\\Program Files\\Apollo\" -Force\n```\n\nThe zip contains only `sunshine.exe`; existing config, web UI, and bundled SudoVDA driver files in the install directory are untouched."},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/323333754","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/323333754/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/323333754/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.5.15-multiseat.1","id":323333754,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84TRa56","tag_name":"v2026.5.15-multiseat.1","target_commitish":"master","name":"ApolloVibe v2026.5.15-multiseat.1","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-05-15T15:22:00Z","updated_at":"2026-05-15T15:28:21Z","published_at":"2026-05-15T15:28:21Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/421103946","id":421103946,"node_id":"RA_kwDOSQ7rp84ZGYlK","name":"apollovibe-v2026.5.15-multiseat.1-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":19721876,"digest":"sha256:e07d4d527b7e9f31ac7f6c1b5a4e53dbbdb126cf1c3e0d12b1701bbe5b61195e","download_count":8,"created_at":"2026-05-15T15:28:19Z","updated_at":"2026-05-15T15:28:20Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.5.15-multiseat.1/apollovibe-v2026.5.15-multiseat.1-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.5.15-multiseat.1","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.5.15-multiseat.1","body":"Rebase onto ClassicOldSong/Apollo upstream (13 commits, 2026-05-14).\n\nIncludes upstream fixes:\n- AMD AMF encoder: re-enable profile and coder options\n- Boost hash mismatch fix\n- PSP client compatibility\n- VAAPI/Wayland fixes (Linux, no-op on Windows)\n- MSYS2 build docs update\n\nApolloVibe patches carried forward:\n- stream_mic microphone passthrough (6 logabell commits)\n- ApolloVibe rebrand\n- disable_rtsp_encryption config option\n- Busy-wait CPU reduction + mic debug overhead stripped from release builds"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/319274071","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/319274071/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/319274071/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.4.30-multiseat.4","id":319274071,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84TB7xX","tag_name":"v2026.4.30-multiseat.4","target_commitish":"master","name":"ApolloVibe v2026.4.30-multiseat.4","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-05-08T01:25:07Z","updated_at":"2026-05-08T01:25:24Z","published_at":"2026-05-08T01:25:24Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/414867666","id":414867666,"node_id":"RA_kwDOSQ7rp84YumDS","name":"apollovibe-v2026.4.30-multiseat.4-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24156722,"digest":"sha256:a616811dffc52b3a861c1047c7297d9fafb5ff51f8f7831e1c320d5a56cd86b9","download_count":10,"created_at":"2026-05-08T01:25:22Z","updated_at":"2026-05-08T01:25:24Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.4.30-multiseat.4/apollovibe-v2026.4.30-multiseat.4-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.4.30-multiseat.4","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.4.30-multiseat.4","body":"## Changes\n\n- **RTSP_SETUP_PORT moved from offset 21 to 26.** Apollo's RTSP listener now binds to **port 48010** with the default `sunshine.port = 47984`, matching Sunshine's stock RTSP port. More compatible with Moonlight clients that treat the canonical Sunshine port layout as well-known.\n\n## Installation\n\nDrop the zip's contents over your existing `C:\\Program Files\\Apollo\\` install. Stop the Apollo process first if running.\n\n## Notes\n\n- No config changes required. Existing `sunshine.conf` files keep working as-is.\n- If you're running [MultiSeat](https://github.com/vibesoftwarecoder/MultiSeat), pair this with **MultiSeat v0.3.0+** so the firewall rules and port-allocation logic match the new RTSP port.\n\n## Built from\n`vibesoftwarecoder/Apollo@b5480ca4` (branch `apollovibe-dev`)"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316237122","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316237122/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316237122/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.4.30-multiseat.2","id":316237122,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84S2WVC","tag_name":"v2026.4.30-multiseat.2","target_commitish":"master","name":"Apollo v2026.4.30-multiseat.2","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-05-01T01:10:00Z","updated_at":"2026-05-01T01:53:20Z","published_at":"2026-05-01T01:53:20Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/409518369","id":409518369,"node_id":"RA_kwDOSQ7rp84YaMEh","name":"apollo-v2026.4.30-multiseat.2-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24167044,"digest":"sha256:9a576fc875ad569f53226a2a28c05eabc15bc11502cf2aedf0c9e0acc0e6f4b9","download_count":1,"created_at":"2026-05-01T01:17:29Z","updated_at":"2026-05-01T01:17:30Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.4.30-multiseat.2/apollo-v2026.4.30-multiseat.2-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.4.30-multiseat.2","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.4.30-multiseat.2","body":"MultiSeat fork — built from stream-mic-rebase branch.\n\nChanges vs v2026.4.30-multiseat.1:\n- Fix version string: binary now reports 2026.4.30 (was 0.0.0 commit:), eliminating the update notification\n- Update check now points to vibesoftwarecoder/Apollo releases instead of ClassicOldSong/Apollo\n\nAll prior changes still included:\n- 6 logabell mic passthrough commits (stream_mic = enabled)\n- Boost 1.89.0 CMake fix\n- AMD encoder profile fix (src/video.cpp)"},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316090909","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316090909/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316090909/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.4.30-multiseat.1","id":316090909,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84S1yod","tag_name":"v2026.4.30-multiseat.1","target_commitish":"stream-mic-rebase","name":"v2026.4.30-multiseat.1 - MultiSeat mic passthrough on Apollo HEAD","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-04-30T19:56:51Z","updated_at":"2026-05-01T01:53:19Z","published_at":"2026-05-01T01:53:19Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/409343259","id":409343259,"node_id":"RA_kwDOSQ7rp84YZhUb","name":"apollo-v2026.4.30-multiseat.1-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":23403932,"digest":"sha256:ab6b4d02c27121f3fc90297c4799519a230cf56c040974f9dfab4c622f5e101a","download_count":0,"created_at":"2026-04-30T20:32:12Z","updated_at":"2026-04-30T20:32:13Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.4.30-multiseat.1/apollo-v2026.4.30-multiseat.1-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.4.30-multiseat.1","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.4.30-multiseat.1","body":"## MultiSeat Apollo Fork\n\nBuilt from ClassicOldSong/Apollo latest HEAD with logabell's 6 stream_mic commits rebased on top.\n\n### Whats included\n- stream_mic = enabled: Moonlight mic passthrough via Steam Streaming Microphone\n Requires logabell/moonlight-qt-mic client\n Uses moonlight-common-c fork at vibesoftwarecoder/moonlight-common-c (timed mic packets with FEC)\n- NVENC async encode: already in ClassicOldSong/Apollo base\n- Frame pacing based on client framerate: already in base\n- Color conversion matrix: already in base\n- AV1 encoding: already in base\n\n### Bug fixes in this build\n- Fixed missing BOOST_VERSION variable after conflict resolution\n- Fixed stale config_t.profile reference in AMD encoder (removed in Sunshine merge)\n\n### Installation\nExtract to C:\\Program Files\\Apollo\\ and replace sunshine.exe, tools\\, and assets\\."},{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316245370","assets_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316245370/assets","upload_url":"https://uploads.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/316245370/assets{?name,label}","html_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/v2026.4.30-multiseat.3","id":316245370,"author":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOSQ7rp84S2YV6","tag_name":"v2026.4.30-multiseat.3","target_commitish":"master","name":"ApolloVibe v2026.4.30-multiseat.3","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-04-05T07:41:49Z","updated_at":"2026-05-01T02:10:44Z","published_at":"2026-05-01T02:10:44Z","assets":[{"url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases/assets/409545962","id":409545962,"node_id":"RA_kwDOSQ7rp84YaSzq","name":"apollovibe-v2026.4.30-multiseat.3-windows-x64.zip","label":"","uploader":{"login":"vibesoftwarecoder","id":270236408,"node_id":"U_kgDOEBt6-A","avatar_url":"https://avatars.githubusercontent.com/u/270236408?v=4","gravatar_id":"","url":"https://api.github.com/users/vibesoftwarecoder","html_url":"https://github.com/vibesoftwarecoder","followers_url":"https://api.github.com/users/vibesoftwarecoder/followers","following_url":"https://api.github.com/users/vibesoftwarecoder/following{/other_user}","gists_url":"https://api.github.com/users/vibesoftwarecoder/gists{/gist_id}","starred_url":"https://api.github.com/users/vibesoftwarecoder/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vibesoftwarecoder/subscriptions","organizations_url":"https://api.github.com/users/vibesoftwarecoder/orgs","repos_url":"https://api.github.com/users/vibesoftwarecoder/repos","events_url":"https://api.github.com/users/vibesoftwarecoder/events{/privacy}","received_events_url":"https://api.github.com/users/vibesoftwarecoder/received_events","type":"User","user_view_type":"public","site_admin":false},"content_type":"application/zip","state":"uploaded","size":24179762,"digest":"sha256:98e53cc299d66faf031faa57b5bae98c745f6b71a61360eb36628935040c8814","download_count":1,"created_at":"2026-05-01T02:10:42Z","updated_at":"2026-05-01T02:10:44Z","browser_download_url":"https://github.com/vibesoftwarecoder/ApolloVibe/releases/download/v2026.4.30-multiseat.3/apollovibe-v2026.4.30-multiseat.3-windows-x64.zip"}],"tarball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/tarball/v2026.4.30-multiseat.3","zipball_url":"https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/zipball/v2026.4.30-multiseat.3","body":"ApolloVibe — MultiSeat fork of ClassicOldSong/Apollo\n\nChanges vs v2026.4.30-multiseat.2:\n- Rebranded: Apollo → ApolloVibe in all user-facing UI strings, browser title, navbar, locale files\n- Update check now points to vibesoftwarecoder/Apollo releases\n\nAll prior changes still included:\n- 6 logabell mic passthrough commits (stream_mic = enabled)\n- Version string: 2026.4.30 (no update nag)\n- Boost 1.89.0 CMake fix\n- AMD encoder profile fix (src/video.cpp)\n- Issue tracker URL → vibesoftwarecoder/Apollo\n- Windows version resource copyright → vibesoftwarecoder/Apollo"}]
\ No newline at end of file
diff --git a/src/MultiSeat.Tests/Updates/GitHubReleaseClientTests.cs b/src/MultiSeat.Tests/Updates/GitHubReleaseClientTests.cs
new file mode 100644
index 0000000..82e9f8b
--- /dev/null
+++ b/src/MultiSeat.Tests/Updates/GitHubReleaseClientTests.cs
@@ -0,0 +1,937 @@
+using System.Net;
+using System.Reflection;
+using System.Text;
+using MultiSeat.Service.Updates;
+using Xunit;
+
+namespace MultiSeat.Tests.Updates;
+
+///
+/// The GitHub client against a fake HttpMessageHandler (the pattern of ApolloReadinessTests).
+/// No test here opens a socket: the only handler ever used to send is the fake.
+///
+public class GitHubReleaseClientTests
+{
+ private const UpdateComponent MS = UpdateComponent.MultiSeat;
+
+ private static readonly DateTimeOffset Now = new(2026, 10, 9, 12, 0, 0, TimeSpan.Zero);
+
+ private sealed class FixedTime(DateTimeOffset now) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => now;
+ }
+
+ private sealed record Seen(Uri Uri, Dictionary Headers);
+
+ private sealed class Handler(Func> respond)
+ : HttpMessageHandler
+ {
+ public List Requests { get; } = [];
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ // Snapshot now: the request is disposed after the call.
+ var headers = request.Headers.ToDictionary(h => h.Key, h => string.Join(",", h.Value), StringComparer.OrdinalIgnoreCase);
+ lock (Requests) Requests.Add(new Seen(request.RequestUri!, headers));
+ return await respond(request, Requests.Count, ct);
+ }
+ }
+
+ private static (GitHubReleaseClient Client, Handler Handler) Make(
+ Func> respond, TimeSpan? timeout = null)
+ {
+ var handler = new Handler(respond);
+ var client = new GitHubReleaseClient(new HttpClient(handler), timeout, new FixedTime(Now));
+ return (client, handler);
+ }
+
+ private static (GitHubReleaseClient Client, Handler Handler) Make(Func respond, TimeSpan? timeout = null) =>
+ Make((_, _, _) => Task.FromResult(respond()), timeout);
+
+ private static string One(string tag = "v0.6.19", string extra = "") =>
+ $$"""
+ {"tag_name":"{{tag}}","draft":false,"prerelease":false,"published_at":"2026-10-09T10:20:56Z",
+ "target_commitish":"master","body":"notes","assets":[{"name":"multiseat-windows-x64.zip","digest":"sha256:abc"}]{{extra}}}
+ """;
+
+ private static HttpResponseMessage Ok(string json, string? etag = null)
+ {
+ var r = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
+ if (etag is not null) r.Headers.TryAddWithoutValidation("ETag", etag);
+ return r;
+ }
+
+ private static HttpResponseMessage Redirect(string location, HttpStatusCode code = HttpStatusCode.MovedPermanently)
+ {
+ var r = new HttpResponseMessage(code);
+ r.Headers.TryAddWithoutValidation("Location", location);
+ return r;
+ }
+
+ // ── Success and the exact request ─────────────────────────────────
+
+ [Fact]
+ public async Task Ok_ParsesReleases_AndKeepsTheETagVerbatim()
+ {
+ var (client, _) = Make(() => Ok("[" + One() + "]", "W/\"bcd412d8\""));
+
+ var r = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(FetchOutcome.Ok, r.Outcome);
+ Assert.Equal("W/\"bcd412d8\"", r.ETag);
+ Assert.Null(r.Error);
+ var release = Assert.Single(r.Releases);
+ Assert.Equal("v0.6.19", release.TagName);
+ Assert.Equal(new DateTimeOffset(2026, 10, 9, 10, 20, 56, TimeSpan.Zero), release.PublishedAt);
+ Assert.Equal("master", release.TargetCommitish);
+ Assert.Equal("notes", release.Body);
+ var asset = Assert.Single(release.Assets);
+ Assert.Equal(new GitHubAsset("multiseat-windows-x64.zip", "sha256:abc"), asset);
+ }
+
+ [Theory]
+ [InlineData(UpdateComponent.MultiSeat, "MultiSeat")]
+ [InlineData(UpdateComponent.ApolloVibe, "ApolloVibe")]
+ [InlineData(UpdateComponent.MoonlightVibe, "MoonlightVibe")]
+ public async Task Request_HasTheExactHeaders_AndOnlyTheAllowListedUrl(UpdateComponent c, string repo)
+ {
+ var (client, handler) = Make(() => Ok("[]"));
+
+ await client.FetchAsync(c, null, default);
+
+ var seen = Assert.Single(handler.Requests);
+ Assert.Equal("https", seen.Uri.Scheme);
+ Assert.Equal("api.github.com", seen.Uri.Host);
+ Assert.Equal(443, seen.Uri.Port);
+ Assert.Equal($"/repos/vibesoftwarecoder/{repo}/releases", seen.Uri.AbsolutePath);
+ Assert.Equal("?per_page=30", seen.Uri.Query);
+
+ Assert.Equal("application/vnd.github+json", seen.Headers["Accept"]);
+ Assert.Equal("2022-11-28", seen.Headers["X-GitHub-Api-Version"]);
+ // Generic: no version number, no host name. GitHub refuses a request with no User-Agent.
+ Assert.Equal("MultiSeat-update-check", seen.Headers["User-Agent"]);
+ Assert.False(seen.Headers.ContainsKey("If-None-Match"));
+ // Nothing that identifies the caller or authenticates it.
+ Assert.False(seen.Headers.ContainsKey("Authorization"));
+ Assert.False(seen.Headers.ContainsKey("Proxy-Authorization"));
+ Assert.False(seen.Headers.ContainsKey("Cookie"));
+ Assert.False(seen.Headers.ContainsKey("Referer"));
+ Assert.False(seen.Headers.ContainsKey("From"));
+ Assert.Equal(["Accept", "User-Agent", "X-GitHub-Api-Version"], seen.Headers.Keys.OrderBy(k => k, StringComparer.Ordinal));
+ }
+
+ [Theory]
+ [InlineData("W/\"bcd412d8\"")]
+ [InlineData("\"strong-etag\"")]
+ [InlineData("W/\"\"")]
+ public async Task StoredETag_IsSentBackVerbatim(string etag)
+ {
+ var (client, handler) = Make(() => Ok("[]"));
+
+ await client.FetchAsync(MS, etag, default);
+
+ Assert.Equal(etag, Assert.Single(handler.Requests).Headers["If-None-Match"]);
+ }
+
+ [Theory]
+ [InlineData("no-quotes")]
+ [InlineData("\"unterminated")]
+ [InlineData("\"a\r\nX-Injected: 1\"")]
+ [InlineData("W/\"a\"\"b\"")]
+ [InlineData("")]
+ public async Task MalformedStoredETag_IsNotSent(string etag)
+ {
+ var (client, handler) = Make(() => Ok("[]"));
+
+ await client.FetchAsync(MS, etag, default);
+
+ Assert.False(Assert.Single(handler.Requests).Headers.ContainsKey("If-None-Match"));
+ }
+
+ [Fact]
+ public async Task Ok_IgnoresAMalformedResponseETag()
+ {
+ var (client, _) = Make(() => Ok("[]", "not-an-etag"));
+
+ var r = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(FetchOutcome.Ok, r.Outcome);
+ Assert.Null(r.ETag);
+ }
+
+ // ── 304 ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task NotModified_KeepsTheETag_AndCarriesNoReleases()
+ {
+ var (client, handler) = Make(() => new HttpResponseMessage(HttpStatusCode.NotModified));
+
+ var r = await client.FetchAsync(MS, "W/\"abc\"", default);
+
+ Assert.Equal(FetchOutcome.NotModified, r.Outcome);
+ Assert.Equal("W/\"abc\"", r.ETag);
+ Assert.Empty(r.Releases);
+ Assert.Null(r.Error);
+ Assert.Equal(304, r.HttpStatus);
+ Assert.Equal("W/\"abc\"", handler.Requests[0].Headers["If-None-Match"]);
+ }
+
+ [Fact]
+ public async Task NotModified_WhenNoETagWasSent_IsAFailure()
+ {
+ var (client, _) = Make(() => new HttpResponseMessage(HttpStatusCode.NotModified));
+
+ var r = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(FetchOutcome.Failed, r.Outcome);
+ }
+
+ // ── Rate limits ───────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Forbidden_WithRemainingZero_UsesTheResetTime()
+ {
+ var reset = Now.AddMinutes(37);
+ var (client, _) = Make(() =>
+ {
+ var r = new HttpResponseMessage(HttpStatusCode.Forbidden);
+ r.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "0");
+ r.Headers.TryAddWithoutValidation("X-RateLimit-Reset", reset.ToUnixTimeSeconds().ToString());
+ return r;
+ });
+
+ var res = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(FetchOutcome.RateLimited, res.Outcome);
+ Assert.Equal(reset, res.RetryNotBefore);
+ Assert.Equal(403, res.HttpStatus);
+ Assert.Empty(res.Releases);
+ }
+
+ [Fact]
+ public async Task TooManyRequests_WithRetryAfterSeconds_WaitsThatLong()
+ {
+ var (client, _) = Make(() =>
+ {
+ var r = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
+ r.Headers.TryAddWithoutValidation("Retry-After", "120");
+ return r;
+ });
+
+ var res = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(FetchOutcome.RateLimited, res.Outcome);
+ Assert.Equal(Now.AddSeconds(120), res.RetryNotBefore);
+ }
+
+ [Fact]
+ public async Task Forbidden_WithRetryAfterDate_WaitsUntilThen()
+ {
+ var when = Now.AddMinutes(5);
+ var (client, _) = Make(() =>
+ {
+ var r = new HttpResponseMessage(HttpStatusCode.Forbidden);
+ r.Headers.TryAddWithoutValidation("Retry-After", when.ToString("R"));
+ return r;
+ });
+
+ var res = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(FetchOutcome.RateLimited, res.Outcome);
+ Assert.Equal(when, res.RetryNotBefore);
+ }
+
+ [Fact]
+ public async Task RetryAfter_BeatsTheResetHeader()
+ {
+ var (client, _) = Make(() =>
+ {
+ var r = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
+ r.Headers.TryAddWithoutValidation("Retry-After", "60");
+ r.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "0");
+ r.Headers.TryAddWithoutValidation("X-RateLimit-Reset", Now.AddHours(3).ToUnixTimeSeconds().ToString());
+ return r;
+ });
+
+ var res = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(Now.AddSeconds(60), res.RetryNotBefore);
+ }
+
+ [Fact]
+ public async Task RateLimitWait_IsCappedAtADay_AndNeverInThePast()
+ {
+ var (far, _) = Make(() =>
+ {
+ var r = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
+ r.Headers.TryAddWithoutValidation("Retry-After", "99999999");
+ return r;
+ });
+ var (past, _) = Make(() =>
+ {
+ var r = new HttpResponseMessage(HttpStatusCode.Forbidden);
+ r.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "0");
+ r.Headers.TryAddWithoutValidation("X-RateLimit-Reset", "1");
+ return r;
+ });
+
+ Assert.Equal(Now.AddHours(24), (await far.FetchAsync(MS, null, default)).RetryNotBefore);
+ Assert.Equal(Now, (await past.FetchAsync(MS, null, default)).RetryNotBefore);
+ }
+
+ [Theory]
+ [InlineData(403, null)]
+ [InlineData(403, "5")]
+ [InlineData(429, null)]
+ public async Task Forbidden_OrTooMany_WithoutARateLimitSignal_IsAPlainFailure(int status, string? remaining)
+ {
+ var (client, _) = Make(() =>
+ {
+ var r = new HttpResponseMessage((HttpStatusCode)status);
+ if (remaining is not null) r.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", remaining);
+ return r;
+ });
+
+ var res = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(FetchOutcome.Failed, res.Outcome);
+ Assert.Null(res.RetryNotBefore);
+ Assert.Equal($"HTTP {status}", res.Error);
+ }
+
+ // ── Every failure is a result, none is an exception ───────────────
+
+ [Theory]
+ [InlineData(500)]
+ [InlineData(502)]
+ [InlineData(503)]
+ [InlineData(404)]
+ [InlineData(401)]
+ [InlineData(410)]
+ [InlineData(204)]
+ public async Task OtherStatuses_AreRecordedAsFailures(int status)
+ {
+ var (client, _) = Make(() => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent("x") });
+
+ var r = await client.FetchAsync(MS, null, default);
+
+ Assert.Equal(FetchOutcome.Failed, r.Outcome);
+ Assert.Equal($"HTTP {status}", r.Error);
+ Assert.Equal(status, r.HttpStatus);
+ Assert.Empty(r.Releases);
+ Assert.Null(r.ETag);
+ }
+
+ public static IEnumerable