Skip to content

Commit ddd52cc

Browse files
authored
Merge pull request #70 from peopleworks/desktop-update-notice
Tell the user a newer build exists, after asking whether to look
2 parents e115ab1 + 6586aa4 commit ddd52cc

14 files changed

Lines changed: 556 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ A free, privacy-first toolkit for **academic and writing integrity**. It does tw
4646
> Everything else — every rule, the score, the character scan, the citation cross-check, the writer
4747
> baseline, the report — is computed locally and stays there. In the desktop app, the perplexity
4848
> measurement is local too.
49+
>
50+
> The Windows app can also **check whether a newer version has been published**, because it has no
51+
> auto-update and never will. That is not one of the four: it sends no text, no account and no
52+
> identifier — one request to GitHub's public release list, the same one a browser would make. It
53+
> **asks before its first check**, at most one a day, and it never downloads or runs anything for
54+
> you.
4955
5056
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*.
5157

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.Net.Http;
2+
using System.Text.Json;
3+
using SignsOfAI.UI.Services;
4+
5+
namespace SignsOfAI.Desktop;
6+
7+
/// <summary>
8+
/// Asks GitHub which desktop build is the newest, so somebody running an old one finds out.
9+
///
10+
/// It exists because there is no auto-update and there deliberately will not be: the Windows build is
11+
/// unsigned, and a program that downloads and runs code on its own is the behaviour this project
12+
/// tells teachers to be suspicious of. This reports a number and a link. The person decides.
13+
///
14+
/// <b>What leaves the machine.</b> One GET to the public releases endpoint, with no cookie, no
15+
/// account, no identifier and nothing about the document being analysed — GitHub sees an address and
16+
/// a user agent, exactly as it would if the person opened the releases page in a browser. It does not
17+
/// happen until the user has been asked and said yes, and then at most once a day.
18+
///
19+
/// <b>Why not <c>/releases/latest</c>.</b> That resolves to whichever release is newest overall, and
20+
/// this repository publishes two tag lines on purpose — about half the time the newest release is a
21+
/// NuGet one with no desktop build attached. So it lists and filters, and the filtering lives in
22+
/// <see cref="DesktopRelease.Newest"/> where it can be tested without a network.
23+
/// </summary>
24+
public sealed class GitHubUpdateCheck : IUpdateCheck
25+
{
26+
private const string ReleasesApi =
27+
"https://api.github.com/repos/peopleworks/SignsofAI/releases?per_page=30";
28+
29+
private static readonly HttpClient Http = CreateClient();
30+
31+
private static HttpClient CreateClient()
32+
{
33+
// Short on purpose. A version check that hangs is worse than one that fails: the answer is
34+
// discarded either way, and the only difference is how long a background task sits there.
35+
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
36+
http.DefaultRequestHeaders.UserAgent.ParseAdd(
37+
"SignsOfAI-desktop (version check; https://github.com/peopleworks/SignsofAI)");
38+
http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
39+
return http;
40+
}
41+
42+
public bool IsAvailable => true;
43+
44+
public async Task<UpdateStatus> CheckAsync(CancellationToken ct = default)
45+
{
46+
try
47+
{
48+
using var response = await Http.GetAsync(ReleasesApi, ct);
49+
50+
// 403 is the unauthenticated rate limit, and on a school network it is the expected
51+
// answer rather than an exceptional one: sixty requests an hour are shared by every
52+
// machine behind the same address. Nothing to report and nothing to say about it.
53+
if (!response.IsSuccessStatusCode) return UpdateStatus.Nothing;
54+
55+
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
56+
if (json.RootElement.ValueKind is not JsonValueKind.Array) return UpdateStatus.Nothing;
57+
58+
var tags = json.RootElement.EnumerateArray()
59+
.Where(r => !r.TryGetProperty("draft", out var draft) || !draft.GetBoolean())
60+
.Where(r => !r.TryGetProperty("prerelease", out var pre) || !pre.GetBoolean())
61+
.Select(r => r.TryGetProperty("tag_name", out var tag) ? tag.GetString() : null)
62+
.Where(t => t is not null)
63+
.Select(t => t!);
64+
65+
if (DesktopRelease.Newest(tags) is not { } latest) return UpdateStatus.Nothing;
66+
67+
return new UpdateStatus(
68+
latest,
69+
DesktopRelease.ReleasePageFor(latest),
70+
DesktopRelease.IsNewerThan(latest, DesktopVersion.Running()));
71+
}
72+
catch (Exception e) when (e is HttpRequestException or TaskCanceledException
73+
or JsonException or InvalidOperationException
74+
or UriFormatException)
75+
{
76+
// Offline, a proxy that returns an HTML login page, a malformed body. None of these is
77+
// the user's problem and none is worth a message on a page about somebody's essay.
78+
return UpdateStatus.Nothing;
79+
}
80+
}
81+
}

