From 08a0346626b09e2e9d7b17360551f47a461531b6 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 07:23:09 -0500 Subject: [PATCH 1/5] feat(updates): add UpdateCheckEnabled (off) and UpdateCheckIntervalHours options Adds the two options for the planned update notifications, the constants for the GitHub API host and the three repositories (constants on purpose, not settings), and ships "UpdateCheckEnabled": false in appsettings.json. Nothing reads them yet. Co-Authored-By: Claude Sonnet 5.5 --- .../Configuration/MultiSeatOptions.cs | 14 ++++++++++++++ src/MultiSeat.Service/appsettings.json | 8 +++++++- src/MultiSeat.Shared/Constants.cs | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) 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/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"; From b1fec41d9eb23ccec20bb9f6c4a8d148b5a6901c Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 07:23:09 -0500 Subject: [PATCH 2/5] feat(updates): version grammar, release selection and the ApolloVibe notes hash parser Pure logic for the update check, with tests and real release-note fixtures: - ReleaseVersion: closed grammar per component, numeric tuples (so 0.6.9 < 0.6.19 and -ms9 < -ms10), +build ignored, overflow-safe, never throws on garbage. - ReleaseSelector: drops drafts, pre-releases and tags outside the grammar, picks the highest version, and classifies installed against latest (including "ahead"). - ApolloHashTable: reads the sunshine.exe SHA-256 from release notes, anchored on the file name so the zip hash in the same notes is never taken. - The result model, whose JSON matches the design (camelCase status names, kebab-case installed.source). Not wired to anything: no service, endpoint or network call. Co-Authored-By: Claude Sonnet 5.5 --- .../Updates/ApolloHashTable.cs | 110 ++++++++ .../Updates/ReleaseSelector.cs | 86 ++++++ .../Updates/ReleaseVersion.cs | 213 ++++++++++++++ src/MultiSeat.Service/Updates/UpdateModels.cs | 171 +++++++++++ src/MultiSeat.Tests/MultiSeat.Tests.csproj | 5 + .../Updates/ApolloHashTableTests.cs | 265 ++++++++++++++++++ src/MultiSeat.Tests/Updates/Fixtures.cs | 22 ++ .../Updates/Fixtures/apollovibe-ms3.json | 1 + .../Updates/Fixtures/apollovibe-ms4.json | 1 + .../Updates/Fixtures/apollovibe-ms5.json | 1 + .../Updates/Fixtures/apollovibe-ms6.json | 1 + .../Fixtures/github-releases-apollovibe.json | 1 + .../Updates/ReleaseSelectorTests.cs | 160 +++++++++++ .../Updates/ReleaseVersionTests.cs | 198 +++++++++++++ .../Updates/UpdateModelsTests.cs | 85 ++++++ .../Updates/UpdateOptionsTests.cs | 169 +++++++++++ 16 files changed, 1489 insertions(+) create mode 100644 src/MultiSeat.Service/Updates/ApolloHashTable.cs create mode 100644 src/MultiSeat.Service/Updates/ReleaseSelector.cs create mode 100644 src/MultiSeat.Service/Updates/ReleaseVersion.cs create mode 100644 src/MultiSeat.Service/Updates/UpdateModels.cs create mode 100644 src/MultiSeat.Tests/Updates/ApolloHashTableTests.cs create mode 100644 src/MultiSeat.Tests/Updates/Fixtures.cs create mode 100644 src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms3.json create mode 100644 src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms4.json create mode 100644 src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms5.json create mode 100644 src/MultiSeat.Tests/Updates/Fixtures/apollovibe-ms6.json create mode 100644 src/MultiSeat.Tests/Updates/Fixtures/github-releases-apollovibe.json create mode 100644 src/MultiSeat.Tests/Updates/ReleaseSelectorTests.cs create mode 100644 src/MultiSeat.Tests/Updates/ReleaseVersionTests.cs create mode 100644 src/MultiSeat.Tests/Updates/UpdateModelsTests.cs create mode 100644 src/MultiSeat.Tests/Updates/UpdateOptionsTests.cs 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/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.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/ReleaseSelectorTests.cs b/src/MultiSeat.Tests/Updates/ReleaseSelectorTests.cs new file mode 100644 index 0000000..caf306a --- /dev/null +++ b/src/MultiSeat.Tests/Updates/ReleaseSelectorTests.cs @@ -0,0 +1,160 @@ +using MultiSeat.Service.Updates; +using Xunit; + +namespace MultiSeat.Tests.Updates; + +public class ReleaseSelectorTests +{ + private const UpdateComponent MS = UpdateComponent.MultiSeat; + private const UpdateComponent AV = UpdateComponent.ApolloVibe; + private const UpdateComponent MV = UpdateComponent.MoonlightVibe; + + private static GitHubRelease Rel(string tag, string published = "2026-01-01T00:00:00Z", bool draft = false, + bool pre = false, string? body = null, string? target = null) => + new(tag, DateTimeOffset.Parse(published), target, body, [], draft, pre); + + private static ReleaseCandidate Cand(string tag, string published = "2026-01-01T00:00:00Z") => + new() { Tag = tag, PublishedAt = DateTimeOffset.Parse(published) }; + + // ── BuildCandidates ─────────────────────────────────────────────── + + [Fact] + public void BuildCandidates_SkipsDraftsAndPreReleases_EvenWithAParseableTag() + { + var list = new[] + { + Rel("v0.6.19"), + Rel("v0.7.0", draft: true), + Rel("v0.8.0", pre: true), + }; + + var result = ReleaseSelector.BuildCandidates(MS, list); + + Assert.Equal(["v0.6.19"], result.Select(c => c.Tag)); + } + + [Fact] + public void BuildCandidates_SkipsTagsOutsideTheGrammar() + { + var list = new[] + { + Rel("v2026.6.1-ms6"), + Rel("test-no-priority-hack-2026-09-06"), + Rel("debug-probe-instrumented-2026-09-06"), + Rel("v2026.6.1"), + }; + + Assert.Equal(["v2026.6.1-ms6"], ReleaseSelector.BuildCandidates(AV, list).Select(c => c.Tag)); + } + + [Fact] + public void BuildCandidates_ApolloTakesTheSunshineHashFromTheNotes_OtherComponentsDoNot() + { + var hash = new string('a', 64); + var notes = $"SHA-256 of `sunshine.exe`:\n`{hash}`\n"; + + var apollo = ReleaseSelector.BuildCandidates(AV, [Rel("v2026.6.1-ms6", body: notes, target: "abc")]); + var multi = ReleaseSelector.BuildCandidates(MS, [Rel("v0.6.19", body: notes)]); + + Assert.Equal(hash, apollo.Single().SunshineSha256); + Assert.Equal("abc", apollo.Single().TargetCommit); + Assert.Null(multi.Single().SunshineSha256); + } + + [Fact] + public void BuildCandidates_KeepsNoReleaseNotesAndNoUrl() + { + var c = ReleaseSelector.BuildCandidates(MS, [Rel("v0.6.19", body: "secret notes")]).Single(); + + // The candidate type has no field that could carry them: the notes never reach the state file. + Assert.DoesNotContain(typeof(ReleaseCandidate).GetProperties(), p => p.Name is "Body" or "HtmlUrl" or "Url"); + Assert.Equal("v0.6.19", c.Tag); + } + + // ── SelectLatest ────────────────────────────────────────────────── + + [Fact] + public void SelectLatest_TakesTheMaximum_NotTheFirstAndNotTheNewestCreated() + { + // GitHub lists newest-created first. Here a hotfix for an older line was created last. + var list = new[] + { + Cand("v0.6.5", "2026-09-30T00:00:00Z"), + Cand("v0.6.19", "2026-09-01T00:00:00Z"), + Cand("v0.6.9", "2026-08-01T00:00:00Z"), + }; + + Assert.Equal("v0.6.19", ReleaseSelector.SelectLatest(MS, list)!.Tag); + } + + [Fact] + public void SelectLatest_ApolloMs10BeatsMs9() + { + var list = new[] { Cand("v2026.6.1-ms9"), Cand("v2026.6.1-ms10"), Cand("v2026.6.1-ms2") }; + + Assert.Equal("v2026.6.1-ms10", ReleaseSelector.SelectLatest(AV, list)!.Tag); + } + + [Fact] + public void SelectLatest_IgnoresUnparseableTags() + { + var list = new[] { Cand("moonlightvibe-win-6.3.1-efabf3"), Cand("v6.3.9"), Cand("v6.1.0-multiseat.1"), Cand("garbage") }; + + Assert.Equal("v6.3.9", ReleaseSelector.SelectLatest(MV, list)!.Tag); + } + + [Fact] + public void SelectLatest_NothingParses_ReturnsNull() + { + Assert.Null(ReleaseSelector.SelectLatest(MS, [])); + Assert.Null(ReleaseSelector.SelectLatest(MS, [Cand("nightly"), Cand("test-1")])); + } + + [Fact] + public void SelectLatest_SameVersionUnderTwoTags_PrefersTheLaterPublishDate() + { + var older = Cand("v6.3.9", "2026-01-01T00:00:00Z"); + var newer = Cand("6.3.9", "2026-02-01T00:00:00Z"); + + Assert.Same(newer, ReleaseSelector.SelectLatest(MV, [older, newer])); + Assert.Same(newer, ReleaseSelector.SelectLatest(MV, [newer, older])); + } + + [Fact] + public void SelectLatest_SameVersionAndDate_KeepsTheFirst() + { + var a = Cand("v6.3.9"); + var b = Cand("6.3.9"); + + Assert.Same(a, ReleaseSelector.SelectLatest(MV, [a, b])); + } + + // ── Classify ────────────────────────────────────────────────────── + + private static ReleaseVersion V(UpdateComponent c, string s) => ReleaseVersion.TryParse(c, s)!; + + [Fact] + public void Classify_ComparesInstalledWithLatest() + { + Assert.Equal(UpdateStatus.UpdateAvailable, ReleaseSelector.Classify(V(MS, "0.6.9"), V(MS, "0.6.19"))); + Assert.Equal(UpdateStatus.UpToDate, ReleaseSelector.Classify(V(MS, "0.6.19+b7e6d9539"), V(MS, "v0.6.19"))); + // 8: a local build ahead of the newest release is never "update available" + Assert.Equal(UpdateStatus.Ahead, ReleaseSelector.Classify(V(MS, "0.6.20+abc"), V(MS, "0.6.19"))); + Assert.Equal(UpdateStatus.Ahead, ReleaseSelector.Classify(V(MS, "0.7.0"), V(MS, "0.7.0-rc1"))); + Assert.Equal(UpdateStatus.UpdateAvailable, ReleaseSelector.Classify(V(MS, "0.7.0-rc1"), V(MS, "0.7.0"))); + } + + [Fact] + public void Classify_NoLatest_IsUnavailable_WhateverIsInstalled() + { + // 12 + Assert.Equal(UpdateStatus.Unavailable, ReleaseSelector.Classify(V(MS, "0.6.19"), null)); + Assert.Equal(UpdateStatus.Unavailable, ReleaseSelector.Classify(null, null)); + } + + [Fact] + public void Classify_UnknownInstalled_NeverSaysUpdateAvailable() + { + Assert.Equal(UpdateStatus.UnknownInstalled, ReleaseSelector.Classify(null, V(AV, "v2026.6.1-ms6"))); + } +} diff --git a/src/MultiSeat.Tests/Updates/ReleaseVersionTests.cs b/src/MultiSeat.Tests/Updates/ReleaseVersionTests.cs new file mode 100644 index 0000000..745492a --- /dev/null +++ b/src/MultiSeat.Tests/Updates/ReleaseVersionTests.cs @@ -0,0 +1,198 @@ +using MultiSeat.Service.Updates; +using Xunit; + +namespace MultiSeat.Tests.Updates; + +/// +/// The version grammar and comparison. Every row here is a way a naive string or SemVer compare +/// gets a real tag wrong; the numbers in the comments are the cases of the update-check design +/// (section 3.2). +/// +public class ReleaseVersionTests +{ + private const UpdateComponent MS = UpdateComponent.MultiSeat; + private const UpdateComponent AV = UpdateComponent.ApolloVibe; + private const UpdateComponent MV = UpdateComponent.MoonlightVibe; + + private static ReleaseVersion V(UpdateComponent c, string s) => + ReleaseVersion.TryParse(c, s) ?? throw new Xunit.Sdk.XunitException($"'{s}' did not parse as {c}"); + + // expected: sign of Compare(a, b) + [Theory] + // 1: 0.6.9 is BELOW 0.6.19 (a string compare says the opposite) + [InlineData(MS, "0.6.9", "0.6.19", -1)] + [InlineData(MS, "0.9.0", "0.10.0", -1)] + [InlineData(MS, "1.0.0", "0.99.99", 1)] + // 2: the v prefix and the +sha build do not matter + [InlineData(MS, "v0.6.19", "0.6.19+b7e6d9539", 0)] + [InlineData(MS, "v0.6.19", "v0.6.19", 0)] + // 3: ms10 is above ms9 (SemVer would compare the identifiers as text) + [InlineData(AV, "v2026.6.1-ms10", "v2026.6.1-ms9", 1)] + [InlineData(AV, "v2026.6.1-ms2", "v2026.6.1-ms10", -1)] + [InlineData(AV, "v2026.6.1-ms99", "v2026.6.1-ms100", -1)] + // 5: the date triple outranks N + [InlineData(AV, "v2026.7.1-ms1", "v2026.6.1-ms9", 1)] + [InlineData(AV, "v2026.6.2-ms1", "v2026.6.1-ms9", 1)] + [InlineData(AV, "v2027.1.1-ms1", "v2026.12.31-ms50", 1)] + // legacy spelling feeds N too + [InlineData(AV, "v2026.6.1-multiseat.1", "v2026.6.1-ms2", -1)] + [InlineData(AV, "v2026.6.1-ms1", "v2026.6.1-multiseat.1", 0)] + [InlineData(AV, "v2026.4.30-multiseat.3", "v2026.4.30-multiseat.10", -1)] + [InlineData(AV, "v2026.5.15-multiseat.1", "v2026.4.30-multiseat.4", 1)] + // 6: a MultiSeat pre-release sorts below the same triple + [InlineData(MS, "0.7.0-rc1", "0.7.0", -1)] + [InlineData(MS, "0.7.0-rc1", "0.6.99", 1)] + [InlineData(MS, "0.7.0-rc1", "0.7.0-rc2", -1)] + [InlineData(MS, "0.7.0-rc.2", "0.7.0-rc.10", -1)] + [InlineData(MS, "0.7.0-alpha", "0.7.0-beta", -1)] + [InlineData(MS, "0.7.0-alpha", "0.7.0-alpha.1", -1)] + [InlineData(MS, "0.7.0-1", "0.7.0-alpha", -1)] + [InlineData(MS, "0.7.0-2", "0.7.0-10", -1)] + // 7: equal versions with different build metadata are equal + [InlineData(MS, "0.6.19+aaaaaaaaa", "0.6.19+bbbbbbbbb", 0)] + // MoonlightVibe: numeric tuple of 2 to 4 parts, missing parts are 0 + [InlineData(MV, "v6.3.9", "v6.3.10", -1)] + [InlineData(MV, "6.3", "6.3.0", 0)] + [InlineData(MV, "6.3.0.0", "6.3", 0)] + [InlineData(MV, "6.3.9.1", "6.3.9", 1)] + [InlineData(MV, "6.10.0", "6.9.9", 1)] + // 10: leading zeros are numbers, big numbers are fine until they overflow a long + [InlineData(MS, "0.06.19", "0.6.19", 0)] + [InlineData(MS, "v1.99999999999.0", "v1.2.0", 1)] + public void Compare_IsNumericTupleOrder(UpdateComponent c, string a, string b, int expected) + { + var va = V(c, a); + var vb = V(c, b); + + Assert.Equal(expected, Math.Sign(va.CompareTo(vb))); + Assert.Equal(-expected, Math.Sign(vb.CompareTo(va))); + Assert.Equal(expected == 0, va.Equals(vb)); + Assert.Equal(expected < 0, va < vb); + Assert.Equal(expected > 0, va > vb); + if (expected == 0) Assert.Equal(va.GetHashCode(), vb.GetHashCode()); + } + + // 4, 9 and the rest of the grammar: these are "not a version", never an exception + [Theory] + // 4: a bare ApolloVibe CalVer is not a real tag; it is ignored rather than ranked wrongly + [InlineData(AV, "v2026.6.1")] + [InlineData(AV, "2026.6.1")] + [InlineData(AV, "v2026.6.1-ms")] + [InlineData(AV, "v2026.6.1-msX")] + [InlineData(AV, "v2026.6.1-rc1")] + [InlineData(AV, "v2026.6.1-ms6-extra")] + [InlineData(AV, "v2026.6-ms6")] + // pre-release test builds + [InlineData(AV, "test-no-priority-hack-2026-09-06")] + [InlineData(AV, "debug-probe-instrumented-2026-09-06")] + // 9: legacy and mismatched MoonlightVibe tags + [InlineData(MV, "moonlightvibe-win-6.3.1-efabf3")] + [InlineData(MV, "moonlightvibe-mac-6.2.2-14707a")] + [InlineData(MV, "v6.1.0-multiseat.1")] + [InlineData(MV, "v6")] + [InlineData(MV, "v6.3.9.1.2")] + [InlineData(MV, "v6.3.x")] + [InlineData(MV, "v6..3")] + // MultiSeat + [InlineData(MS, "0.6")] + [InlineData(MS, "0.6.19.1")] + [InlineData(MS, "v")] + [InlineData(MS, "V0.6.19")] + [InlineData(MS, "vv0.6.19")] + [InlineData(MS, "0.6.19-")] + [InlineData(MS, "0.6.19-a..b")] + [InlineData(MS, "0.6.19+")] + [InlineData(MS, "0.6.19 rc1")] + [InlineData(MS, "0.6.19\n+x")] + [InlineData(MS, "0.6.-1")] + [InlineData(MS, "-0.6.1")] + [InlineData(MS, "0.6.1e3")] + // 10: overflow of a long, in every position + [InlineData(MS, "99999999999999999999.0.0")] + [InlineData(MS, "0.0.9223372036854775808")] + [InlineData(AV, "v2026.6.1-ms99999999999999999999")] + [InlineData(MV, "6.99999999999999999999")] + // not ASCII digits: \d would have accepted these + [InlineData(MS, "٣.٢.١")] + // empty, whitespace, null, junk + [InlineData(MS, "")] + [InlineData(MS, " ")] + [InlineData(MS, "garbage")] + [InlineData(MS, "latest")] + [InlineData(MS, null)] + [InlineData(AV, null)] + [InlineData(MV, null)] + public void TryParse_OutsideTheGrammar_ReturnsNullAndDoesNotThrow(UpdateComponent c, string? text) + { + Assert.Null(ReleaseVersion.TryParse(c, text)); + } + + [Fact] + public void TryParse_ExtremelyLongInput_ReturnsNull() + { + Assert.Null(ReleaseVersion.TryParse(MS, "0.0." + new string('9', 100_000))); + Assert.Null(ReleaseVersion.TryParse(MS, "0.0.1-" + new string('a', 100_000))); + } + + [Fact] + public void TryParse_LongestValidNumber_Parses() + { + var v = V(MS, "0.0." + long.MaxValue); + Assert.Equal(long.MaxValue, v.Key[2]); + Assert.True(V(MS, "0.0." + long.MaxValue) > V(MS, "0.0.9223372036854775806")); + } + + [Theory] + [InlineData(MS, " v0.6.19\r\n", "0.6.19")] + [InlineData(MS, "0.6.19+b7e6d9539", "0.6.19")] + [InlineData(MS, "v0.7.0-rc1", "0.7.0-rc1")] + [InlineData(MS, "0.7.0-rc.1+abc.def", "0.7.0-rc.1")] + [InlineData(AV, "v2026.6.1-ms6", "2026.6.1-ms6")] + [InlineData(AV, "v2026.4.30-multiseat.3", "2026.4.30-multiseat.3")] + [InlineData(MV, "v6.3.9", "6.3.9")] + [InlineData(MV, "6.3", "6.3")] + public void Display_DropsPrefixAndBuild(UpdateComponent c, string tag, string expected) + { + Assert.Equal(expected, V(c, tag).Display); + } + + [Fact] + public void Build_KeepsTheCommitForDisplay() + { + var v = V(MS, "0.6.19+b7e6d9539"); + Assert.Equal("b7e6d9539", v.Build); + Assert.Null(V(MS, "0.6.19").Build); + } + + [Fact] + public void IsPreRelease_OnlyForMultiSeatSuffix() + { + Assert.True(V(MS, "0.7.0-rc1").IsPreRelease); + Assert.False(V(MS, "0.7.0").IsPreRelease); + Assert.False(V(AV, "v2026.6.1-ms6").IsPreRelease); + } + + [Fact] + public void Compare_AcrossComponents_IsAProgrammingError() + { + Assert.Throws(() => V(MS, "1.0.0").CompareTo(V(MV, "1.0.0"))); + Assert.False(V(MS, "1.0.0").Equals(V(MV, "1.0.0"))); + } + + [Fact] + public void Compare_WithNull_IsGreater() + { + Assert.True(V(MS, "1.0.0").CompareTo(null) > 0); + } + + [Fact] + public void SortingARealTagList_PutsTheHighestLast() + { + // The order GitHub lists them in is creation order, not version order. + var tags = new[] { "v2026.6.1-ms6", "v2026.6.1-ms10", "v2026.6.1-ms9", "v2026.6.1-multiseat.1", "v2026.5.15-multiseat.1", "v2026.7.1-ms1" }; + var sorted = tags.Select(t => V(AV, t)).OrderBy(v => v).Select(v => v.Display).ToArray(); + Assert.Equal( + ["2026.5.15-multiseat.1", "2026.6.1-multiseat.1", "2026.6.1-ms6", "2026.6.1-ms9", "2026.6.1-ms10", "2026.7.1-ms1"], + sorted); + } +} diff --git a/src/MultiSeat.Tests/Updates/UpdateModelsTests.cs b/src/MultiSeat.Tests/Updates/UpdateModelsTests.cs new file mode 100644 index 0000000..438bd9c --- /dev/null +++ b/src/MultiSeat.Tests/Updates/UpdateModelsTests.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MultiSeat.Service.Updates; +using Xunit; + +namespace MultiSeat.Tests.Updates; + +/// +/// The result model is serialized as is by the API later. These tests pin the JSON it produces +/// with the API's own options (camelCase, enums as strings, see ApiServer) against the shape in +/// the design, so step 7 cannot silently change what the dashboard receives. +/// +public class UpdateModelsTests +{ + // Exactly the options ApiServer registers for minimal-API responses. + private static readonly JsonSerializerOptions ApiOptions = new() + { + Converters = { new JsonStringEnumConverter() }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [Theory] + [InlineData(UpdateStatus.UpToDate, "upToDate")] + [InlineData(UpdateStatus.UpdateAvailable, "updateAvailable")] + [InlineData(UpdateStatus.Ahead, "ahead")] + [InlineData(UpdateStatus.UnknownInstalled, "unknownInstalled")] + [InlineData(UpdateStatus.NotInstalled, "notInstalled")] + [InlineData(UpdateStatus.LatestOnly, "latestOnly")] + [InlineData(UpdateStatus.Unavailable, "unavailable")] + [InlineData(UpdateStatus.Disabled, "disabled")] + public void Status_SerializesAsTheDesignNames(UpdateStatus status, string expected) + { + Assert.Equal($"\"{expected}\"", JsonSerializer.Serialize(status, ApiOptions)); + } + + [Theory] + [InlineData(InstalledSource.Assembly, "assembly")] + [InlineData(InstalledSource.Marker, "marker")] + [InlineData(InstalledSource.MarkerModified, "marker-modified")] + [InlineData(InstalledSource.ReleaseHash, "release-hash")] + [InlineData(InstalledSource.CommitMatch, "commit-match")] + public void Source_SerializesAsTheDesignNames_WithTheApiOptionsAndWithoutThem(InstalledSource source, string expected) + { + Assert.Equal($"\"{expected}\"", JsonSerializer.Serialize(source, ApiOptions)); + Assert.Equal($"\"{expected}\"", JsonSerializer.Serialize(source)); + Assert.Equal(source, JsonSerializer.Deserialize($"\"{expected}\"", ApiOptions)); + } + + [Fact] + public void Component_SerializesIntoTheDesignShape() + { + var c = new ComponentUpdateStatus( + "multiseat", "MultiSeat", UpdateStatus.UpdateAvailable, + new InstalledInfo("0.6.18", "0.6.18 (ebcd253)", InstalledSource.Assembly, null), + null, + new LatestInfo("0.6.19", "v0.6.19", new DateTimeOffset(2026, 10, 9, 10, 20, 56, TimeSpan.Zero), UpdateRepos.ReleaseUrl(UpdateComponent.MultiSeat, "v0.6.19")), + true, + new DateTimeOffset(2026, 10, 9, 14, 2, 11, TimeSpan.Zero), + null); + + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(c, ApiOptions)); + var root = doc.RootElement; + + Assert.Equal( + ["id", "name", "status", "installed", "installedNote", "latest", "announce", "checkedAt", "error"], + root.EnumerateObject().Select(p => p.Name)); + Assert.Equal("updateAvailable", root.GetProperty("status").GetString()); + Assert.Equal(["version", "display", "source", "note"], root.GetProperty("installed").EnumerateObject().Select(p => p.Name)); + Assert.Equal("assembly", root.GetProperty("installed").GetProperty("source").GetString()); + Assert.Equal(["version", "tag", "publishedAt", "releaseUrl"], root.GetProperty("latest").EnumerateObject().Select(p => p.Name)); + Assert.Equal("https://github.com/vibesoftwarecoder/MultiSeat/releases/tag/v0.6.19", root.GetProperty("latest").GetProperty("releaseUrl").GetString()); + Assert.Equal(JsonValueKind.True, root.GetProperty("announce").ValueKind); + Assert.Equal(JsonValueKind.Null, root.GetProperty("installedNote").ValueKind); + } + + [Fact] + public void TheModel_HasNoFieldThatCarriesGitHubTextToTheDashboard() + { + var names = new[] { typeof(ComponentUpdateStatus), typeof(InstalledInfo), typeof(LatestInfo) } + .SelectMany(t => t.GetProperties().Select(p => p.Name)) + .ToArray(); + + Assert.DoesNotContain(names, n => n is "Body" or "HtmlUrl" or "Notes" or "Assets" or "Digest"); + } +} diff --git a/src/MultiSeat.Tests/Updates/UpdateOptionsTests.cs b/src/MultiSeat.Tests/Updates/UpdateOptionsTests.cs new file mode 100644 index 0000000..e4d8497 --- /dev/null +++ b/src/MultiSeat.Tests/Updates/UpdateOptionsTests.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using MultiSeat.Service.Configuration; +using MultiSeat.Service.Updates; +using Xunit; + +namespace MultiSeat.Tests.Updates; + +/// +/// Update checks are OFF unless an operator turns them on. Both places that say so are pinned: +/// the C# default (what a host with no config file gets) and the shipped appsettings.json (what +/// a fresh install gets). If either flips, an upgrade would start making internet connections +/// from a SYSTEM service without anyone having been asked. +/// +public class UpdateOptionsTests +{ + [Fact] + public void Default_IsOff_WithATwelveHourInterval() + { + var o = new MultiSeatOptions(); + + Assert.False(o.UpdateCheckEnabled); + Assert.Equal(12, o.UpdateCheckIntervalHours); + } + + [Fact] + public void ShippedAppSettings_SaysOff_AsARealBoolean() + { + // The file has // comments, so read it the way the host does (configuration) and also + // as raw JSON with comments skipped, to prove the value is the literal false and not a string. + var config = new ConfigurationBuilder() + .AddJsonFile("service-appsettings.json", optional: false) + .Build(); + Assert.Equal("False", config.GetSection("MultiSeat")["UpdateCheckEnabled"]); + + using var doc = JsonDocument.Parse(File.ReadAllText("service-appsettings.json"), + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); + var value = doc.RootElement.GetProperty("MultiSeat").GetProperty("UpdateCheckEnabled"); + Assert.Equal(JsonValueKind.False, value.ValueKind); + } + + [Fact] + public void ShippedAppSettings_BindsToOff() + { + var o = new MultiSeatOptions(); + new ConfigurationBuilder() + .AddJsonFile("service-appsettings.json", optional: false) + .Build() + .GetSection("MultiSeat") + .Bind(o); + + Assert.False(o.UpdateCheckEnabled); + } + + [Fact] + public void ShippedAppSettings_ExplainsWhatTurningItOnDoes() + { + var text = File.ReadAllText("service-appsettings.json"); + var at = text.IndexOf("\"UpdateCheckEnabled\"", StringComparison.Ordinal); + Assert.True(at > 0); + + // The comment sits right above the key, in the file's own // style, and says what is sent. + var before = text[Math.Max(0, at - 700)..at]; + Assert.Contains("api.github.com", before); + Assert.Contains("never downloads or installs", before); + } + + [Fact] + public void LocalOverride_TurnsItOn() + { + // appsettings.local.json is loaded after appsettings.json and wins. + var local = new MemoryStream(System.Text.Encoding.UTF8.GetBytes( + """{ "MultiSeat": { "UpdateCheckEnabled": true, "UpdateCheckIntervalHours": 6 } }""")); + var o = new MultiSeatOptions(); + new ConfigurationBuilder() + .AddJsonFile("service-appsettings.json", optional: false) + .AddJsonStream(local) + .Build() + .GetSection("MultiSeat") + .Bind(o); + + Assert.True(o.UpdateCheckEnabled); + Assert.Equal(6, o.UpdateCheckIntervalHours); + } + + [Fact] + public void ConfigWithNoUpdateKeys_KeepsTheDefaultOff() + { + var o = new MultiSeatOptions(); + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["MultiSeat:MaxSeats"] = "2" }) + .Build() + .GetSection("MultiSeat") + .Bind(o); + + Assert.False(o.UpdateCheckEnabled); + Assert.Equal(12, o.UpdateCheckIntervalHours); + } + + [Theory] + [InlineData(-5, 1)] + [InlineData(0, 1)] + [InlineData(1, 1)] + [InlineData(12, 12)] + [InlineData(168, 168)] + [InlineData(169, 168)] + [InlineData(int.MaxValue, 168)] + [InlineData(int.MinValue, 1)] + public void Interval_IsClampedToOneThroughOneSixtyEightHours(int configured, int expected) + { + Assert.Equal(expected, UpdateIntervals.ClampHours(configured)); + Assert.Equal(TimeSpan.FromHours(expected), UpdateIntervals.ToInterval(configured)); + } + + // ── Constants, not settings ─────────────────────────────────────── + + [Fact] + public void RepositoriesAndHost_AreConstants_AndNotSettings() + { + // A setting that could point the check elsewhere would make it a generic outbound request. + var names = typeof(MultiSeatOptions).GetProperties().Select(p => p.Name).ToArray(); + + Assert.DoesNotContain(names, n => n.Contains("Repo", StringComparison.OrdinalIgnoreCase) && n.Contains("Update", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, n => n.Contains("ApiHost", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, n => n.Contains("Url", StringComparison.OrdinalIgnoreCase) && n.Contains("Update", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(["UpdateCheckEnabled", "UpdateCheckIntervalHours"], names.Where(n => n.StartsWith("UpdateCheck")).OrderBy(n => n)); + } + + [Fact] + public void Constants_PinTheHostAndTheThreeRepositories() + { + Assert.Equal("api.github.com", MultiSeat.Shared.Constants.UpdateApiHost); + Assert.Equal("vibesoftwarecoder", MultiSeat.Shared.Constants.UpdateRepoOwner); + Assert.Equal("MultiSeat-update-check", MultiSeat.Shared.Constants.UpdateUserAgent); + Assert.Equal(@"C:\ProgramData\MultiSeat\update-check.json", MultiSeat.Shared.Constants.DefaultUpdateStatePath); + + Assert.Equal("https://api.github.com/repos/vibesoftwarecoder/MultiSeat/releases?per_page=30", + UpdateRepos.ReleaseListUri(UpdateComponent.MultiSeat).AbsoluteUri); + Assert.Equal("https://api.github.com/repos/vibesoftwarecoder/ApolloVibe/releases?per_page=30", + UpdateRepos.ReleaseListUri(UpdateComponent.ApolloVibe).AbsoluteUri); + Assert.Equal("https://api.github.com/repos/vibesoftwarecoder/MoonlightVibe/releases?per_page=30", + UpdateRepos.ReleaseListUri(UpdateComponent.MoonlightVibe).AbsoluteUri); + } + + [Fact] + public void UserAgent_CarriesNoVersionAndNoHostName() + { + var ua = MultiSeat.Shared.Constants.UpdateUserAgent; + + Assert.DoesNotContain("/", ua); + Assert.DoesNotMatch(@"\d", ua); + Assert.DoesNotContain(Environment.MachineName, ua, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ReleaseUrl_IsBuiltFromTheRepoAndTheEncodedTag_NeverFromGitHubText() + { + Assert.Equal("https://github.com/vibesoftwarecoder/MultiSeat/releases/tag/v0.6.19", + UpdateRepos.ReleaseUrl(UpdateComponent.MultiSeat, "v0.6.19")); + + var hostile = UpdateRepos.ReleaseUrl(UpdateComponent.ApolloVibe, "\">/../x?y#z"); + Assert.StartsWith("https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/", hostile); + Assert.DoesNotContain("<", hostile); + Assert.DoesNotContain("\"", hostile); + Assert.DoesNotContain("?", hostile); + Assert.DoesNotContain("#", hostile); + Assert.DoesNotContain("/", hostile["https://github.com/vibesoftwarecoder/ApolloVibe/releases/tag/".Length..]); + } +} From 566d5bc0bc98301c0e9ee685be50ecd07c2dc748 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 07:23:09 -0500 Subject: [PATCH 3/5] feat(updates): GitHub releases client that records every failure and throws none GET api.github.com/repos/vibesoftwarecoder/{repo}/releases?per_page=30 with a generic User-Agent, If-None-Match from the stored ETag, no Authorization and no cookies. Manual redirects (at most 3, only to api.github.com /repos or /repositories), 10 s timeout, 2 MB body cap, rate-limit handling, strict UTF-8 and JSON handling. Takes an injected HttpClient; nothing creates or starts it yet. Tests use a fake handler and open no sockets. Co-Authored-By: Claude Sonnet 5.5 --- .../Updates/GitHubReleaseClient.cs | 405 ++++++++ .../Updates/GitHubReleaseClientTests.cs | 937 ++++++++++++++++++ 2 files changed, 1342 insertions(+) create mode 100644 src/MultiSeat.Service/Updates/GitHubReleaseClient.cs create mode 100644 src/MultiSeat.Tests/Updates/GitHubReleaseClientTests.cs 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.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 ThrowingHandlers() + { + yield return [new HttpRequestException(HttpRequestError.NameResolutionError, "No such host is known", new System.Net.Sockets.SocketException(11001)), "network error (NameResolutionError)"]; + yield return [new HttpRequestException(HttpRequestError.SecureConnectionError, "The SSL connection could not be established", null), "network error (SecureConnectionError)"]; + yield return [new HttpRequestException(HttpRequestError.ConnectionError, "refused", null), "network error (ConnectionError)"]; + yield return [new IOException("The response ended prematurely"), "unexpected error (IOException)"]; + yield return [new InvalidOperationException("boom"), "unexpected error (InvalidOperationException)"]; + yield return [new NotSupportedException("boom"), "unexpected error (NotSupportedException)"]; + yield return [new TimeoutException(), "unexpected error (TimeoutException)"]; + } + + [Theory] + [MemberData(nameof(ThrowingHandlers))] + public async Task ExceptionsFromTheNetworkStack_NeverEscape(Exception thrown, string expectedError) + { + var (client, _) = Make((_, _, _) => throw thrown); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal(expectedError, r.Error); + // The message of the exception (which can carry host or proxy names) is not recorded. + Assert.DoesNotContain(thrown.Message, r.Error); + } + + [Fact] + public async Task HungServer_TimesOut_AsAResult() + { + var (client, _) = Make(async (_, _, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return Ok("[]"); + }, timeout: TimeSpan.FromMilliseconds(150)); + + var r = await WithinAsync(client.FetchAsync(MS, null, default)); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("timeout", r.Error); + } + + [Fact] + public async Task BodyThatNeverFinishes_TimesOut_AsAResult() + { + var (client, _) = Make(() => + { + var content = new StreamContent(new HangingStream()); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + }, timeout: TimeSpan.FromMilliseconds(150)); + + var r = await WithinAsync(client.FetchAsync(MS, null, default)); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("timeout", r.Error); + } + + [Fact] + public async Task CallerCancellation_Propagates_AndIsNotRecorded() + { + using var cts = new CancellationTokenSource(); + var (client, _) = Make(async (_, _, ct) => + { + cts.Cancel(); + await Task.Delay(Timeout.Infinite, ct); + return Ok("[]"); + }); + + await Assert.ThrowsAnyAsync(() => client.FetchAsync(MS, null, cts.Token)); + } + + [Fact] + public async Task AlreadyCancelledCaller_SendsNothing() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var (client, handler) = Make(() => Ok("[]")); + + await Assert.ThrowsAnyAsync(() => client.FetchAsync(MS, null, cts.Token)); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task TruncatedBody_ThatThrowsMidRead_IsAFailure() + { + var (client, _) = Make(() => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new ThrowingStream(Encoding.UTF8.GetBytes("[{\"tag_name\":\"v1"), new IOException("premature EOF"))), + }); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("body read failed", r.Error); + } + + [Fact] + public async Task TruncatedBody_ThatEndsEarlyAgainstContentLength_IsAFailure() + { + var full = "[" + One() + "]"; + var content = new StringContent(full[..(full.Length - 10)]); + content.Headers.ContentLength = full.Length; + var (client, _) = Make(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("truncated response", r.Error); + } + + [Fact] + public async Task TruncatedJson_WithoutALength_IsAFailure() + { + var (client, _) = Make(() => Ok("[" + One())); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("invalid JSON", r.Error); + } + + [Theory] + [InlineData("

