232 lines
8.4 KiB
C#
232 lines
8.4 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string CooldownPath =>
|
||
|
|
Path.Combine(
|
||
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||
|
|
"TeamsISO", "last-update-check.txt");
|
||
|
|
|
||
|
|
private static string OptOutPath =>
|
||
|
|
Path.Combine(
|
||
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||
|
|
"TeamsISO", "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.
|
||
|
|
/// </summary>
|
||
|
|
private 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;
|
||
|
|
}
|
||
|
|
}
|