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;
}
}