diff --git a/README.md b/README.md index 856100c..693a2ca 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ A free, privacy-first toolkit for **academic and writing integrity**. It does tw > Everything else — every rule, the score, the character scan, the citation cross-check, the writer > baseline, the report — is computed locally and stays there. In the desktop app, the perplexity > measurement is local too. +> +> The Windows app can also **check whether a newer version has been published**, because it has no +> auto-update and never will. That is not one of the four: it sends no text, no account and no +> identifier — one request to GitHub's public release list, the same one a browser would make. It +> **asks before its first check**, at most one a day, and it never downloads or runs anything for +> you. Built with **.NET 10** and **Blazor WebAssembly** by **Pedro Hernández (PeopleWorks)**, [Microsoft MVP for .NET](https://mvp.microsoft.com/en-US/mvp/profile/24060a02-dbc6-44ec-bca5-c213ff9835c5) — for the .NET and Microsoft developer community, *por y para la comunidad educativa*. diff --git a/src/SignsOfAI.Desktop/GitHubUpdateCheck.cs b/src/SignsOfAI.Desktop/GitHubUpdateCheck.cs new file mode 100644 index 0000000..a53b6f2 --- /dev/null +++ b/src/SignsOfAI.Desktop/GitHubUpdateCheck.cs @@ -0,0 +1,81 @@ +using System.Net.Http; +using System.Text.Json; +using SignsOfAI.UI.Services; + +namespace SignsOfAI.Desktop; + +/// +/// Asks GitHub which desktop build is the newest, so somebody running an old one finds out. +/// +/// It exists because there is no auto-update and there deliberately will not be: the Windows build is +/// unsigned, and a program that downloads and runs code on its own is the behaviour this project +/// tells teachers to be suspicious of. This reports a number and a link. The person decides. +/// +/// What leaves the machine. One GET to the public releases endpoint, with no cookie, no +/// account, no identifier and nothing about the document being analysed — GitHub sees an address and +/// a user agent, exactly as it would if the person opened the releases page in a browser. It does not +/// happen until the user has been asked and said yes, and then at most once a day. +/// +/// Why not /releases/latest. That resolves to whichever release is newest overall, and +/// this repository publishes two tag lines on purpose — about half the time the newest release is a +/// NuGet one with no desktop build attached. So it lists and filters, and the filtering lives in +/// where it can be tested without a network. +/// +public sealed class GitHubUpdateCheck : IUpdateCheck +{ + private const string ReleasesApi = + "https://api.github.com/repos/peopleworks/SignsofAI/releases?per_page=30"; + + private static readonly HttpClient Http = CreateClient(); + + private static HttpClient CreateClient() + { + // Short on purpose. A version check that hangs is worse than one that fails: the answer is + // discarded either way, and the only difference is how long a background task sits there. + var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + http.DefaultRequestHeaders.UserAgent.ParseAdd( + "SignsOfAI-desktop (version check; https://github.com/peopleworks/SignsofAI)"); + http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + return http; + } + + public bool IsAvailable => true; + + public async Task CheckAsync(CancellationToken ct = default) + { + try + { + using var response = await Http.GetAsync(ReleasesApi, ct); + + // 403 is the unauthenticated rate limit, and on a school network it is the expected + // answer rather than an exceptional one: sixty requests an hour are shared by every + // machine behind the same address. Nothing to report and nothing to say about it. + if (!response.IsSuccessStatusCode) return UpdateStatus.Nothing; + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + if (json.RootElement.ValueKind is not JsonValueKind.Array) return UpdateStatus.Nothing; + + var tags = json.RootElement.EnumerateArray() + .Where(r => !r.TryGetProperty("draft", out var draft) || !draft.GetBoolean()) + .Where(r => !r.TryGetProperty("prerelease", out var pre) || !pre.GetBoolean()) + .Select(r => r.TryGetProperty("tag_name", out var tag) ? tag.GetString() : null) + .Where(t => t is not null) + .Select(t => t!); + + if (DesktopRelease.Newest(tags) is not { } latest) return UpdateStatus.Nothing; + + return new UpdateStatus( + latest, + DesktopRelease.ReleasePageFor(latest), + DesktopRelease.IsNewerThan(latest, DesktopVersion.Running())); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException + or JsonException or InvalidOperationException + or UriFormatException) + { + // Offline, a proxy that returns an HTML login page, a malformed body. None of these is + // the user's problem and none is worth a message on a page about somebody's essay. + return UpdateStatus.Nothing; + } + } +} diff --git a/src/SignsOfAI.Desktop/MainWindow.xaml.cs b/src/SignsOfAI.Desktop/MainWindow.xaml.cs index 96415f2..308ea58 100644 --- a/src/SignsOfAI.Desktop/MainWindow.xaml.cs +++ b/src/SignsOfAI.Desktop/MainWindow.xaml.cs @@ -35,6 +35,10 @@ public MainWindow() // Singleton: loading the weights is expensive and the engine unloads itself when idle. services.AddSingleton(); + // There is no auto-update and there will not be, so the app has to be able to say that a + // newer build exists. It asks before its first check — see IUpdateCheck. + services.AddScoped(); + // Native HTTP: Ollama on localhost is simply reachable, with no CORS workaround to explain. // The build number travels with it, because a downloaded app is the kind that can be out of // date and this one used to have no way of saying which it was. diff --git a/src/SignsOfAI.UI/Components/UpdateNotice.razor b/src/SignsOfAI.UI/Components/UpdateNotice.razor new file mode 100644 index 0000000..ced3a29 --- /dev/null +++ b/src/SignsOfAI.UI/Components/UpdateNotice.razor @@ -0,0 +1,112 @@ +@* The one thing a downloaded app has to be able to say: there is a newer one. + + There is no auto-update and there will not be — the Windows build is unsigned, and a program that + fetches and runs code on its own is the behaviour this project tells teachers to be suspicious of. + So this reports a number and links to the notes. The person decides. + + It asks before it checks. That is the whole reason this is a strip and not a silent background + task: it would be the first network call the app makes without being asked, and a tool whose case + rests on "nothing leaves your machine" does not get to make an exception quietly, even one that + sends no text and no identifier. Asked once, remembered, changeable on /download. + + Nothing renders in a browser tab, which is always whatever was last deployed. *@ +@inherits LocalizedComponent +@inject IUpdateCheck Updates +@inject UpdatePreference Preference +@inject HostCapabilities Host + +@if (_state is State.Asking) +{ +
+
+ @L["update.ask"] + @L["update.ask.detail"] +
+
+ + +
+
+} +else if (_state is State.Available && _found is { Latest: { } latest, Url: { } url }) +{ +
+
+ @L.F("update.available", latest) + @* Only when there is a build number to name. A host that cannot say which version it is + has nothing useful to put in that sentence, and "you are running —" is worse than + saying less. *@ + @if (Host.Version is { } running) + { + @L.F("update.available.detail", running) + } +
+
+ @L["update.see"] + +
+
+} + +@code { + private enum State { Idle, Asking, Available } + + private State _state = State.Idle; + private UpdateStatus? _found; + + /// + /// After the first render, never during it: reading the preference is a JavaScript call, and the + /// check that may follow is a network one. Neither belongs in the path that puts the page on + /// screen — a page that waits on GitHub before drawing has made somebody's essay wait on GitHub. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || !Updates.IsAvailable) return; + + var consent = await Preference.ConsentAsync(); + if (consent is null) + { + _state = State.Asking; + StateHasChanged(); + return; + } + + if (consent is true) await Check(); + } + + private async Task Answer(bool agreed) + { + await Preference.SetConsentAsync(agreed); + _state = State.Idle; + + // Checking straight away, so saying yes does something visible rather than promising to do + // something tomorrow. + if (agreed) await Check(); + else StateHasChanged(); + } + + private async Task Check() + { + var today = DateOnly.FromDateTime(DateTime.Now); + if (!await Preference.DueAsync(today)) return; + + var status = await Updates.CheckAsync(); + await Preference.MarkCheckedAsync(today); + + // A failed check and an up-to-date build are the same answer here, and both are silence. + if (!status.IsNewer || status.Latest is null) return; + + // Told once per version, not once per launch. + if (await Preference.DismissedAsync() == status.Latest) return; + + _found = status; + _state = State.Available; + StateHasChanged(); + } + + private async Task Dismiss() + { + if (_found?.Latest is { } latest) await Preference.DismissAsync(latest); + _state = State.Idle; + } +} diff --git a/src/SignsOfAI.UI/Layout/MainLayout.razor b/src/SignsOfAI.UI/Layout/MainLayout.razor index 986c59e..b513c24 100644 --- a/src/SignsOfAI.UI/Layout/MainLayout.razor +++ b/src/SignsOfAI.UI/Layout/MainLayout.razor @@ -35,6 +35,9 @@ + @* Above the content, below the navigation: it is about the app, not about the document. Renders + nothing at all in a host that cannot be out of date. *@ +
@Body
diff --git a/src/SignsOfAI.UI/Pages/Download.razor b/src/SignsOfAI.UI/Pages/Download.razor index d949c37..987e9a6 100644 --- a/src/SignsOfAI.UI/Pages/Download.razor +++ b/src/SignsOfAI.UI/Pages/Download.razor @@ -12,6 +12,8 @@ @page "/download" @inherits LocalizedComponent @inject HostCapabilities Host +@inject IUpdateCheck Updates +@inject UpdatePreference Preference @L["dl.pagetitle"] @@ -34,6 +36,20 @@

@L["dl.all"]

+ + @* Where the answer given on first run can be changed, since that is the only place the + question is ever asked. It is a checkbox and not a button because the choice is standing, + not an action. *@ + @if (Updates.IsAvailable) + { + + } } else @@ -91,6 +107,23 @@ else @code { + private bool _checkForUpdates; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || !Updates.IsAvailable) return; + + // Unanswered reads as off here, which is what it is: nothing has been checked. + _checkForUpdates = await Preference.ConsentAsync() is true; + StateHasChanged(); + } + + private async Task ToggleUpdates(ChangeEventArgs e) + { + _checkForUpdates = e.Value is true; + await Preference.SetConsentAsync(_checkForUpdates); + } + // Same order as the argument they make: the two a teacher meets first, then the two about // measuring locally. Keys are built from these, so LocaleFileTests lists them by hand. private static readonly string[] Adds = ["documents", "folder", "perplexity", "ollama"]; diff --git a/src/SignsOfAI.UI/Services/DesktopRelease.cs b/src/SignsOfAI.UI/Services/DesktopRelease.cs index 1ff9168..567173d 100644 --- a/src/SignsOfAI.UI/Services/DesktopRelease.cs +++ b/src/SignsOfAI.UI/Services/DesktopRelease.cs @@ -36,4 +36,55 @@ public static class DesktopRelease /// Every desktop release, for somebody who wants an older build or its checksum. public const string AllReleasesUrl = "https://github.com/peopleworks/SignsofAI/releases?q=desktop&expanded=true"; + + /// The prefix that separates this tag line from the one that publishes the packages. + public const string TagPrefix = "desktop-v"; + + /// + /// The newest desktop version among a list of tag names, or null if none of them is one. + /// + /// Pure, and separated from whatever fetched the list, because this is the half that can be + /// quietly wrong. Three things it must get right and a naive implementation does not: + /// + /// + /// Order by version, never by date. A release can be re-cut, and this repository + /// publishes two tag lines that interleave — the newest release overall is a NuGet one about half + /// the time. + /// Order numerically. Sorted as text, 0.10.0 comes before 0.4.0, and + /// the app would tell everyone they were up to date for the rest of the project's life. + /// Ignore what it cannot parse. A prerelease tag is not something to advertise to a + /// teacher, and an unparseable one is not something to guess at. + /// + /// + public static string? Newest(IEnumerable tagNames) + { + ArgumentNullException.ThrowIfNull(tagNames); + + // `Version` here is this class's own constant, so the type needs its full name. + System.Version? best = null; + foreach (var tag in tagNames) + { + if (tag is null || !tag.StartsWith(TagPrefix, StringComparison.Ordinal)) continue; + if (!System.Version.TryParse(tag[TagPrefix.Length..], out var version)) continue; + if (best is null || version > best) best = version; + } + + return best?.ToString(); + } + + /// + /// Whether is strictly newer than the build being run. + /// + /// False when either side cannot be parsed, which covers the case that matters: a developer build + /// reports the SDK's own 1.0.0, and telling a maintainer they are behind because 0.5.0 sorts lower + /// would be noise. Equal versions are not newer, so a current build says nothing at all. + /// + public static bool IsNewerThan(string? candidate, string? running) => + System.Version.TryParse(candidate, out var latest) + && System.Version.TryParse(running, out var current) + && latest > current; + + /// The release page for a version, for a notice that links to the notes and nothing else. + public static string ReleasePageFor(string version) => + $"https://github.com/peopleworks/SignsofAI/releases/tag/{TagPrefix}{version}"; } diff --git a/src/SignsOfAI.UI/Services/IUpdateCheck.cs b/src/SignsOfAI.UI/Services/IUpdateCheck.cs new file mode 100644 index 0000000..ac97b95 --- /dev/null +++ b/src/SignsOfAI.UI/Services/IUpdateCheck.cs @@ -0,0 +1,66 @@ +namespace SignsOfAI.UI.Services; + +/// +/// What a version check found: the newest published build, and whether it is newer than this one. +/// +/// The newest published version, or null when the check could not be made. +/// Where to read about it — the release page, never a file to download. +/// True only when a strictly newer version exists. +public sealed record UpdateStatus(string? Latest, string? Url, bool IsNewer) +{ + /// + /// Nothing to say — the check failed, or this build is current. + /// + /// Failure and "you are up to date" collapse into the same answer on purpose. The interface's + /// only correct response to a failed check is silence: the machine may be behind a school proxy + /// or offline, and an error banner about a version check would be noise on a page about somebody + /// else's essay. + /// + public static UpdateStatus Nothing { get; } = new(null, null, false); +} + +/// +/// Telling the user that a newer build exists, in a host that has to be downloaded and therefore can +/// be out of date. +/// +/// Two things it deliberately does not do, and both are the point: +/// +/// +/// It never downloads or runs anything. The Windows build is unsigned, and a program +/// that fetches and executes code on its own is exactly the behaviour this project tells teachers to +/// be suspicious of. It reports a version and links to the release notes; the person decides. +/// It does not check until the user has said yes. This is the first network call the app +/// would make without being asked, and a tool whose case rests on "nothing leaves your machine" does +/// not get to make an exception quietly — even one that sends no text and no identifier. The consent +/// is asked once, in the app, and remembered. +/// +/// +/// A host that is always current — a browser tab, which serves whatever was last deployed — leaves +/// false and the interface does not mention any of this. +/// +public interface IUpdateCheck +{ + /// False where there is nothing to update: the interface offers nothing. + bool IsAvailable { get; } + + /// + /// Asks the source of releases what the newest published build is. + /// + /// Never throws. No network, a proxy, a rate limit, a malformed answer — all of them return + /// , because none of them is the user's problem and none of + /// them is worth a message on the page. + /// + Task CheckAsync(CancellationToken ct = default); +} + +/// +/// The browser's answer: a page is whatever was last deployed, so it is never out of date and there +/// is nothing to check. +/// +public sealed class NoUpdateCheck : IUpdateCheck +{ + public bool IsAvailable => false; + + public Task CheckAsync(CancellationToken ct = default) => + Task.FromResult(UpdateStatus.Nothing); +} diff --git a/src/SignsOfAI.UI/Services/UpdatePreference.cs b/src/SignsOfAI.UI/Services/UpdatePreference.cs new file mode 100644 index 0000000..26086e5 --- /dev/null +++ b/src/SignsOfAI.UI/Services/UpdatePreference.cs @@ -0,0 +1,60 @@ +namespace SignsOfAI.UI.Services; + +/// +/// What the user has decided about version checks, and how often one is due. +/// +/// Kept out of the component and out of the host so there is one answer to "may we check?" rather +/// than one per surface — the same reason exists, applied to a much +/// smaller question. It stores three things and nothing else: whether they said yes, the day of the +/// last check, and the version they have already been told about. +/// +/// Deliberately no identifier of any kind. There is nothing here that could become one later. +/// +public sealed class UpdatePreference(BrowserStorage storage) +{ + private const string ConsentKey = "signsofai.updates.consent"; + private const string LastCheckKey = "signsofai.updates.lastcheck"; + private const string DismissedKey = "signsofai.updates.dismissed"; + + /// + /// True if they agreed, false if they declined, null if they were never asked. + /// + /// The three states have to stay distinct: "not yet asked" is what makes the app show the + /// question, and collapsing it into "no" would mean the question never appears and nobody ever + /// hears about a fix. + /// + public async ValueTask ConsentAsync() => + await storage.GetAsync(ConsentKey) switch + { + "yes" => true, + "no" => false, + _ => null, + }; + + public async ValueTask SetConsentAsync(bool agreed) + { + await storage.SetAsync(ConsentKey, agreed ? "yes" : "no"); + + // Declining clears the schedule too, so turning it back on later checks immediately rather + // than waiting out a day that was counted while the feature was off. + if (!agreed) await storage.RemoveAsync(LastCheckKey); + } + + /// + /// Whether a check is due. At most one a day, and the reason is somebody else's server: the + /// GitHub API allows 60 unauthenticated requests an hour per address, and a school with forty + /// machines behind one NAT would exhaust that between first and second period. + /// + public async ValueTask DueAsync(DateOnly today) => + await storage.GetAsync(LastCheckKey) is not { } last + || !DateOnly.TryParse(last, out var when) + || when < today; + + public ValueTask MarkCheckedAsync(DateOnly today) => + storage.SetAsync(LastCheckKey, today.ToString("yyyy-MM-dd")); + + /// The version they have already been told about and closed. Told once, not every launch. + public ValueTask DismissedAsync() => storage.GetAsync(DismissedKey); + + public ValueTask DismissAsync(string version) => storage.SetAsync(DismissedKey, version); +} diff --git a/src/SignsOfAI.UI/UiServices.cs b/src/SignsOfAI.UI/UiServices.cs index 10ee820..edb1daf 100644 --- a/src/SignsOfAI.UI/UiServices.cs +++ b/src/SignsOfAI.UI/UiServices.cs @@ -43,8 +43,14 @@ public static IServiceCollection AddSignsOfAiUi(this IServiceCollection services // runtime and a half-gigabyte of weights — so the browser keeps offering the optional server. services.AddScoped(); + // Telling the user a newer build exists. Nothing to tell in a browser tab, which always + // serves whatever was last deployed; a host that gets downloaded replaces this. + services.AddScoped(); + // Local persistence (localStorage in the browser, the WebView's own store on the desktop). services.AddScoped(); + // Whether the user agreed to version checks, and when the last one was. + services.AddScoped(); // User-defined catalogs (custom rule-packs), kept in that same local store. services.AddScoped(); // Interface language (EN/ES/…), remembered locally. diff --git a/src/SignsOfAI.UI/wwwroot/css/app.css b/src/SignsOfAI.UI/wwwroot/css/app.css index e9fcca3..83569e1 100644 --- a/src/SignsOfAI.UI/wwwroot/css/app.css +++ b/src/SignsOfAI.UI/wwwroot/css/app.css @@ -1165,3 +1165,26 @@ button.ghost.sm { padding: .35rem .7rem; font-size: .82rem; } /* The before/after pair of the rewriter, and the folder-scan pill, share the refusal colour. */ .sc-num.unmeasured { color: var(--text-muted); } + +/* ---- new-version strip (desktop host only) ---- + Quiet on purpose. It is about the app, not about the document on the page, and a downloaded tool + that shouts about itself over somebody's essay has misjudged whose afternoon it is. */ +.update-strip { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: .6rem 1rem; + margin: 0 0 1rem; + padding: .6rem .9rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + font-size: .88rem; +} +.update-strip.found { border-color: color-mix(in srgb, var(--brand) 35%, var(--border)); } +.update-strip strong { display: block; color: var(--text); } +.update-detail { color: var(--text-muted); font-size: .82rem; } +.update-actions { display: flex; gap: .4rem; flex-shrink: 0; } +.update-actions .small { font-size: .82rem; padding: .3rem .7rem; } +.update-actions a.small { text-decoration: none; display: inline-flex; align-items: center; } diff --git a/src/SignsOfAI.UI/wwwroot/i18n/en.json b/src/SignsOfAI.UI/wwwroot/i18n/en.json index 72d9bad..b7d160a 100644 --- a/src/SignsOfAI.UI/wwwroot/i18n/en.json +++ b/src/SignsOfAI.UI/wwwroot/i18n/en.json @@ -63,6 +63,16 @@ "dl.warn.step2": "Compare its SHA-256 against the one printed in the release notes — in PowerShell, Get-FileHash.", "dl.warn.step3": "Only then extract it and run SignsOfAI.Desktop.exe: on the warning, More infoRun anyway.", "dl.zip.body": "There is no installer. It is a .zip you extract wherever you like — keep the folder together, because the app reads files next to the executable. To uninstall it, delete the folder.", + "update.ask": "Check for new versions?", + "update.ask.detail": "This app does not update itself. A check asks GitHub which build is newest — no text, no account and no identifier are sent, and nothing is ever downloaded for you. You can change this later on the Windows app page.", + "update.yes": "Yes, check", + "update.no": "No", + "update.available": "Version {0} is published", + "update.available.detail": "You are running {0}. Nothing here updates itself — read what changed and decide.", + "update.see": "What changed", + "update.dismiss": "Not now", + "dl.updates.label": "Tell me when a new version is published", + "dl.updates.detail": "At most one check a day. No text, no account, no identifier — and nothing is downloaded for you.", "footer.runtime.browser": "built with .NET 10 and Blazor WebAssembly · runs 100% in your browser", "footer.runtime.desktop": "built with .NET 10, Blazor and WebView2 · runs on this machine", "nav.catalog": "Catalog", diff --git a/src/SignsOfAI.UI/wwwroot/i18n/es.json b/src/SignsOfAI.UI/wwwroot/i18n/es.json index 55b7702..5bdbda0 100644 --- a/src/SignsOfAI.UI/wwwroot/i18n/es.json +++ b/src/SignsOfAI.UI/wwwroot/i18n/es.json @@ -63,6 +63,16 @@ "dl.warn.step2": "Compara su SHA-256 con el que está impreso en las notas de la versión — en PowerShell, Get-FileHash.", "dl.warn.step3": "Solo entonces descomprímelo y ejecuta SignsOfAI.Desktop.exe: en el aviso, Más informaciónEjecutar de todas formas.", "dl.zip.body": "No hay instalador. Es un .zip que descomprimes donde quieras — mantén la carpeta junta, porque la app lee archivos que están al lado del ejecutable. Para desinstalarla, borra la carpeta.", + "update.ask": "¿Compruebo si hay versiones nuevas?", + "update.ask.detail": "Esta app no se actualiza sola. La comprobación le pregunta a GitHub cuál es la compilación más reciente — no se envía texto, ni cuenta, ni identificador, y nunca se descarga nada por ti. Puedes cambiarlo después en la página de la app de Windows.", + "update.yes": "Sí, comprueba", + "update.no": "No", + "update.available": "Está publicada la versión {0}", + "update.available.detail": "Estás usando la {0}. Aquí nada se actualiza solo — lee qué cambió y decide.", + "update.see": "Qué cambió", + "update.dismiss": "Ahora no", + "dl.updates.label": "Avísame cuando se publique una versión nueva", + "dl.updates.detail": "Como mucho una comprobación al día. Sin texto, sin cuenta, sin identificador — y no se descarga nada por ti.", "footer.runtime.browser": "hecho con .NET 10 y Blazor WebAssembly · funciona 100% en tu navegador", "footer.runtime.desktop": "hecho con .NET 10, Blazor y WebView2 · funciona en esta máquina", "nav.catalog": "Catálogo", diff --git a/tests/SignsOfAI.Desktop.Tests/UpdateCheckTests.cs b/tests/SignsOfAI.Desktop.Tests/UpdateCheckTests.cs new file mode 100644 index 0000000..d788ae9 --- /dev/null +++ b/tests/SignsOfAI.Desktop.Tests/UpdateCheckTests.cs @@ -0,0 +1,91 @@ +using System.Linq; +using SignsOfAI.UI.Services; + +namespace SignsOfAI.Desktop.Tests; + +/// +/// Picking the newest desktop build out of a list of release tags. +/// +/// The network half of the check is not tested here — it is a GET and a try/catch, and a test that +/// stands up a fake GitHub would be testing HttpClient. What is tested is the half that can be +/// quietly wrong for a year: an app that sorts versions as text tells everyone they are up to date +/// from 0.10.0 onward, and nobody reports a message that never appears. +/// +public class UpdateCheckTests +{ + [Fact] + public void The_newest_is_the_highest_version_not_the_last_line() + { + // Deliberately out of order, and with the interleaved NuGet line this repository publishes: + // ordering by position or by the newest release overall picks `v0.9.0`, which has no app. + string[] tags = + [ + "desktop-v0.3.0", "v0.4.0", "desktop-v0.4.0", "v0.9.0", "desktop-v0.2.0", + ]; + + Assert.Equal("0.4.0", DesktopRelease.Newest(tags)); + } + + [Fact] + public void Ten_is_after_four_and_not_before_it() + { + // The bug that would have hidden every update for the rest of the project's life. + Assert.Equal("0.10.0", DesktopRelease.Newest(["desktop-v0.4.0", "desktop-v0.10.0"])); + Assert.Equal("1.0.0", DesktopRelease.Newest(["desktop-v0.10.0", "desktop-v1.0.0"])); + } + + [Theory] + [InlineData("desktop-v0.5.0-rc.1")] // a prerelease is not something to send a teacher to + [InlineData("desktop-vnext")] + [InlineData("desktop-v")] + [InlineData("v0.4.0")] // the package line, not this one + [InlineData("random-tag")] + public void What_it_cannot_parse_it_ignores(string tag) => + Assert.Null(DesktopRelease.Newest([tag])); + + [Fact] + public void An_empty_or_unrelated_list_produces_nothing() + { + Assert.Null(DesktopRelease.Newest([])); + Assert.Null(DesktopRelease.Newest(["v0.1.0", "v0.2.0"])); + } + + [Theory] + [InlineData("0.5.0", "0.4.0", true)] + [InlineData("0.4.0", "0.4.0", false)] // current: say nothing at all + [InlineData("0.4.0", "0.5.0", false)] // ahead of the release: also nothing + [InlineData("0.5.0", "1.0.0", false)] // a developer build reports the SDK's 1.0.0 + [InlineData("0.5.0", null, false)] // no idea what is running: do not guess + [InlineData(null, "0.4.0", false)] + public void Newer_means_strictly_newer(string? candidate, string? running, bool expected) => + Assert.Equal(expected, DesktopRelease.IsNewerThan(candidate, running)); + + /// + /// The link a notice offers is a page to read, never a file to fetch. The build is unsigned, and + /// an app that hands somebody a download it chose is one step from an app that runs it. + /// + [Fact] + public void The_notice_links_to_the_notes_and_not_to_a_zip() + { + var url = DesktopRelease.ReleasePageFor("0.5.0"); + + Assert.Equal("https://github.com/peopleworks/SignsofAI/releases/tag/desktop-v0.5.0", url); + Assert.DoesNotContain(".zip", url); + Assert.DoesNotContain("/download/", url); + } + + /// + /// The browser is never out of date, so it offers none of this and the interface renders nothing. + /// + [Fact] + public async Task A_browser_tab_has_nothing_to_check() + { + var browser = new NoUpdateCheck(); + + Assert.False(browser.IsAvailable); + Assert.Equal(UpdateStatus.Nothing, await browser.CheckAsync()); + } + + [Fact] + public void The_desktop_host_does_offer_it() => Assert.True(new GitHubUpdateCheck().IsAvailable); +}