src/SignsOfAI.Desktop/MainWindow.xaml.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ public MainWindow()
3535
// Singleton: loading the weights is expensive and the engine unloads itself when idle.
3636
services.AddSingleton<ILocalPerplexity, DesktopPerplexity>();
3737

38+
// There is no auto-update and there will not be, so the app has to be able to say that a
39+
// newer build exists. It asks before its first check — see IUpdateCheck.
40+
services.AddScoped<IUpdateCheck, GitHubUpdateCheck>();
41+
3842
// Native HTTP: Ollama on localhost is simply reachable, with no CORS workaround to explain.
3943
// The build number travels with it, because a downloaded app is the kind that can be out of
4044
// date and this one used to have no way of saying which it was.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
@* The one thing a downloaded app has to be able to say: there is a newer one.
2+
3+
There is no auto-update and there will not be — the Windows build is unsigned, and a program that
4+
fetches and runs code on its own is the behaviour this project tells teachers to be suspicious of.
5+
So this reports a number and links to the notes. The person decides.
6+
7+
It asks before it checks. That is the whole reason this is a strip and not a silent background
8+
task: it would be the first network call the app makes without being asked, and a tool whose case
9+
rests on "nothing leaves your machine" does not get to make an exception quietly, even one that
10+
sends no text and no identifier. Asked once, remembered, changeable on /download.
11+
12+
Nothing renders in a browser tab, which is always whatever was last deployed. *@
13+
@inherits LocalizedComponent
14+
@inject IUpdateCheck Updates
15+
@inject UpdatePreference Preference
16+
@inject HostCapabilities Host
17+
18+
@if (_state is State.Asking)
19+
{
20+
<div class="update-strip ask" role="status">
21+
<div>
22+
<strong>@L["update.ask"]</strong>
23+
<span class="update-detail">@L["update.ask.detail"]</span>
24+
</div>
25+
<div class="update-actions">
26+
<button class="ghost small" @onclick="@(() => Answer(true))">@L["update.yes"]</button>
27+
<button class="ghost small" @onclick="@(() => Answer(false))">@L["update.no"]</button>
28+
</div>
29+
</div>
30+
}
31+
else if (_state is State.Available && _found is { Latest: { } latest, Url: { } url })
32+
{
33+
<div class="update-strip found" role="status">
34+
<div>
35+
<strong>@L.F("update.available", latest)</strong>
36+
@* Only when there is a build number to name. A host that cannot say which version it is
37+
has nothing useful to put in that sentence, and "you are running —" is worse than
38+
saying less. *@
39+
@if (Host.Version is { } running)
40+
{
41+
<span class="update-detail">@L.F("update.available.detail", running)</span>
42+
}
43+
</div>
44+
<div class="update-actions">
45+
<a class="ghost small" href="@url" target="_blank" rel="noopener">@L["update.see"]</a>
46+
<button class="ghost small" @onclick="Dismiss" title="@L["common.close"]">@L["update.dismiss"]</button>
47+
</div>
48+
</div>
49+
}
50+
51+
@code {
52+
private enum State { Idle, Asking, Available }
53+
54+
private State _state = State.Idle;
55+
private UpdateStatus? _found;
56+
57+
/// <summary>
58+
/// After the first render, never during it: reading the preference is a JavaScript call, and the
59+
/// check that may follow is a network one. Neither belongs in the path that puts the page on
60+
/// screen — a page that waits on GitHub before drawing has made somebody's essay wait on GitHub.
61+
/// </summary>
62+
protected override async Task OnAfterRenderAsync(bool firstRender)
63+
{
64+
if (!firstRender || !Updates.IsAvailable) return;
65+
66+
var consent = await Preference.ConsentAsync();
67+
if (consent is null)
68+
{
69+
_state = State.Asking;
70+
StateHasChanged();
71+
return;
72+
}
73+
74+
if (consent is true) await Check();
75+
}
76+
77+
private async Task Answer(bool agreed)
78+
{
79+
await Preference.SetConsentAsync(agreed);
80+
_state = State.Idle;
81+
82+
// Checking straight away, so saying yes does something visible rather than promising to do
83+
// something tomorrow.
84+
if (agreed) await Check();
85+
else StateHasChanged();
86+
}
87+
88+
private async Task Check()
89+
{
90+
var today = DateOnly.FromDateTime(DateTime.Now);
91+
if (!await Preference.DueAsync(today)) return;
92+
93+
var status = await Updates.CheckAsync();
94+
await Preference.MarkCheckedAsync(today);
95+
96+
// A failed check and an up-to-date build are the same answer here, and both are silence.
97+
if (!status.IsNewer || status.Latest is null) return;
98+
99+
// Told once per version, not once per launch.
100+
if (await Preference.DismissedAsync() == status.Latest) return;
101+
102+
_found = status;
103+
_state = State.Available;
104+
StateHasChanged();
105+
}
106+
107+
private async Task Dismiss()
108+
{
109+
if (_found?.Latest is { } latest) await Preference.DismissAsync(latest);
110+
_state = State.Idle;
111+
}
112+
}

