using System.Diagnostics; using System.IO; using System.Net.Http; using System.Reflection; using System.Text.Json; namespace TeamsISO.App.Services; /// /// 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 with a short /// human-readable message rather than an exception. /// 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"; /// Outcome of a single check. 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 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); } } /// /// 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. /// 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 } } /// /// 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 %LOCALAPPDATA%\TeamsISO\last-update-check.txt as an ISO 8601 /// timestamp; a missing file means "never checked, do it now." /// public static async Task 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; } /// /// 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). /// 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"); /// /// 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. /// 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()?.InformationalVersion ?? asm.GetName().Version?.ToString() ?? "0.0.0"; } /// /// 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. /// 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; } }