teamsiso/src/TeamsISO.App/Services/UpdateChecker.cs
Zac Gaetano 6505a3cab0 test: services — NotesService, UpdateChecker, PresetApplier, OscBridge, IsoController
Punch-list items 19–25 — covers six of the seven services + the
engine controller. TeamsLauncher fallback chain (item 21) is deferred:
it depends on Process.Start in ways that don't unit-test cleanly
without a process-launch seam that the May 2026 codebase doesn't
have yet.

Service seams added for testability (each marked internal + a
matching InternalsVisibleTo-equivalent grant via the existing
TeamsISO.App.Tests visibility):
* NotesService.DirectoryOverride — redirect %LOCALAPPDATA%\TeamsISO\Notes
* WindowStateStore.PathOverride — redirect window.json
* UpdateChecker.StateDirectoryOverride — redirect both the 24h
  cooldown stamp and the no-update-check.flag
* UpdateChecker.TryParseSemVer — visibility bumped to internal
* OscBridge.DispatchAsync — visibility bumped to internal so tests
  can drive route dispatch without spinning up the UDP receive loop

New test files (App.Tests):
* Services/NotesServiceTests.cs (6 cases) — header-once, timestamp
  format, multi-append, whitespace trim + reject, today-path shape.
* Services/UpdateCheckerTests.cs (7 cases) — TryParseSemVer Theory
  across the v?X.Y.Z(.N)(-suffix) inputs the real release stream
  produces, semver ordering pin, CheckIfDueAsync short-circuit on
  recent stamps (the throttle never fires HTTP — deterministic
  offline), LaunchCheckEnabled round-trip via the opt-out flag.
* Services/PresetApplierTests.cs (6 cases) — the four enable/disable
  state transitions, case-insensitive display-name join, partial
  meeting (preset names participants not present), live participants
  unnamed by the preset stay untouched.
* Services/PresetStoreCollection.cs — xUnit collection so any test
  class that mutates OperatorPresetStore.PathOverride serializes
  with siblings that do the same. OperatorPresetStoreTests now joins
  the collection (the class comment claimed it didn't need one
  because file paths were per-test-unique — true, but PathOverride
  is shared static state, which is why the new PresetApplierTests
  was clobbering its result on first run).
* Services/WindowStateStoreTests.cs (6 cases) — JSON round-trip
  through the Snapshot record + all the bail paths (no file, too
  small, too large, fully off-screen, garbage JSON). Full Window
  property write coverage is deferred to branch 11 (needs STA).
* Services/OscBridgeDispatchTests.cs (5 cases) — /teamsiso/refresh-
  discovery + unknown-address + /teamsiso/notes + clean bail when
  the toggle/preset paths can't reach a dispatcher.

New test cases (Engine.Tests):
* Controller/IsoControllerTests.cs gains three cases —
  SetRecording_TogglesEnabledAndStoresDirectory,
  AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders,
  RefreshDiscovery_SetsRefreshFlagOnDiscoveryService.