src/SignsOfAI.UI/Layout/MainLayout.razor

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
<LanguageSwitch />
3636
</div>
3737
</nav>
38+
@* Above the content, below the navigation: it is about the app, not about the document. Renders
39+
nothing at all in a host that cannot be out of date. *@
40+
<UpdateNotice />
3841
<main>
3942
@Body
4043
</main>

src/SignsOfAI.UI/Pages/Download.razor

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
@page "/download"
1313
@inherits LocalizedComponent
1414
@inject HostCapabilities Host
15+
@inject IUpdateCheck Updates
16+
@inject UpdatePreference Preference
1517

1618
<PageTitle>@L["dl.pagetitle"]</PageTitle>
1719

@@ -34,6 +36,20 @@
3436
<p class="hint sub">
3537
<a href="@DesktopRelease.AllReleasesUrl" target="_blank" rel="noopener">@L["dl.all"]</a>
3638
</p>
39+
40+
@* Where the answer given on first run can be changed, since that is the only place the
41+
question is ever asked. It is a checkbox and not a button because the choice is standing,
42+
not an action. *@
43+
@if (Updates.IsAvailable)
44+
{
45+
<label class="inline dl-updates">
46+
<input type="checkbox" checked="@_checkForUpdates" @onchange="ToggleUpdates" />
47+
<span>
48+
@L["dl.updates.label"]
49+
<span class="hint sub">@L["dl.updates.detail"]</span>
50+
</span>
51+
</label>
52+
}
3753
</section>
3854
}
3955
else
@@ -91,6 +107,23 @@ else
91107
</section>
92108