Unicorn!

This page is taking too long to load.", "invalid JSON")] + [InlineData("", "invalid JSON")] + [InlineData("not json", "invalid JSON")] + [InlineData("{}", "unexpected JSON shape")] + [InlineData("{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com\"}", "unexpected JSON shape")] + [InlineData("\"a string\"", "unexpected JSON shape")] + [InlineData("null", "unexpected JSON shape")] + [InlineData("42", "unexpected JSON shape")] + [InlineData("[1,2,3]", "unexpected JSON shape")] + [InlineData("[\"v0.6.19\"]", "unexpected JSON shape")] + [InlineData("[[]]", "unexpected JSON shape")] + public async Task WrongBodies_AreRecordedFailures(string body, string expectedError) + { + var (client, _) = Make(() => Ok(body)); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal(expectedError, r.Error); + Assert.Empty(r.Releases); + } + + [Fact] + public async Task InvalidUtf8_IsAFailure() + { + // 0xFF is never valid in UTF-8. Valid JSON structure around it. + var bytes = Encoding.ASCII.GetBytes("[{\"tag_name\":\"v1.0.0\",\"draft\":false,\"prerelease\":false,\"body\":\"x").Concat(new byte[] { 0xFF, 0xFE }) + .Concat(Encoding.ASCII.GetBytes("y\"}]")).ToArray(); + var (client, _) = Make(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("invalid UTF-8", r.Error); + } + + [Fact] + public async Task Utf8Bom_IsTolerated() + { + var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes("[" + One() + "]")).ToArray(); + var (client, _) = Make(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Ok, r.Outcome); + Assert.Single(r.Releases); + } + + // ── The 2 MB cap ────────────────────────────────────────────────── + + [Fact] + public async Task BodyOverTheCap_IsRefused_WithoutBeingParsed() + { + // 5 MB of valid-looking JSON, no Content-Length: the stream is cut at the cap. + var big = "[" + One() + "," + string.Join(",", Enumerable.Repeat(One("v0.0.1", ",\"pad\":\"" + new string('x', 1000) + "\""), 5000)) + "]"; + Assert.True(Encoding.UTF8.GetByteCount(big) > 4 * 1024 * 1024); + var (client, _) = Make(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new PlainStream(Encoding.UTF8.GetBytes(big))) }); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("response too large", r.Error); + } + + [Fact] + public async Task DeclaredLengthOverTheCap_IsRefused_WithoutReadingTheBody() + { + var neverRead = new ThrowingStream([], new InvalidOperationException("body must not be read")); + var content = new StreamContent(neverRead); + content.Headers.ContentLength = 10L * 1024 * 1024; + var (client, _) = Make(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal("response too large", r.Error); + Assert.False(neverRead.WasRead); + } + + [Fact] + public async Task BodyExactlyAtTheCap_IsAccepted_OneByteOverIsNot() + { + static string Padded(int totalBytes) + { + const string head = "[{\"tag_name\":\"v0.6.19\",\"draft\":false,\"prerelease\":false,\"body\":\""; + const string tail = "\"}]"; + return head + new string('x', totalBytes - head.Length - tail.Length) + tail; + } + + var atCap = Padded((int)GitHubReleaseClient.MaxBodyBytes); + var overCap = Padded((int)GitHubReleaseClient.MaxBodyBytes + 1); + Assert.Equal(GitHubReleaseClient.MaxBodyBytes, Encoding.UTF8.GetByteCount(atCap)); + + var (a, _) = Make(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new PlainStream(Encoding.UTF8.GetBytes(atCap))) }); + var (b, _) = Make(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new PlainStream(Encoding.UTF8.GetBytes(overCap))) }); + + Assert.Equal(FetchOutcome.Ok, (await a.FetchAsync(MS, null, default)).Outcome); + Assert.Equal("response too large", (await b.FetchAsync(MS, null, default)).Error); + } + + // ── Redirects ───────────────────────────────────────────────────── + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] + [InlineData(HttpStatusCode.Found)] + [InlineData(HttpStatusCode.TemporaryRedirect)] + [InlineData(HttpStatusCode.PermanentRedirect)] + public async Task Redirect_ToTheApiHost_IsFollowed(HttpStatusCode code) + { + var (client, handler) = Make((_, n, _) => Task.FromResult(n == 1 + ? Redirect("https://api.github.com/repositories/12345/releases?per_page=30", code) + : Ok("[" + One() + "]", "W/\"new\""))); + + var r = await client.FetchAsync(MS, "W/\"old\"", default); + + Assert.Equal(FetchOutcome.Ok, r.Outcome); + Assert.Equal("W/\"new\"", r.ETag); + Assert.Equal(2, handler.Requests.Count); + Assert.Equal("https://api.github.com/repositories/12345/releases?per_page=30", handler.Requests[1].Uri.AbsoluteUri); + // The second hop is still the same anonymous, generic request. + Assert.Equal("MultiSeat-update-check", handler.Requests[1].Headers["User-Agent"]); + Assert.False(handler.Requests[1].Headers.ContainsKey("Authorization")); + } + + [Fact] + public async Task Redirect_WithARelativeLocation_StaysOnTheApiHost() + { + var (client, handler) = Make((_, n, _) => Task.FromResult(n == 1 + ? Redirect("/repositories/777/releases?per_page=30") + : Ok("[]"))); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Ok, r.Outcome); + Assert.Equal("https://api.github.com/repositories/777/releases?per_page=30", handler.Requests[1].Uri.AbsoluteUri); + } + + [Theory] + [InlineData("https://github.com/vibesoftwarecoder/MultiSeat/releases")] + [InlineData("https://evil.example/repos/vibesoftwarecoder/MultiSeat/releases")] + [InlineData("https://api.github.com.evil.example/repos/x/y/releases")] + [InlineData("https://api.github.com@evil.example/repos/x/y/releases")] + [InlineData("https://user:pw@api.github.com/repos/x/y/releases")] + [InlineData("http://api.github.com/repos/x/y/releases")] + [InlineData("https://api.github.com:8443/repos/x/y/releases")] + [InlineData("https://api.github.com/user")] + [InlineData("https://api.github.com/")] + [InlineData("https://api.github.com/reposX/y")] + [InlineData("ftp://api.github.com/repos/x/y/releases")] + [InlineData("file:///C:/Windows/win.ini")] + [InlineData("//evil.example/repos/x/y/releases")] + [InlineData("javascript:alert(1)")] + public async Task Redirect_ToAnythingElse_IsRefused_AndNeverRequested(string location) + { + var (client, handler) = Make(() => Redirect(location)); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("redirect refused", r.Error); + Assert.Single(handler.Requests); // the refused target was never contacted + } + + [Fact] + public async Task Redirect_WithoutALocation_IsRefused() + { + var (client, _) = Make(() => new HttpResponseMessage(HttpStatusCode.MovedPermanently)); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal("redirect refused", r.Error); + } + + [Fact] + public async Task ThreeRedirects_AreFollowed_TheFourthIsRefused() + { + string Hop(int n) => $"https://api.github.com/repositories/{n}/releases?per_page=30"; + + var (three, threeHandler) = Make((_, n, _) => Task.FromResult(n <= 3 ? Redirect(Hop(n)) : Ok("[]"))); + var (four, fourHandler) = Make((_, n, _) => Task.FromResult(n <= 4 ? Redirect(Hop(n)) : Ok("[]"))); + + var ok = await three.FetchAsync(MS, null, default); + var refused = await four.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Ok, ok.Outcome); + Assert.Equal(4, threeHandler.Requests.Count); + Assert.Equal(FetchOutcome.Failed, refused.Outcome); + Assert.Equal("too many redirects", refused.Error); + Assert.Equal(4, fourHandler.Requests.Count); // the fifth request was never made + } + + [Fact] + public async Task RedirectLoop_IsRefused() + { + var (client, handler) = Make(() => Redirect("https://api.github.com/repositories/1/releases?per_page=30")); + + var r = await client.FetchAsync(MS, null, default); + + Assert.Equal(FetchOutcome.Failed, r.Outcome); + Assert.Equal("redirect loop", r.Error); + Assert.Equal(2, handler.Requests.Count); + } + + // ── Parsing ─────────────────────────────────────────────────────── + + private static List Parse(string json, out string? error) => + GitHubReleaseClient.ParseReleases(Encoding.UTF8.GetBytes(json), out error) ?? []; + + [Fact] + public void Parse_SkipsDraftsAndPreReleases() + { + var json = "[" + + One("v0.6.19") + "," + + One("v0.7.0").Replace("\"draft\":false", "\"draft\":true") + "," + + One("v0.8.0").Replace("\"prerelease\":false", "\"prerelease\":true") + "]"; + + var releases = Parse(json, out var error); + + Assert.Null(error); + Assert.Equal(["v0.6.19"], releases.Select(r => r.TagName)); + } + + [Theory] + [InlineData("\"draft\":\"false\"")] + [InlineData("\"draft\":null")] + [InlineData("\"draft\":0")] + public void Parse_SkipsAReleaseWhoseFlagsAreNotBooleans(string badDraft) + { + var json = "[" + One("v1.0.0").Replace("\"draft\":false", badDraft) + "," + One("v1.0.1") + "]"; + + Assert.Equal(["v1.0.1"], Parse(json, out _).Select(r => r.TagName)); + } + + [Fact] + public void Parse_SkipsAReleaseWithMissingFlags_OrMissingOrHugeTag() + { + var noFlags = "{\"tag_name\":\"v1.0.0\"}"; + var noTag = "{\"draft\":false,\"prerelease\":false}"; + var nullTag = "{\"tag_name\":null,\"draft\":false,\"prerelease\":false}"; + var intTag = "{\"tag_name\":5,\"draft\":false,\"prerelease\":false}"; + var hugeTag = One(new string('v', 500)); + + var releases = Parse($"[{noFlags},{noTag},{nullTag},{intTag},{hugeTag},{One("v1.0.2")}]", out var error); + + Assert.Null(error); + Assert.Equal(["v1.0.2"], releases.Select(r => r.TagName)); + } + + [Fact] + public void Parse_ToleratesOddFields_WithoutLosingTheRelease() + { + var json = """ + [{"tag_name":"v1.0.0","draft":false,"prerelease":false, + "published_at":"not a date","target_commitish":12,"body":null, + "assets":[{"name":"ok.zip","digest":null},{"name":5},"junk",{"name":""},{"name":"b.zip","digest":"sha256:1"}], + "unknown_future_field":{"nested":[1,2,3]}}] + """; + + var r = Assert.Single(Parse(json, out var error)); + + Assert.Null(error); + Assert.Null(r.PublishedAt); + Assert.Null(r.TargetCommitish); + Assert.Null(r.Body); + Assert.Equal([new GitHubAsset("ok.zip", null), new GitHubAsset("b.zip", "sha256:1")], r.Assets); + } + + [Fact] + public void Parse_AssetsMissingOrWrongType_YieldsNoAssets() + { + var json = "[" + One("v1.0.0") + ",{\"tag_name\":\"v1.0.1\",\"draft\":false,\"prerelease\":false,\"assets\":\"none\"}]"; + + var releases = Parse(json, out _); + + Assert.Equal(2, releases.Count); + Assert.Empty(releases[1].Assets); + } + + [Fact] + public void Parse_EmptyArray_IsAnEmptyListNotAnError() + { + var releases = GitHubReleaseClient.ParseReleases("[]"u8.ToArray(), out var error); + + Assert.NotNull(releases); + Assert.Empty(releases!); + Assert.Null(error); + } + + [Fact] + public void Parse_NonObjectElementsAreSkipped_WhenAtLeastOneObjectIsPresent() + { + var releases = Parse("[1,\"x\"," + One("v1.0.0") + ",null]", out var error); + + Assert.Null(error); + Assert.Single(releases); + } + + [Fact] + public void Parse_DeeplyNestedJson_IsAFailureNotACrash() + { + var deep = new string('[', 5000) + new string(']', 5000); + + Parse(deep, out var error); + + Assert.Equal("invalid JSON", error); + } + + [Fact] + public void Parse_OverlongBody_IsTruncatedNotKeptWhole() + { + var json = "[{\"tag_name\":\"v1.0.0\",\"draft\":false,\"prerelease\":false,\"body\":\"" + new string('a', 1_000_000) + "\"}]"; + + var r = Assert.Single(Parse(json, out _)); + + Assert.True(r.Body!.Length <= 256 * 1024); + } + + [Fact] + public void Parse_KeepsAtMostAHundredReleases() + { + var json = "[" + string.Join(",", Enumerable.Range(0, 250).Select(i => One($"v0.0.{i}"))) + "]"; + + Assert.Equal(100, Parse(json, out _).Count); + } + + [Fact] + public void Parse_TheRealApolloVibeResponse() + { + var bytes = File.ReadAllBytes(Fixtures.Path("github-releases-apollovibe.json")); + + var releases = GitHubReleaseClient.ParseReleases(bytes, out var error)!; + + Assert.Null(error); + // 16 releases in the response; the five test-* / debug-* ones are pre-releases and are dropped. + Assert.Equal(11, releases.Count); + Assert.DoesNotContain(releases, r => r.TagName.StartsWith("test-") || r.TagName.StartsWith("debug-")); + var ms6 = releases.Single(r => r.TagName == "v2026.6.1-ms6"); + Assert.StartsWith("681e36a8", ms6.TargetCommitish); + Assert.Contains("SHA-256 of `sunshine.exe`", ms6.Body); + var zip = Assert.Single(ms6.Assets); + Assert.Equal("apollovibe-windows-x64.zip", zip.Name); + Assert.StartsWith("sha256:b39bdc29", zip.Digest); + } + + [Fact] + public void TheRealApolloVibeResponse_SelectsMs6_AndKeepsTheExeHashNotTheZipHash() + { + var bytes = File.ReadAllBytes(Fixtures.Path("github-releases-apollovibe.json")); + var releases = GitHubReleaseClient.ParseReleases(bytes, out var error)!; + Assert.Null(error); + + var candidates = ReleaseSelector.BuildCandidates(UpdateComponent.ApolloVibe, releases); + var latest = ReleaseSelector.SelectLatest(UpdateComponent.ApolloVibe, candidates)!; + + Assert.Equal("v2026.6.1-ms6", latest.Tag); + Assert.DoesNotContain(candidates, c => c.Tag.StartsWith("test-") || c.Tag.StartsWith("debug-")); + Assert.Contains(candidates, c => c.Tag == "v2026.4.30-multiseat.1"); + // ms6's notes name the zip hash first and the exe hash second; the candidate keeps the exe's. + Assert.Equal("7e9fa1572579879fa98716c74753da7ec070f0c8209fe5177f2d73cd0c1b33cc", latest.SunshineSha256); + Assert.StartsWith("681e36a8", latest.TargetCommit); + // ms3 to ms5 each have their own, and the legacy releases have none. + Assert.Equal(4, candidates.Count(c => c.SunshineSha256 is not null)); + } + + // ── Construction, handler, state ────────────────────────────────── + + [Fact] + public void Constructor_RefusesAClientThatWouldAddCredentialsOrAnIdentity() + { + var withAuth = new HttpClient(); + withAuth.DefaultRequestHeaders.Authorization = new("Bearer", "x"); + var withUa = new HttpClient(); + withUa.DefaultRequestHeaders.UserAgent.ParseAdd("MultiSeat/0.6.19 (HOST-NAME)"); + var withCookie = new HttpClient(); + withCookie.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", "a=b"); + var withProxyAuth = new HttpClient(); + withProxyAuth.DefaultRequestHeaders.ProxyAuthorization = new("Basic", "x"); + + Assert.Throws(() => new GitHubReleaseClient(withAuth)); + Assert.Throws(() => new GitHubReleaseClient(withUa)); + Assert.Throws(() => new GitHubReleaseClient(withCookie)); + Assert.Throws(() => new GitHubReleaseClient(withProxyAuth)); + Assert.Throws(() => new GitHubReleaseClient(null!)); + } + + [Fact] + public void CreateHandler_DisablesAutoRedirectAndCookies() + { + using var handler = GitHubReleaseClient.CreateHandler(); + + Assert.False(handler.AllowAutoRedirect); + Assert.False(handler.UseCookies); + Assert.Equal(DecompressionMethods.GZip | DecompressionMethods.Deflate, handler.AutomaticDecompression); + // Normal certificate validation: nothing was overridden. + Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback); + } + + [Fact] + public void Client_HasNoMutableStaticState() + { + const BindingFlags all = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + foreach (var f in typeof(GitHubReleaseClient).GetFields(all)) + Assert.True(f.IsInitOnly || f.IsLiteral, $"static field {f.Name} is mutable"); + Assert.DoesNotContain(typeof(GitHubReleaseClient).GetProperties(all), p => p.SetMethod is not null); + } + + [Fact] + public async Task TwoClients_DoNotShareState() + { + var (a, _) = Make(() => Ok("[" + One("v1.0.0") + "]", "W/\"a\"")); + var (b, _) = Make(() => Ok("[" + One("v2.0.0") + "]", "W/\"b\"")); + + var ra = await a.FetchAsync(MS, null, default); + var rb = await b.FetchAsync(MS, null, default); + + Assert.Equal("W/\"a\"", ra.ETag); + Assert.Equal("W/\"b\"", rb.ETag); + Assert.Equal("v2.0.0", rb.Releases.Single().TagName); + } + + // A fetch that should have timed out must FAIL the test, not hang the run. + private static async Task WithinAsync(Task task) + { + var finished = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10))); + Assert.True(ReferenceEquals(finished, task), "the fetch did not finish: the timeout is not working"); + return await task; + } + + // ── Helper streams ──────────────────────────────────────────────── + + private sealed class ThrowingStream(byte[] prefix, Exception thrown) : Stream + { + private int _pos; + public bool WasRead { get; private set; } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) + { + WasRead = true; + if (_pos >= prefix.Length) throw thrown; + var n = Math.Min(count, prefix.Length - _pos); + Array.Copy(prefix, _pos, buffer, offset, n); + _pos += n; + return n; + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private sealed class PlainStream(byte[] data) : Stream + { + private int _pos; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) + { + var n = Math.Min(count, data.Length - _pos); + Array.Copy(data, _pos, buffer, offset, n); + _pos += n; + return n; + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private sealed class HangingStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} From f159bdc060f6c20a369c2da804d39fbc8579aee8 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 07:23:09 -0500 Subject: [PATCH 4/5] feat(updates): update-check.json cache that ignores anything wrong with itself Per-repo ETag, check time, parsed candidates, baseline, backoff and last error, plus the installed sunshine.exe hash cache. Written with AtomicFile and restricted to SYSTEM and Administrators; a corrupt, wrong-type, oversized or future-schema file loads as empty. Co-Authored-By: Claude Sonnet 5.5 --- .../Updates/UpdateStateStore.cs | 244 +++++++++ .../Updates/UpdateStateStoreTests.cs | 501 ++++++++++++++++++ 2 files changed, 745 insertions(+) create mode 100644 src/MultiSeat.Service/Updates/UpdateStateStore.cs create mode 100644 src/MultiSeat.Tests/Updates/UpdateStateStoreTests.cs 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.Tests/Updates/UpdateStateStoreTests.cs b/src/MultiSeat.Tests/Updates/UpdateStateStoreTests.cs new file mode 100644 index 0000000..70c752a --- /dev/null +++ b/src/MultiSeat.Tests/Updates/UpdateStateStoreTests.cs @@ -0,0 +1,501 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using MultiSeat.Service.Updates; +using Xunit; + +namespace MultiSeat.Tests.Updates; + +/// +/// The update-check cache. It is disposable, so the contract is: whatever is wrong with the file, +/// loading yields an empty state and never throws; saving is atomic and restricted; a failed +/// save never throws either. +/// +public class UpdateStateStoreTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "multiseat-updstate-" + Guid.NewGuid().ToString("N")); + private string FilePath => Path.Combine(_dir, "update-check.json"); + + public void Dispose() + { + try { if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); } catch { /* best effort */ } + } + + private readonly List _warnings = []; + private readonly List _restricted = []; + + private UpdateStateStore Store(string? path = null, bool restrictSucceeds = true) => + new(path ?? FilePath, + (p, _) => { _restricted.Add(p); return restrictSucceeds; }, + _warnings.Add); + + private static string Sha(char c) => new(c, 64); + + private static UpdateState Sample() + { + var s = new UpdateState(); + var apollo = s.GetOrAdd(UpdateComponent.ApolloVibe); + apollo.ETag = "W/\"bcd412d8\""; + apollo.CheckedAt = new DateTimeOffset(2026, 10, 9, 14, 2, 11, TimeSpan.Zero); + apollo.Baseline = "2026.6.1-ms6"; + apollo.LastError = "HTTP 503"; + apollo.Backoff = new BackoffState { ConsecutiveFailures = 2, NotBefore = new DateTimeOffset(2026, 10, 9, 15, 0, 0, TimeSpan.Zero) }; + apollo.Candidates = + [ + new ReleaseCandidate { Tag = "v2026.6.1-ms6", PublishedAt = new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), TargetCommit = "681e36a885fa2adc6232152fa099db80bdc0a976", SunshineSha256 = Sha('a') }, + new ReleaseCandidate { Tag = "v2026.6.1-ms5", TargetCommit = "master" }, + ]; + s.GetOrAdd(UpdateComponent.MultiSeat).ETag = "\"strong\""; + s.ApolloExeHash = new InstalledExeHash { Path = @"C:\Program Files\ApolloVibe\sunshine.exe", Size = 23_456_789, MtimeUtc = new DateTime(2026, 10, 1, 12, 30, 0, 123, DateTimeKind.Utc).AddTicks(4567), Sha256 = Sha('b') }; + return s; + } + + // ── Round trip ──────────────────────────────────────────────────── + + [Fact] + public void SaveThenLoad_RoundTripsEverything() + { + var store = Store(); + var original = Sample(); + + Assert.True(store.Save(original)); + var loaded = store.Load(); + + var a = loaded.Repos["apollovibe"]; + Assert.Equal("W/\"bcd412d8\"", a.ETag); + Assert.Equal(original.Repos["apollovibe"].CheckedAt, a.CheckedAt); + Assert.Equal("2026.6.1-ms6", a.Baseline); + Assert.Equal("HTTP 503", a.LastError); + Assert.Equal(2, a.Backoff.ConsecutiveFailures); + Assert.Equal(new DateTimeOffset(2026, 10, 9, 15, 0, 0, TimeSpan.Zero), a.Backoff.NotBefore); + Assert.Equal(2, a.Candidates.Count); + Assert.Equal("v2026.6.1-ms6", a.Candidates[0].Tag); + Assert.Equal("681e36a885fa2adc6232152fa099db80bdc0a976", a.Candidates[0].TargetCommit); + Assert.Equal(Sha('a'), a.Candidates[0].SunshineSha256); + Assert.Null(a.Candidates[1].SunshineSha256); + Assert.Equal("\"strong\"", loaded.Repos["multiseat"].ETag); + Assert.False(loaded.Repos.ContainsKey("moonlightvibe")); + + var h = loaded.ApolloExeHash!; + Assert.Equal(original.ApolloExeHash!.Path, h.Path); + Assert.Equal(23_456_789, h.Size); + Assert.Equal(original.ApolloExeHash.MtimeUtc.Ticks, h.MtimeUtc.Ticks); // sub-millisecond precision survives + Assert.Equal(Sha('b'), h.Sha256); + Assert.Empty(_warnings); + } + + [Fact] + public void Save_CreatesAMissingDirectory() + { + Assert.False(Directory.Exists(_dir)); + + Assert.True(Store().Save(new UpdateState())); + + Assert.True(File.Exists(FilePath)); + } + + [Fact] + public void Save_WritesCamelCaseJsonWithoutABom_AndTheSchema() + { + Store().Save(Sample()); + + var bytes = File.ReadAllBytes(FilePath); + Assert.NotEqual(0xEF, bytes[0]); + var text = Encoding.UTF8.GetString(bytes); + Assert.Contains("\"schema\": 1", text); + Assert.Contains("\"apolloExeHash\"", text); + Assert.Contains("\"eTag\"", text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Save_AlwaysStampsTheCurrentSchema() + { + var store = Store(); + var s = Sample(); + s.Schema = 99; + + store.Save(s); + + Assert.Equal(UpdateState.CurrentSchema, store.Load().Schema); + } + + // ── Atomic write and permissions ────────────────────────────────── + + [Fact] + public void Save_LeavesNoTempFileBehind_AndReplacesTheOldFile() + { + var store = Store(); + store.Save(Sample()); + var second = new UpdateState(); + second.GetOrAdd(UpdateComponent.MoonlightVibe).ETag = "\"second\""; + + store.Save(second); + + Assert.Equal(["update-check.json"], Directory.GetFiles(_dir).Select(Path.GetFileName)); + var loaded = store.Load(); + Assert.Equal("\"second\"", loaded.Repos["moonlightvibe"].ETag); + Assert.False(loaded.Repos.ContainsKey("apollovibe")); + } + + [Fact] + public async Task Save_IsAtomic_AReaderNeverSeesAHalfWrittenFile() + { + // A direct File.WriteAllText truncates first and streams after, so a reader that opens the + // file in between sees a short or empty file. A rename never does. The state is made big + // (about 3 MB) so the window of a direct write is wide. + var big = new UpdateState(); + var repo = big.GetOrAdd(UpdateComponent.ApolloVibe); + for (var i = 0; i < 20_000; i++) + repo.Candidates.Add(new ReleaseCandidate { Tag = $"v2026.6.1-ms{i}", TargetCommit = new string('a', 40), SunshineSha256 = Sha('a') }); + var store = Store(); + Assert.True(store.Save(big)); + var fullLength = new FileInfo(FilePath).Length; + + using var stop = new CancellationTokenSource(); + var torn = 0; + var reads = 0; + var reader = Task.Run(() => + { + while (!stop.IsCancellationRequested) + { + try + { + using var fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var ms = new MemoryStream(); + fs.CopyTo(ms); + reads++; + if (ms.Length != fullLength) torn++; + } + catch (IOException) { /* the file was being replaced at that instant: not a torn read */ } + catch (UnauthorizedAccessException) { } + } + }); + + for (var i = 0; i < 40; i++) store.Save(big); + stop.Cancel(); + await reader; + + Assert.True(reads > 0); + Assert.Equal(0, torn); + } + + [Fact] + public void Save_RestrictsTheFile_AfterEveryWrite() + { + var store = Store(); + + store.Save(Sample()); + store.Save(Sample()); + + // Each write is a rename that creates a new file object, so each needs its own restriction. + Assert.Equal([FilePath, FilePath], _restricted); + } + + [Fact] + public void Save_FailureToRestrict_StillSucceeds_AndIsReported() + { + var warned = new List(); + var store = new UpdateStateStore(FilePath, (_, onError) => { onError(new UnauthorizedAccessException("no")); return false; }, warned.Add); + + Assert.True(store.Save(Sample())); + + Assert.Contains(warned, w => w.Contains("restrict permissions", StringComparison.Ordinal)); + } + + [Fact] + public void Save_WithTheRealHelper_LeavesOnlySystemAndAdministrators() + { + var store = new UpdateStateStore(FilePath); // default: SecureFile.TryRestrictToSystemAndAdmins + + Assert.True(store.Save(Sample())); + + var acl = new FileInfo(FilePath).GetAccessControl(); + Assert.True(acl.AreAccessRulesProtected); + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + var admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + var sids = acl.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast() + .Select(r => (SecurityIdentifier)r.IdentityReference).Distinct().ToList(); + Assert.Contains(system, sids); + Assert.Contains(admins, sids); + Assert.Equal(2, sids.Count); + + // Cleanup needs admin rights once the ACL is tight; Dispose is best effort. + } + + [Fact] + public void Save_WhenTheTargetCannotBeWritten_ReturnsFalse_AndDoesNotThrow() + { + Directory.CreateDirectory(_dir); + Directory.CreateDirectory(FilePath); // a directory where the file should be + var store = Store(); + + Assert.False(store.Save(Sample())); + Assert.Contains(_warnings, w => w.Contains("could not be written")); + Assert.Empty(Directory.GetFiles(_dir)); // and the temp file was cleaned up + } + + [Fact] + public void Save_WhenTheDirectoryCannotBeCreated_ReturnsFalse() + { + Directory.CreateDirectory(_dir); + var blocker = Path.Combine(_dir, "not-a-directory"); + File.WriteAllText(blocker, "x"); + var store = Store(Path.Combine(blocker, "sub", "update-check.json")); + + Assert.False(store.Save(Sample())); + } + + [Fact] + public void Save_ThatThrowsFromTheWarningCallback_IsStillSwallowed() + { + Directory.CreateDirectory(_dir); + Directory.CreateDirectory(FilePath); + var store = new UpdateStateStore(FilePath, (_, _) => true, _ => throw new InvalidOperationException("logger broke")); + + Assert.False(store.Save(Sample())); + } + + // ── A bad file is ignored, never an error ───────────────────────── + + private UpdateState LoadFromText(string text) + { + Directory.CreateDirectory(_dir); + File.WriteAllText(FilePath, text, new UTF8Encoding(false)); + return Store().Load(); + } + + private static void AssertEmpty(UpdateState s) + { + Assert.Empty(s.Repos); + Assert.Null(s.ApolloExeHash); + Assert.Equal(UpdateState.CurrentSchema, s.Schema); + } + + [Fact] + public void Load_MissingFile_IsEmpty_WithoutAWarning() + { + AssertEmpty(Store().Load()); + Assert.Empty(_warnings); + } + + [Fact] + public void Load_MissingDirectory_IsEmpty() + { + AssertEmpty(Store(Path.Combine(_dir, "a", "b", "c.json")).Load()); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("{")] + [InlineData("{\"schema\": 1")] + [InlineData("not json at all")] + [InlineData("")] + [InlineData("\0\0\0\0")] + [InlineData("[]")] + [InlineData("null")] + [InlineData("42")] + [InlineData("\"schema\"")] + public void Load_CorruptJson_IsIgnored(string text) + { + AssertEmpty(LoadFromText(text)); + Assert.NotEmpty(_warnings); + } + + [Fact] + public void Load_TruncatedRealFile_IsIgnored() + { + Store().Save(Sample()); + var full = File.ReadAllText(FilePath); + + AssertEmpty(LoadFromText(full[..(full.Length / 2)])); + } + + [Fact] + public void Load_BinaryGarbage_IsIgnored() + { + Directory.CreateDirectory(_dir); + File.WriteAllBytes(FilePath, Enumerable.Range(0, 5000).Select(i => (byte)(i * 31 % 251)).ToArray()); + + AssertEmpty(Store().Load()); + } + + [Theory] + [InlineData("{}")] // no schema + [InlineData("{\"schema\": 2}")] // a newer file is not ours to interpret + [InlineData("{\"schema\": 0}")] + [InlineData("{\"schema\": -1}")] + [InlineData("{\"schema\": 1.5}")] + [InlineData("{\"schema\": \"1\"}")] + [InlineData("{\"schema\": null}")] + [InlineData("{\"schema\": 99, \"repos\": {\"multiseat\": {\"eTag\": \"\\\"x\\\"\"}}}")] + public void Load_WrongOrFutureSchema_IsIgnored_EvenWhenTheRestLooksValid(string text) + { + AssertEmpty(LoadFromText(text)); + Assert.Contains(_warnings, w => w.Contains("schema")); + } + + [Theory] + [InlineData("{\"schema\":1,\"repos\":[]}")] + [InlineData("{\"schema\":1,\"repos\":\"x\"}")] + [InlineData("{\"schema\":1,\"repos\":{\"multiseat\":\"x\"}}")] + [InlineData("{\"schema\":1,\"repos\":{\"multiseat\":{\"candidates\":\"x\"}}}")] + [InlineData("{\"schema\":1,\"repos\":{\"multiseat\":{\"candidates\":[1,2]}}}")] + [InlineData("{\"schema\":1,\"repos\":{\"multiseat\":{\"checkedAt\":\"yesterday\"}}}")] + [InlineData("{\"schema\":1,\"repos\":{\"multiseat\":{\"checkedAt\":12}}}")] + [InlineData("{\"schema\":1,\"repos\":{\"multiseat\":{\"backoff\":{\"consecutiveFailures\":\"many\"}}}}")] + [InlineData("{\"schema\":1,\"apolloExeHash\":\"x\"}")] + [InlineData("{\"schema\":1,\"apolloExeHash\":{\"size\":\"big\"}}")] + [InlineData("{\"schema\":1,\"apolloExeHash\":{\"mtimeUtc\":\"never\"}}")] + public void Load_WrongTypes_AreIgnored(string text) + { + AssertEmpty(LoadFromText(text)); + } + + [Fact] + public void Load_UnknownFields_AreTolerated_AndKnownOnesKept() + { + var s = LoadFromText(""" + { "schema": 1, "futureTopLevel": { "x": [1,2,3] }, + "repos": { "multiseat": { "eTag": "\"abc\"", "somethingNew": true, "candidates": [ { "tag": "v0.6.19", "extra": 1 } ] }, + "someFutureComponent": { "eTag": "\"zzz\"" } } } + """); + + Assert.Equal("\"abc\"", s.Repos["multiseat"].ETag); + Assert.Equal("v0.6.19", Assert.Single(s.Repos["multiseat"].Candidates).Tag); + Assert.False(s.Repos.ContainsKey("someFutureComponent")); + } + + [Fact] + public void Load_TrailingCommasAndExtraWhitespace_AreTolerated() + { + var s = LoadFromText("{\n \"schema\": 1,\n \"repos\": { \"multiseat\": { \"eTag\": \"\\\"a\\\"\", }, },\n}\n"); + + Assert.Equal("\"a\"", s.Repos["multiseat"].ETag); + } + + [Fact] + public void Load_AFileOverTheSizeLimit_IsIgnoredWithoutBeingParsed() + { + Directory.CreateDirectory(_dir); + File.WriteAllText(FilePath, "{\"schema\":1,\"pad\":\"" + new string('x', 5 * 1024 * 1024) + "\"}"); + + AssertEmpty(Store().Load()); + Assert.Contains(_warnings, w => w.Contains("too large")); + } + + [Fact] + public void Load_DeeplyNestedJson_IsIgnored() + { + AssertEmpty(LoadFromText("{\"schema\":1,\"x\":" + new string('[', 3000) + new string(']', 3000) + "}")); + } + + [Fact] + public void Load_ALockedFile_IsIgnored() + { + Store().Save(Sample()); + using var hold = new FileStream(FilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + AssertEmpty(Store().Load()); + } + + // ── What is kept from a damaged-but-parseable file ──────────────── + + [Fact] + public void Load_DropsAnETagThatCouldInjectAHeader() + { + var s = LoadFromText("""{ "schema": 1, "repos": { "multiseat": { "eTag": "\"a\"\r\nAuthorization: Bearer x" }, "apollovibe": { "eTag": "W/\"ok\"" } } }"""); + + Assert.Null(s.Repos["multiseat"].ETag); + Assert.Equal("W/\"ok\"", s.Repos["apollovibe"].ETag); + } + + [Fact] + public void Load_DropsBadCandidatesAndBadHashes_KeepsGoodOnes() + { + var s = LoadFromText($$""" + { "schema": 1, "repos": { "apollovibe": { "candidates": [ + { "tag": "v2026.6.1-ms6", "sunshineSha256": "{{Sha('C')}}" }, + { "tag": "", "sunshineSha256": "{{Sha('a')}}" }, + { "tag": "v2026.6.1-ms5", "sunshineSha256": "not-a-hash" }, + null, + { "tag": "{{new string('t', 600)}}" } ] } }, + "apolloExeHash": { "path": "C:\\x\\sunshine.exe", "size": 5, "mtimeUtc": "2026-10-01T00:00:00Z", "sha256": "short" } } + """); + + var c = s.Repos["apollovibe"].Candidates; + Assert.Equal(["v2026.6.1-ms6", "v2026.6.1-ms5"], c.Select(x => x.Tag)); + Assert.Equal(Sha('c'), c[0].SunshineSha256); // normalised to lower case + Assert.Null(c[1].SunshineSha256); + Assert.Null(s.ApolloExeHash); // a hash that is not 64 hex digits is not a cache entry + } + + [Fact] + public void Load_CapsTheNumberOfCandidates_AndOverlongStrings() + { + var many = string.Join(",", Enumerable.Range(0, 300).Select(i => $"{{\"tag\":\"v0.0.{i}\"}}")); + var s = LoadFromText($$"""{ "schema": 1, "repos": { "multiseat": { "lastError": "{{new string('e', 5000)}}", "candidates": [{{many}}] } } }"""); + + Assert.Equal(100, s.Repos["multiseat"].Candidates.Count); + Assert.Equal(512, s.Repos["multiseat"].LastError!.Length); + } + + [Fact] + public void Load_ClampsTheFailureCount() + { + var s = LoadFromText("""{ "schema": 1, "repos": { "multiseat": { "backoff": { "consecutiveFailures": 2000000000 } }, "apollovibe": { "backoff": { "consecutiveFailures": -4 } } } }"""); + + Assert.Equal(1000, s.Repos["multiseat"].Backoff.ConsecutiveFailures); + Assert.Equal(0, s.Repos["apollovibe"].Backoff.ConsecutiveFailures); + } + + // ── What the file must not contain ──────────────────────────────── + + [Fact] + public void SavedFile_HoldsNoReleaseNotesNoUrlsNoSecretsNoHostName() + { + var s = Sample(); + Store().Save(s); + + var text = File.ReadAllText(FilePath); + + Assert.DoesNotContain("body", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("://", text); + Assert.DoesNotContain("token", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("password", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("apikey", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(Environment.MachineName, text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(Environment.UserName, text.Replace("Program Files", ""), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_RejectsAnEmptyPath() + { + Assert.Throws(() => new UpdateStateStore("")); + Assert.Throws(() => new UpdateStateStore(" ")); + } + + [Fact] + public async Task ConcurrentSaves_AllSucceed_AndLeaveOneCompleteFile() + { + var store = Store(); + var results = new System.Collections.Concurrent.ConcurrentBag(); + var tasks = Enumerable.Range(0, 8).Select(i => Task.Run(() => + { + for (var n = 0; n < 15; n++) + { + var s = Sample(); + s.GetOrAdd(UpdateComponent.MultiSeat).Baseline = $"writer-{i}-{n}"; + results.Add(store.Save(s)); + } + })); + + await Task.WhenAll(tasks); + + Assert.Equal(120, results.Count); + Assert.DoesNotContain(false, results); + Assert.Single(Directory.GetFiles(_dir)); + Assert.StartsWith("writer-", store.Load().Repos["multiseat"].Baseline); + Assert.Equal("W/\"bcd412d8\"", store.Load().Repos["apollovibe"].ETag); + } +} From 9caca233ed429d9b2de1303479e02389f704f08d Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 07:23:10 -0500 Subject: [PATCH 5/5] feat(updates): installed-version detectors for MultiSeat and ApolloVibe MultiSeat from the running assembly's informational version. ApolloVibe in order: a release.json marker whose hash matches the real sunshine.exe, the SHA-256 of sunshine.exe against the release notes, the PE commit against a release's full-SHA target, else unknown or not installed. The exe is opened with FileShare.ReadWrite|Delete and the hash is cached by path, size and mtime. A commit match is labelled and never reported as the release; the PE 2026.6.1 is never used as a version. Co-Authored-By: Claude Sonnet 5.5 --- .../Updates/InstalledVersions.cs | 273 ++++++++ .../Updates/InstalledVersionsTests.cs | 626 ++++++++++++++++++ 2 files changed, 899 insertions(+) create mode 100644 src/MultiSeat.Service/Updates/InstalledVersions.cs create mode 100644 src/MultiSeat.Tests/Updates/InstalledVersionsTests.cs 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.Tests/Updates/InstalledVersionsTests.cs b/src/MultiSeat.Tests/Updates/InstalledVersionsTests.cs new file mode 100644 index 0000000..a09f800 --- /dev/null +++ b/src/MultiSeat.Tests/Updates/InstalledVersionsTests.cs @@ -0,0 +1,626 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MultiSeat.Service.Configuration; +using MultiSeat.Service.Updates; +using Xunit; + +namespace MultiSeat.Tests.Updates; + +/// +/// Installed-version detection. Everything runs against temp files: the real C:\Program Files +/// tree, the services and the host's state are never touched, and no test makes a network call. +/// +public class InstalledVersionsTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "multiseat-installed-" + Guid.NewGuid().ToString("N")); + private string ExePath => Path.Combine(_dir, "sunshine.exe"); + + public InstalledVersionsTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ } + } + + private static readonly DateTime Mtime = new(2026, 10, 1, 12, 0, 0, DateTimeKind.Utc); + + private void WriteExe(byte[] bytes, DateTime? mtime = null) + { + File.WriteAllBytes(ExePath, bytes); + File.SetLastWriteTimeUtc(ExePath, mtime ?? Mtime); + } + + private static byte[] Bytes(string seed) => Encoding.UTF8.GetBytes("MZ fake sunshine " + seed).Concat(new byte[4096]).ToArray(); + + private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private const string Ms6Commit = "681e36a885fa2adc6232152fa099db80bdc0a976"; + + private static ReleaseCandidate Cand(string tag, string? sha = null, string? commit = null) => + new() { Tag = tag, SunshineSha256 = sha, TargetCommit = commit }; + + private static Func Pe(string? version) => _ => version; + + private ApolloDetectionResult Detect(IReadOnlyCollection releases, InstalledExeHash? cache = null, string? pe = "2026.6.1") => + InstalledVersions.DetectApollo(ExePath, releases, cache, Pe(pe)); + + // ── MultiSeat ───────────────────────────────────────────────────── + + [Fact] + public void MultiSeat_ParsesTheInformationalVersion_AndKeepsTheCommitForDisplay() + { + var d = InstalledVersions.DetectMultiSeat("0.6.19+b7e6d9539"); + + Assert.Equal(DetectionOutcome.Identified, d.Outcome); + Assert.Equal("0.6.19", d.Version!.Display); + Assert.Equal(new InstalledInfo("0.6.19", "0.6.19 (b7e6d95)", InstalledSource.Assembly, null), d.Installed); + } + + [Theory] + [InlineData("0.6.19", "0.6.19")] + [InlineData("v0.6.19", "0.6.19")] + [InlineData("0.7.0-rc1+abc", "0.7.0-rc1 (abc)")] + [InlineData("0.6.19+abc", "0.6.19 (abc)")] + public void MultiSeat_DisplayShapes(string informational, string display) + { + Assert.Equal(display, InstalledVersions.DetectMultiSeat(informational).Installed!.Display); + } + + [Theory] + [InlineData("1.0.0")] + [InlineData("1.0.0+b7e6d9539")] + [InlineData("garbage")] + [InlineData("")] + [InlineData(null)] + [InlineData("0.6")] + public void MultiSeat_PlaceholderOrGarbage_IsUnknown_NeverAnException_NeverAnUpdate(string? informational) + { + var d = InstalledVersions.DetectMultiSeat(informational); + + Assert.Equal(DetectionOutcome.Unknown, d.Outcome); + Assert.Null(d.Version); + Assert.Null(d.Installed); + Assert.False(string.IsNullOrWhiteSpace(d.Note)); + Assert.Equal(UpdateStatus.UnknownInstalled, ReleaseSelector.Classify(d.Version, ReleaseVersion.TryParse(UpdateComponent.MultiSeat, "v0.6.19"))); + } + + [Fact] + public void MultiSeat_OneDotZeroDotZero_OnlyCountsAsPlaceholderWithoutAPreRelease() + { + Assert.Equal(DetectionOutcome.Identified, InstalledVersions.DetectMultiSeat("1.0.0-rc1").Outcome); + } + + [Fact] + public void RunningVersion_IsTheServiceAssembliesInformationalVersion() + { + var expected = typeof(MultiSeatOptions).Assembly.GetCustomAttribute()!.InformationalVersion; + + Assert.Equal(expected, InstalledVersions.ReadRunningInformationalVersion()); + Assert.False(string.IsNullOrEmpty(expected)); + } + + // ── ApolloVibe: not installed, unknown ──────────────────────────── + + [Fact] + public void Apollo_MissingFile_IsNotInstalled() + { + var r = Detect([Cand("v2026.6.1-ms6", H('a'))]); + + Assert.Equal(DetectionOutcome.NotInstalled, r.Detection.Outcome); + Assert.Null(r.Detection.Installed); + Assert.Contains(ExePath, r.Detection.Note); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Apollo_EmptyPath_IsNotInstalled(string path) + { + var r = InstalledVersions.DetectApollo(path, [], null); + + Assert.Equal(DetectionOutcome.NotInstalled, r.Detection.Outcome); + } + + [Fact] + public void Apollo_APathThatIsADirectory_IsNotInstalled() + { + var r = InstalledVersions.DetectApollo(_dir, [], null); + + Assert.Equal(DetectionOutcome.NotInstalled, r.Detection.Outcome); + } + + [Fact] + public void Apollo_NoMatchAnywhere_IsUnknown_AndSaysWhy() + { + WriteExe(Bytes("locally built")); + + var r = Detect([Cand("v2026.6.1-ms6", H('a'), Ms6Commit)]); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + Assert.Null(r.Detection.Version); + Assert.Null(r.Detection.Installed); + Assert.Contains("no hash match", r.Detection.Note); + } + + // ── release-hash ────────────────────────────────────────────────── + + [Fact] + public void Apollo_HashMatchesARelease_IsReleaseHash_AndTheRightRelease() + { + var bytes = Bytes("ms5"); + WriteExe(bytes); + + var r = Detect([Cand("v2026.6.1-ms6", H('a')), Cand("v2026.6.1-ms5", Sha(bytes)), Cand("v2026.6.1-ms4", H('b'))]); + + Assert.Equal(DetectionOutcome.Identified, r.Detection.Outcome); + Assert.Equal("2026.6.1-ms5", r.Detection.Version!.Display); + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + Assert.Equal("2026.6.1-ms5", r.Detection.Installed.Version); + // It is ms5, not ms6: a real update is available. + Assert.Equal(UpdateStatus.UpdateAvailable, ReleaseSelector.Classify(r.Detection.Version, ReleaseVersion.TryParse(UpdateComponent.ApolloVibe, "v2026.6.1-ms6"))); + } + + [Fact] + public void Apollo_HashMatchIsCaseInsensitive() + { + var bytes = Bytes("upper"); + WriteExe(bytes); + + var r = Detect([Cand("v2026.6.1-ms6", Sha(bytes).ToUpperInvariant())]); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + } + + [Fact] + public void Apollo_OneChangedByte_IsNoLongerAMatch() + { + var bytes = Bytes("ms6"); + var candidates = new[] { Cand("v2026.6.1-ms6", Sha(bytes)) }; + WriteExe(bytes); + Assert.Equal(InstalledSource.ReleaseHash, Detect(candidates).Detection.Installed!.Source); + + var changed = (byte[])bytes.Clone(); + changed[10] ^= 0x01; + WriteExe(changed, Mtime.AddMinutes(1)); + + var r = Detect(candidates); + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + } + + [Fact] + public void Apollo_RealNotesParsedEndToEnd_ThenMatchedAgainstAFileWithThatHash() + { + // Take the real ms6 notes, swap the printed exe hash for the hash of a test file, run the + // real parser and the real matcher on it. + var bytes = Bytes("end to end"); + WriteExe(bytes); + var notes = Fixtures.Notes("apollovibe-ms6.json").Replace("7E9FA1572579879FA98716C74753DA7EC070F0C8209FE5177F2D73CD0C1B33CC", Sha(bytes).ToUpperInvariant()); + var releases = ReleaseSelector.BuildCandidates(UpdateComponent.ApolloVibe, + [new GitHubRelease("v2026.6.1-ms6", null, "master", notes, [], false, false)]); + + var r = Detect(releases); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + Assert.Equal("2026.6.1-ms6", r.Detection.Installed.Version); + } + + [Fact] + public void Apollo_AFileThatMatchesTheZipHashOfARelease_IsNotAMatch() + { + // A hash that only ever appeared as the zip's hash must not identify the exe. + var bytes = Bytes("zip-like"); + WriteExe(bytes); + var notes = $"SHA-256 of `apollovibe-windows-x64.zip`:\n`{Sha(bytes)}`\n\nSHA-256 of `sunshine.exe`:\n`{H('a')}`\n"; + var releases = ReleaseSelector.BuildCandidates(UpdateComponent.ApolloVibe, + [new GitHubRelease("v2026.6.1-ms6", null, "master", notes, [], false, false)]); + + Assert.Equal(DetectionOutcome.Unknown, Detect(releases).Detection.Outcome); + } + + // ── Locked files and hashing failure ────────────────────────────── + + [Fact] + public void Apollo_RunningProcessHoldsTheFileOpenForWriting_StillHashes() + { + var bytes = Bytes("running"); + WriteExe(bytes); + // How a running exe looks to us: open, shared for read, write and delete. + using var holder = new FileStream(ExePath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); + + var r = Detect([Cand("v2026.6.1-ms6", Sha(bytes))]); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + } + + [Fact] + public void Apollo_AFileOpenedWithoutSharing_IsUnknownWithANote_NotAnException() + { + WriteExe(Bytes("exclusive")); + using var holder = new FileStream(ExePath, FileMode.Open, FileAccess.Read, FileShare.None); + + var r = Detect([Cand("v2026.6.1-ms6", H('a'))]); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + Assert.Contains("Could not read sunshine.exe", r.Detection.Note); + Assert.Null(r.Detection.Installed); + } + + [Fact] + public void Apollo_HashingFailure_KeepsTheOldCacheEntry() + { + WriteExe(Bytes("exclusive-2")); + var old = new InstalledExeHash { Path = ExePath, Size = 1, MtimeUtc = Mtime, Sha256 = H('c') }; + using var holder = new FileStream(ExePath, FileMode.Open, FileAccess.Read, FileShare.None); + + var r = Detect([], old); + + Assert.Same(old, r.HashCache); + } + + [Fact] + public void Apollo_AThrowingProductVersionReader_IsUnknown_NotAnException() + { + WriteExe(Bytes("pe-throws")); + + var r = InstalledVersions.DetectApollo(ExePath, [Cand("v2026.6.1-ms6", H('a'), Ms6Commit)], null, + _ => throw new InvalidOperationException("pe reader broke")); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + Assert.Contains("InvalidOperationException", r.Detection.Note); + } + + // ── Cache ───────────────────────────────────────────────────────── + + [Fact] + public void Apollo_FirstRun_ReturnsACacheEntryDescribingTheFile() + { + var bytes = Bytes("cache me"); + WriteExe(bytes); + + var r = Detect([]); + + var c = r.HashCache!; + Assert.Equal(Path.GetFullPath(ExePath), c.Path); + Assert.Equal(bytes.Length, c.Size); + Assert.Equal(Mtime, c.MtimeUtc); + Assert.Equal(Sha(bytes), c.Sha256); + } + + [Fact] + public void Apollo_UnchangedFile_ReusesTheCache_WithoutReadingTheFileAgain() + { + var bytes = Bytes("reuse"); + WriteExe(bytes); + // A cache entry for the same path, size and mtime, but carrying a hash the file does NOT + // have. If the detector re-hashed, it would not match the release below; if it trusts the + // cache (as designed), it does. That makes the reuse observable. + var planted = new InstalledExeHash { Path = Path.GetFullPath(ExePath), Size = bytes.Length, MtimeUtc = Mtime, Sha256 = H('d') }; + + var r = Detect([Cand("v2026.6.1-ms6", H('d'))], planted); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + Assert.Same(planted, r.HashCache); + } + + [Fact] + public void Apollo_CacheEntryIsReused_EvenIfThePathCaseDiffers() + { + var bytes = Bytes("case"); + WriteExe(bytes); + var planted = new InstalledExeHash { Path = Path.GetFullPath(ExePath).ToUpperInvariant(), Size = bytes.Length, MtimeUtc = Mtime, Sha256 = H('d') }; + + var r = Detect([Cand("v2026.6.1-ms6", H('d'))], planted); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + } + + [Fact] + public void Apollo_ChangedSize_InvalidatesTheCache() + { + var bytes = Bytes("size"); + WriteExe(bytes); + var stale = new InstalledExeHash { Path = Path.GetFullPath(ExePath), Size = bytes.Length - 1, MtimeUtc = Mtime, Sha256 = H('d') }; + + var r = Detect([Cand("v2026.6.1-ms6", H('d'))], stale); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); // the stale hash was not trusted + Assert.Equal(Sha(bytes), r.HashCache!.Sha256); + Assert.Equal(bytes.Length, r.HashCache.Size); + } + + [Fact] + public void Apollo_ChangedMtime_InvalidatesTheCache() + { + var bytes = Bytes("mtime"); + WriteExe(bytes); + var stale = new InstalledExeHash { Path = Path.GetFullPath(ExePath), Size = bytes.Length, MtimeUtc = Mtime.AddSeconds(-1), Sha256 = H('d') }; + + var r = Detect([Cand("v2026.6.1-ms6", H('d'))], stale); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + Assert.Equal(Sha(bytes), r.HashCache!.Sha256); + Assert.Equal(Mtime, r.HashCache.MtimeUtc); + } + + [Fact] + public void Apollo_CacheForAnotherPath_IsNotUsed() + { + var bytes = Bytes("path"); + WriteExe(bytes); + var other = new InstalledExeHash { Path = Path.Combine(_dir, "other", "sunshine.exe"), Size = bytes.Length, MtimeUtc = Mtime, Sha256 = H('d') }; + + var r = Detect([Cand("v2026.6.1-ms6", H('d'))], other); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + Assert.Equal(Path.GetFullPath(ExePath), r.HashCache!.Path); + } + + [Fact] + public void Apollo_AMalformedCacheHash_IsNotUsed() + { + var bytes = Bytes("bad-cache"); + WriteExe(bytes); + var bad = new InstalledExeHash { Path = Path.GetFullPath(ExePath), Size = bytes.Length, MtimeUtc = Mtime, Sha256 = "nope" }; + + var r = Detect([Cand("v2026.6.1-ms6", Sha(bytes))], bad); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + Assert.Equal(Sha(bytes), r.HashCache!.Sha256); + } + + // ── Marker ──────────────────────────────────────────────────────── + + private void WriteMarker(string json) => File.WriteAllText(Path.Combine(_dir, "release.json"), json); + + private static string MarkerJson(string tag, string sha) => + JsonSerializer.Serialize(new { tag, commit = "abc1234", sunshineSha256 = sha, futureField = 1 }); + + [Fact] + public void Marker_WhoseHashEqualsTheRealExe_IsTrusted_EvenWithNoReleaseInTheTable() + { + var bytes = Bytes("ms7"); + WriteExe(bytes); + WriteMarker(MarkerJson("v2026.6.1-ms7", Sha(bytes).ToUpperInvariant())); + + var r = Detect([Cand("v2026.6.1-ms6", H('a'))]); + + Assert.Equal(DetectionOutcome.Identified, r.Detection.Outcome); + Assert.Equal(InstalledSource.Marker, r.Detection.Installed!.Source); + Assert.Equal("2026.6.1-ms7", r.Detection.Installed.Version); + } + + [Fact] + public void Marker_WhoseHashDiffers_IsNeverTrustedAsAMarker_ItIsMarkerModified() + { + WriteExe(Bytes("replaced after install")); + WriteMarker(MarkerJson("v2026.6.1-ms7", H('a'))); + + var r = Detect([]); + + Assert.Equal(InstalledSource.MarkerModified, r.Detection.Installed!.Source); + Assert.NotEqual(InstalledSource.Marker, r.Detection.Installed.Source); + Assert.Equal("2026.6.1-ms7", r.Detection.Version!.Display); + Assert.Contains("replaced", r.Detection.Installed.Note); + } + + [Fact] + public void Marker_Refuted_ButTheExeIsExactlyAnotherRelease_ReportsThatRelease() + { + // The marker says ms7; the file is byte for byte ms5. The bytes are the stronger evidence. + var ms5 = Bytes("ms5 swapped in"); + WriteExe(ms5); + WriteMarker(MarkerJson("v2026.6.1-ms7", H('a'))); + + var r = Detect([Cand("v2026.6.1-ms5", Sha(ms5))]); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + Assert.Equal("2026.6.1-ms5", r.Detection.Installed.Version); + } + + [Fact] + public void Marker_Refuted_ButTheSourceCommitMatches_ReportsCommitMatch() + { + WriteExe(Bytes("rebuilt from ms6 source")); + WriteMarker(MarkerJson("v2026.6.1-ms7", H('a'))); + + var r = Detect([Cand("v2026.6.1-ms6", H('b'), Ms6Commit)], pe: "2026.6.1.681e36a8.dirty"); + + Assert.Equal(InstalledSource.CommitMatch, r.Detection.Installed!.Source); + } + + [Theory] + [InlineData("")] + [InlineData("not json")] + [InlineData("[]")] + [InlineData("{\"tag\":5,\"sunshineSha256\":\"x\"}")] + [InlineData("{\"tag\":\"v2026.6.1-ms7\"}")] + [InlineData("{\"tag\":\"v2026.6.1-ms7\",\"sunshineSha256\":\"short\"}")] + [InlineData("{\"tag\":\"not-a-tag\",\"sunshineSha256\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}")] + public void Marker_Malformed_IsIgnored_AndTheRestOfTheOrderApplies(string json) + { + var bytes = Bytes("ms6 with bad marker"); + WriteExe(bytes); + WriteMarker(json); + + var r = Detect([Cand("v2026.6.1-ms6", Sha(bytes))]); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + } + + [Fact] + public void Marker_Oversized_IsIgnored() + { + var bytes = Bytes("big marker"); + WriteExe(bytes); + WriteMarker(MarkerJson("v2026.6.1-ms7", Sha(bytes)).Replace("}", ",\"pad\":\"" + new string('x', 100_000) + "\"}")); + + var r = Detect([]); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + } + + [Fact] + public void Marker_NextToADifferentExe_IsOnlyReadFromTheExesFolder() + { + var elsewhere = Path.Combine(_dir, "other"); + Directory.CreateDirectory(elsewhere); + var bytes = Bytes("other folder"); + File.WriteAllBytes(Path.Combine(elsewhere, "sunshine.exe"), bytes); + WriteMarker(MarkerJson("v2026.6.1-ms7", Sha(bytes))); // in _dir, not in "other" + + var r = InstalledVersions.DetectApollo(Path.Combine(elsewhere, "sunshine.exe"), [], null, Pe("2026.6.1")); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + } + + // ── Commit match ────────────────────────────────────────────────── + + [Fact] + public void CommitMatch_IsReportedAsCommitMatch_NeverAsTheRelease() + { + WriteExe(Bytes("built from ms6 source, not the release")); + + var r = Detect([Cand("v2026.6.1-ms6", H('a'), Ms6Commit), Cand("v2026.6.1-ms5", H('b'), "master")], pe: "2026.6.1.681e36a8.dirty"); + + Assert.Equal(DetectionOutcome.Identified, r.Detection.Outcome); + Assert.Equal(InstalledSource.CommitMatch, r.Detection.Installed!.Source); + Assert.NotEqual(InstalledSource.ReleaseHash, r.Detection.Installed.Source); + Assert.NotEqual(InstalledSource.Marker, r.Detection.Installed.Source); + Assert.Contains("same source commit", r.Detection.Installed.Note); + Assert.Contains("not that release", r.Detection.Installed.Note); + Assert.Equal("2026.6.1-ms6", r.Detection.Version!.Display); + } + + [Theory] + [InlineData("2026.6.1.681e36a8")] + [InlineData("2026.6.1.681E36A8.dirty")] + [InlineData("2026.6.1.681e36a885fa2adc6232152fa099db80bdc0a976")] + [InlineData("2026.6.1.681e36a")] + public void CommitMatch_AcceptsTheShapesOfThePeVersion(string pe) + { + WriteExe(Bytes("pe shapes " + pe)); + + var r = Detect([Cand("v2026.6.1-ms6", H('a'), Ms6Commit)], pe: pe); + + Assert.Equal(InstalledSource.CommitMatch, r.Detection.Installed!.Source); + } + + [Theory] + [InlineData("2026.6.1")] // no commit in the version at all + [InlineData("2026.6.1.dirty")] + [InlineData("2026.6.1.0")] // a numeric 4th part is not a commit + [InlineData("2026.6.1.12345")] + [InlineData("2026.6.1.681e36")] // under 7 digits is too short to mean anything + [InlineData("2026.6.1.deadbeef")] // a commit nobody released + [InlineData("2026.6.1.681e36a8.rc1")] + [InlineData("")] + [InlineData(null)] + public void CommitMatch_NotPossible_IsUnknown(string? pe) + { + WriteExe(Bytes("no commit " + pe)); + + var r = Detect([Cand("v2026.6.1-ms6", H('a'), Ms6Commit)], pe: pe); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + } + + [Fact] + public void CommitMatch_IgnoresReleasesWhoseTargetIsNotAFullSha() + { + WriteExe(Bytes("branch names")); + + // target_commitish is often a branch name; a branch that happens to start with the hex digits + // must not count, and neither must an abbreviated sha. + var r = Detect([Cand("v2026.6.1-ms6", H('a'), "681e36a8-branch"), Cand("v2026.6.1-ms5", H('b'), "681e36a8"), Cand("v2026.6.1-ms4", H('c'), Ms6Commit[..39])], + pe: "2026.6.1.681e36a8"); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + } + + [Fact] + public void CommitMatch_SeveralReleasesFromOneCommit_PicksTheHighest() + { + WriteExe(Bytes("two tags one commit")); + + var r = Detect([Cand("v2026.6.1-ms9", H('a'), Ms6Commit), Cand("v2026.6.1-ms10", H('b'), Ms6Commit), Cand("v2026.6.1-ms2", H('c'), Ms6Commit)], + pe: "2026.6.1.681e36a8"); + + Assert.Equal("2026.6.1-ms10", r.Detection.Version!.Display); + } + + [Fact] + public void ReleaseHash_BeatsCommitMatch() + { + var bytes = Bytes("exact bytes of ms5"); + WriteExe(bytes); + + var r = Detect([Cand("v2026.6.1-ms5", Sha(bytes), "master"), Cand("v2026.6.1-ms6", H('a'), Ms6Commit)], pe: "2026.6.1.681e36a8"); + + Assert.Equal(InstalledSource.ReleaseHash, r.Detection.Installed!.Source); + Assert.Equal("2026.6.1-ms5", r.Detection.Version!.Display); + } + + // ── The PE version is not a release version ─────────────────────── + + [Fact] + public void ThePeVersionAlone_NeverIdentifiesARelease() + { + // ms3 to ms6 all carry the PE version 2026.6.1. An exe that says so and matches nothing + // else must not be treated as any of them, in particular not as the latest. + WriteExe(Bytes("pe says 2026.6.1")); + var all = new[] { Cand("v2026.6.1-ms3", H('1')), Cand("v2026.6.1-ms4", H('2')), Cand("v2026.6.1-ms5", H('3')), Cand("v2026.6.1-ms6", H('4')) }; + + var r = Detect(all, pe: "2026.6.1"); + + Assert.Equal(DetectionOutcome.Unknown, r.Detection.Outcome); + Assert.Equal(UpdateStatus.UnknownInstalled, ReleaseSelector.Classify(r.Detection.Version, ReleaseVersion.TryParse(UpdateComponent.ApolloVibe, "v2026.6.1-ms6"))); + } + + [Theory] + [InlineData("2026.6.1", null)] + [InlineData("2026.6.1.681e36a8.dirty", "681e36a8")] + [InlineData("2026.6.1.681e36a8", "681e36a8")] + [InlineData(" 2026.6.1.AbCdEf0 ", "AbCdEf0")] + [InlineData("2026.6.1.dirty", null)] + [InlineData("7.1.431.-1", null)] + [InlineData("garbage", null)] + [InlineData("", null)] + [InlineData(null, null)] + public void ParsePeCommit(string? pe, string? expected) + { + Assert.Equal(expected, InstalledVersions.ParsePeCommit(pe)); + } + + [Fact] + public void RealPeVersionReader_ReadsAnExeWithoutThrowing() + { + // The default reader on a real PE file (the test host itself), plus on a non-PE file. + var host = Environment.ProcessPath!; + File.Copy(host, ExePath); + var notPe = Path.Combine(_dir, "plain", "sunshine.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(notPe)!); + File.WriteAllText(notPe, "not a pe"); + + var real = InstalledVersions.DetectApollo(ExePath, [], null); + var plain = InstalledVersions.DetectApollo(notPe, [], null); + + Assert.Equal(DetectionOutcome.Unknown, real.Detection.Outcome); + Assert.Equal(DetectionOutcome.Unknown, plain.Detection.Outcome); + } + + [Fact] + public void Detection_UsesTheGivenPath_NotTheDefaultInstallLocation() + { + // A host whose MultiSeat:ApolloExePath points somewhere else is judged by that file only. + WriteExe(Bytes("custom location")); + + var r = Detect([]); + + Assert.NotEqual(DetectionOutcome.NotInstalled, r.Detection.Outcome); + Assert.NotEqual(Path.GetFullPath(MultiSeat.Shared.Constants.DefaultApolloPath), r.HashCache!.Path); + } + + private static string H(char c) => new(c, 64); +}