Tests: 56 → 128 in App.Tests; 103 → 106 in Engine.Tests. Total
green: 234. Build clean (0 warnings, 0 errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:06:45 -04:00

243 lines
8.9 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
namespace TeamsISO.App.Services;
/// <summary>
/// Asks Forgejo's REST API whether a newer release tag exists than the one
/// we're running. Manual-only for v1 — there's no background polling. The
/// operator can click "Check for updates" in the About dialog whenever they
/// want, and a positive result opens the release page in their browser
/// (rather than auto-downloading; we don't want a long-running show
/// interrupted by a surprise installer).
///
/// We use the public release endpoint so no auth is needed:
/// GET /api/v1/repos/zgaetano/teamsiso/releases?limit=1
///
/// On any error (offline, DNS failure, repo private, malformed response),
/// the caller gets <see cref="UpdateCheckResult.Failed"/> with a short
/// human-readable message rather than an exception.
/// </summary>
public static class UpdateChecker
{
private const string ReleasesApi =
"https://forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1";
private const string ReleasesPage =
"https://forge.wilddragon.net/zgaetano/teamsiso/releases";
/// <summary>Outcome of a single check.</summary>
public sealed record UpdateCheckResult(
UpdateStatus Status,
string? LatestTag,
string? CurrentVersion,
string? Message)
{
public static UpdateCheckResult Failed(string message) =>
new(UpdateStatus.Error, null, null, message);
}
public enum UpdateStatus
{
UpToDate,
UpdateAvailable,
Error,
}
public static async Task<UpdateCheckResult> CheckAsync(CancellationToken ct = default)
{
var current = GetCurrentVersion();
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
client.DefaultRequestHeaders.Add("User-Agent", "TeamsISO/" + current);
using var res = await client.GetAsync(ReleasesApi, ct);
if (!res.IsSuccessStatusCode)
return UpdateCheckResult.Failed($"Server returned {(int)res.StatusCode}.");
var json = await res.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0)
return new UpdateCheckResult(UpdateStatus.UpToDate, null, current,
"No releases published yet.");
var first = doc.RootElement[0];
if (!first.TryGetProperty("tag_name", out var tagProp))
return UpdateCheckResult.Failed("Release record missing tag_name.");
var latestTag = tagProp.GetString();
if (string.IsNullOrWhiteSpace(latestTag))
return UpdateCheckResult.Failed("Latest tag was empty.");
var latestVersion = TryParseSemVer(latestTag);
var currentVersion = TryParseSemVer("v" + current);
if (latestVersion is null || currentVersion is null)
return new UpdateCheckResult(UpdateStatus.UpToDate, latestTag, current,
"Couldn't compare versions; latest tag is " + latestTag);
return latestVersion > currentVersion
? new UpdateCheckResult(UpdateStatus.UpdateAvailable, latestTag, current,
$"A newer version ({latestTag}) is available.")
: new UpdateCheckResult(UpdateStatus.UpToDate, latestTag, current,
"You're on the latest release.");
}
catch (TaskCanceledException)
{
return UpdateCheckResult.Failed("Update check timed out.");
}
catch (HttpRequestException ex)
{
return UpdateCheckResult.Failed("Couldn't reach the update server: " + ex.Message);
}
catch (Exception ex)
{
return UpdateCheckResult.Failed("Unexpected error: " + ex.Message);
}
}
/// <summary>
/// Open the releases page in the user's default browser. Used by the
/// "Update available" dialog button — we deliberately don't download/run
/// the MSI ourselves, so the operator decides when to install.
/// </summary>
public static void OpenReleasesPage()
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = ReleasesPage,
UseShellExecute = true,
});
}
catch
{
// best-effort; the dialog already shows the URL as text fallback
}
}
/// <summary>
/// Silent throttled launch-time check. Returns the result if a check actually
/// happened, or null if the cooldown window suppressed it. The cooldown lives
/// in <c>%LOCALAPPDATA%\TeamsISO\last-update-check.txt</c> as an ISO 8601
/// timestamp; a missing file means "never checked, do it now."
/// </summary>
public static async Task<UpdateCheckResult?> CheckIfDueAsync(TimeSpan cooldown, CancellationToken ct = default)
{
try
{
var path = CooldownPath;
if (File.Exists(path))
{
var raw = File.ReadAllText(path).Trim();
if (DateTimeOffset.TryParse(raw, out var last) &&
DateTimeOffset.UtcNow - last < cooldown)
{
return null;
}
}
}
catch
{
// Throttle check is best-effort; on read failures we just check now.
}
var result = await CheckAsync(ct);
try
{
var dir = Path.GetDirectoryName(CooldownPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(CooldownPath, DateTimeOffset.UtcNow.ToString("o"));
}
catch
{
// If we can't write the cooldown stamp, future launches will check
// again immediately. Annoying but not broken.
}
return result;
}
/// <summary>
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO path that holds the cooldown stamp +
/// the opt-out flag. Tests use this to write to a tempdir so
/// CheckIfDueAsync's throttle path can be exercised without
/// hitting real disk paths or the real network (the throttle
/// short-circuits before the HTTP call).
/// </summary>
internal static string? StateDirectoryOverride { get; set; }
private static string StateDirectory => StateDirectoryOverride ??
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO");
private static string CooldownPath =>
Path.Combine(StateDirectory, "last-update-check.txt");
private static string OptOutPath =>
Path.Combine(StateDirectory, "no-update-check.flag");
/// <summary>
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
/// the absence of the file means "checks on" (default), the presence means
/// "checks off". Operators can ship the flag file via group policy / config-
/// management to suppress checks across a fleet.
/// </summary>
public static bool LaunchCheckEnabled
{
get => !File.Exists(OptOutPath);
set
{
try
{
if (value)
{
if (File.Exists(OptOutPath)) File.Delete(OptOutPath);
}
else
{
var dir = Path.GetDirectoryName(OptOutPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(OptOutPath, "Update checks suppressed by user. Delete this file to re-enable.");
}
}
catch
{
// Best-effort; toggle won't persist if disk is read-only, but
// the in-memory checkbox state still reflects the user's intent
// for this session.
}
}
}
private static string GetCurrentVersion()
{
var asm = typeof(UpdateChecker).Assembly;
return asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? asm.GetName().Version?.ToString()
?? "0.0.0";
}
/// <summary>
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any
/// pre-release suffix ("-alpha", "-beta") so the comparison is on
/// numeric components only — pre-release vs. release ordering is a
/// follow-up if we need it. Internal so tests can pin parsing
/// behaviour without HTTP.
/// </summary>
internal static Version? TryParseSemVer(string s)
{
var trimmed = s.TrimStart('v', 'V');
var dash = trimmed.IndexOf('-');
if (dash >= 0) trimmed = trimmed[..dash];
return Version.TryParse(trimmed, out var v) ? v : null;
}
}