93109
@code {
110+
private bool _checkForUpdates;
111+
112+
protected override async Task OnAfterRenderAsync(bool firstRender)
113+
{
114+
if (!firstRender || !Updates.IsAvailable) return;
115+
116+
// Unanswered reads as off here, which is what it is: nothing has been checked.
117+
_checkForUpdates = await Preference.ConsentAsync() is true;
118+
StateHasChanged();
119+
}
120+
121+
private async Task ToggleUpdates(ChangeEventArgs e)
122+
{
123+
_checkForUpdates = e.Value is true;
124+
await Preference.SetConsentAsync(_checkForUpdates);
125+
}
126+
94127
// Same order as the argument they make: the two a teacher meets first, then the two about
95128
// measuring locally. Keys are built from these, so LocaleFileTests lists them by hand.
96129
private static readonly string[] Adds = ["documents", "folder", "perplexity", "ollama"];

src/SignsOfAI.UI/Services/DesktopRelease.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,55 @@ public static class DesktopRelease
3636
/// <summary>Every desktop release, for somebody who wants an older build or its checksum.</summary>
3737
public const string AllReleasesUrl =
3838
"https://github.com/peopleworks/SignsofAI/releases?q=desktop&expanded=true";
39+
40+
/// <summary>The prefix that separates this tag line from the one that publishes the packages.</summary>
41+
public const string TagPrefix = "desktop-v";
42+
43+
/// <summary>
44+
/// The newest desktop version among a list of tag names, or null if none of them is one.
45+
///
46+
/// Pure, and separated from whatever fetched the list, because this is the half that can be
47+
/// quietly wrong. Three things it must get right and a naive implementation does not:
48+
///
49+
/// <list type="bullet">
50+
/// <item><b>Order by version, never by date.</b> A release can be re-cut, and this repository
51+
/// publishes two tag lines that interleave — the newest release overall is a NuGet one about half
52+
/// the time.</item>
53+
/// <item><b>Order numerically.</b> Sorted as text, <c>0.10.0</c> comes before <c>0.4.0</c>, and
54+
/// the app would tell everyone they were up to date for the rest of the project's life.</item>
55+
/// <item><b>Ignore what it cannot parse.</b> A prerelease tag is not something to advertise to a
56+
/// teacher, and an unparseable one is not something to guess at.</item>
57+
/// </list>
58+
/// </summary>
59+
public static string? Newest(IEnumerable<string> tagNames)
60+
{
61+
ArgumentNullException.ThrowIfNull(tagNames);
62+
63+
// `Version` here is this class's own constant, so the type needs its full name.
64+
System.Version? best = null;
65+
foreach (var tag in tagNames)
66+
{
67+
if (tag is null || !tag.StartsWith(TagPrefix, StringComparison.Ordinal)) continue;
68+
if (!System.Version.TryParse(tag[TagPrefix.Length..], out var version)) continue;
69+
if (best is null || version > best) best = version;
70+
}
71+
72+
return best?.ToString();
73+
}
74+
75+
/// <summary>
76+
/// Whether <paramref name="candidate"/> is strictly newer than the build being run.
77+
///
78+
/// False when either side cannot be parsed, which covers the case that matters: a developer build
79+
/// reports the SDK's own 1.0.0, and telling a maintainer they are behind because 0.5.0 sorts lower
80+
/// would be noise. Equal versions are not newer, so a current build says nothing at all.
81+
/// </summary>
82+
public static bool IsNewerThan(string? candidate, string? running) =>
83+
System.Version.TryParse(candidate, out var latest)
84+
&& System.Version.TryParse(running, out var current)
85+
&& latest > current;
86+
87+
/// <summary>The release page for a version, for a notice that links to the notes and nothing else.</summary>
88+
public static string ReleasePageFor(string version) =>
89+
$"https://github.com/peopleworks/SignsofAI/releases/tag/{TagPrefix}{version}";
3990
}

0 commit comments

Comments
 (0)