Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*.

Expand Down
81 changes: 81 additions & 0 deletions src/SignsOfAI.Desktop/GitHubUpdateCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Net.Http;
using System.Text.Json;
using SignsOfAI.UI.Services;

namespace SignsOfAI.Desktop;

/// <summary>
/// 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.
///
/// <b>What leaves the machine.</b> 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.
///
/// <b>Why not <c>/releases/latest</c>.</b> 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
/// <see cref="DesktopRelease.Newest"/> where it can be tested without a network.
/// </summary>
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<UpdateStatus> 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;
}
}
}
4 changes: 4 additions & 0 deletions src/SignsOfAI.Desktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public MainWindow()
// Singleton: loading the weights is expensive and the engine unloads itself when idle.
services.AddSingleton<ILocalPerplexity, DesktopPerplexity>();

// 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<IUpdateCheck, GitHubUpdateCheck>();

// 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.
Expand Down
112 changes: 112 additions & 0 deletions src/SignsOfAI.UI/Components/UpdateNotice.razor
Original file line number Diff line number Diff line change
@@ -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)
{
<div class="update-strip ask" role="status">
<div>
<strong>@L["update.ask"]</strong>
<span class="update-detail">@L["update.ask.detail"]</span>
</div>
<div class="update-actions">
<button class="ghost small" @onclick="@(() => Answer(true))">@L["update.yes"]</button>
<button class="ghost small" @onclick="@(() => Answer(false))">@L["update.no"]</button>
</div>
</div>
}
else if (_state is State.Available && _found is { Latest: { } latest, Url: { } url })
{
<div class="update-strip found" role="status">
<div>
<strong>@L.F("update.available", latest)</strong>
@* 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)
{
<span class="update-detail">@L.F("update.available.detail", running)</span>
}
</div>
<div class="update-actions">
<a class="ghost small" href="@url" target="_blank" rel="noopener">@L["update.see"]</a>
<button class="ghost small" @onclick="Dismiss" title="@L["common.close"]">@L["update.dismiss"]</button>
</div>
</div>
}

@code {
private enum State { Idle, Asking, Available }

private State _state = State.Idle;
private UpdateStatus? _found;

/// <summary>
/// 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.
/// </summary>
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;
}
}
3 changes: 3 additions & 0 deletions src/SignsOfAI.UI/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
<LanguageSwitch />
</div>
</nav>
@* 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. *@
<UpdateNotice />
<main>
@Body
</main>
Expand Down
33 changes: 33 additions & 0 deletions src/SignsOfAI.UI/Pages/Download.razor
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
@page "/download"
@inherits LocalizedComponent
@inject HostCapabilities Host
@inject IUpdateCheck Updates
@inject UpdatePreference Preference

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

Expand All @@ -34,6 +36,20 @@
<p class="hint sub">
<a href="@DesktopRelease.AllReleasesUrl" target="_blank" rel="noopener">@L["dl.all"]</a>
</p>

@* 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)
{
<label class="inline dl-updates">
<input type="checkbox" checked="@_checkForUpdates" @onchange="ToggleUpdates" />
<span>
@L["dl.updates.label"]
<span class="hint sub">@L["dl.updates.detail"]</span>
</span>
</label>
}
</section>
}
else
Expand Down Expand Up @@ -91,6 +107,23 @@ else
</section>

@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"];
Expand Down
51 changes: 51 additions & 0 deletions src/SignsOfAI.UI/Services/DesktopRelease.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,55 @@ public static class DesktopRelease
/// <summary>Every desktop release, for somebody who wants an older build or its checksum.</summary>
public const string AllReleasesUrl =
"https://github.com/peopleworks/SignsofAI/releases?q=desktop&expanded=true";

/// <summary>The prefix that separates this tag line from the one that publishes the packages.</summary>
public const string TagPrefix = "desktop-v";

/// <summary>
/// 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:
///
/// <list type="bullet">
/// <item><b>Order by version, never by date.</b> 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.</item>
/// <item><b>Order numerically.</b> Sorted as text, <c>0.10.0</c> comes before <c>0.4.0</c>, and
/// the app would tell everyone they were up to date for the rest of the project's life.</item>
/// <item><b>Ignore what it cannot parse.</b> A prerelease tag is not something to advertise to a
/// teacher, and an unparseable one is not something to guess at.</item>
/// </list>
/// </summary>
public static string? Newest(IEnumerable<string> 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();
}

/// <summary>
/// Whether <paramref name="candidate"/> 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.
/// </summary>
public static bool IsNewerThan(string? candidate, string? running) =>
System.Version.TryParse(candidate, out var latest)
&& System.Version.TryParse(running, out var current)
&& latest > current;

/// <summary>The release page for a version, for a notice that links to the notes and nothing else.</summary>
public static string ReleasePageFor(string version) =>
$"https://github.com/peopleworks/SignsofAI/releases/tag/{TagPrefix}{version}";
}
Loading
Loading