From 6586aa46c80ccf9306eafdaefc18042e039f098f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Hern=C3=A1ndez?= Date: Mon, 24 Aug 2026 15:02:40 -0400 Subject: [PATCH] Tell the user a newer build exists, after asking whether to look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no auto-update and there will not be. The Windows build is unsigned, and a program that downloads and runs code on its own is precisely the behaviour this project tells teachers to be suspicious of. But without one, somebody who downloaded 0.2.0 in July is still running it — and 0.2.0 gives a verdict on a pasted paragraph, which we now know is a verdict about a length nobody measured. Leaving them there is its own harm. So the app can look, and report a number and a link. The person decides. It asks first. That is the part worth arguing about and the part I would not give up: 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 — not even one that sends no text, no account and no identifier. A quiet strip on first run, two buttons, remembered, changeable on the Windows app page. Until it is answered, nothing is checked. What actually leaves: one GET to the public releases endpoint. GitHub sees an address and a user agent, the same as if the person opened the releases page in a browser. At most once a day — sixty unauthenticated requests an hour are shared by every machine behind a school's NAT, and a class exhausting the limit between first and second period would be our fault. Failure is silence. Offline, a proxy returning an HTML login page, a rate limit, a malformed body: all return the same nothing as "you are up to date", because an error banner about a version check, on a page about somebody's essay, is noise the reader cannot act on. The half that can be quietly wrong is the version picking, so it is pure, lives next to the tag convention it depends on, and is tested without a network. Ordering by date would pick a NuGet release with no app attached, since the two tag lines interleave. Ordering as text would put 0.10.0 before 0.4.0 and tell everyone they were up to date for the rest of the project's life — the kind of bug nobody reports, because the message simply never appears. The README's privacy paragraph says all of this, and says explicitly that this is not a fifth exception to the four: those four can send your text, and this one cannot send anything. 417 tests, 17 new. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015PEbbiYSNPw7jE3LrPNhyF --- README.md | 6 + src/SignsOfAI.Desktop/GitHubUpdateCheck.cs | 81 +++++++++++++ src/SignsOfAI.Desktop/MainWindow.xaml.cs | 4 + .../Components/UpdateNotice.razor | 112 ++++++++++++++++++ src/SignsOfAI.UI/Layout/MainLayout.razor | 3 + src/SignsOfAI.UI/Pages/Download.razor | 33 ++++++ src/SignsOfAI.UI/Services/DesktopRelease.cs | 51 ++++++++ src/SignsOfAI.UI/Services/IUpdateCheck.cs | 66 +++++++++++ src/SignsOfAI.UI/Services/UpdatePreference.cs | 60 ++++++++++ src/SignsOfAI.UI/UiServices.cs | 6 + src/SignsOfAI.UI/wwwroot/css/app.css | 23 ++++ src/SignsOfAI.UI/wwwroot/i18n/en.json | 10 ++ src/SignsOfAI.UI/wwwroot/i18n/es.json | 10 ++ .../UpdateCheckTests.cs | 91 ++++++++++++++ 14 files changed, 556 insertions(+) create mode 100644 src/SignsOfAI.Desktop/GitHubUpdateCheck.cs create mode 100644 src/SignsOfAI.UI/Components/UpdateNotice.razor create mode 100644 src/SignsOfAI.UI/Services/IUpdateCheck.cs create mode 100644 src/SignsOfAI.UI/Services/UpdatePreference.cs create mode 100644 tests/SignsOfAI.Desktop.Tests/UpdateCheckTests.cs 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); +}