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
24 changes: 24 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ jobs:
"name=$name" >> $env:GITHUB_OUTPUT
Write-Host "Building $name"

# The web app's download page has the version written into a constant, because GitHub's
# /releases/latest points at whichever release is newest overall and this repository publishes
# two independent tag lines — half the time "latest" is a NuGet release with no zip on it.
#
# Writing it down means it can go stale, so it is checked here rather than left to memory: a
# release that forgets to update the page does not ship, and the page can never offer a
# version that was never published.
- name: The download page must offer this version
if: github.event_name == 'release'
shell: pwsh
run: |
$file = 'src/SignsOfAI.UI/Services/DesktopRelease.cs'
$declared = (Select-String -Path $file -Pattern 'Version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
$tagged = "${{ steps.version.outputs.version }}"
if ($declared -ne $tagged) {
Write-Error @"
The tag says $tagged but $file says $declared.
Update DesktopRelease.Version, merge it, then re-run this release — otherwise the download
button on the site points at a release that does not exist.
"@
exit 1
}
Write-Host "Download page offers $declared, which is what this tag builds."

# A release that crashes is worse than a release that never shipped, and a GitHub Release
# cannot be un-downloaded once people have it.
- name: Test
Expand Down
42 changes: 42 additions & 0 deletions src/SignsOfAI.Desktop/DesktopVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Reflection;

namespace SignsOfAI.Desktop;

/// <summary>
/// Which build this is.
///
/// Small enough to look unnecessary, and it exists because of a support message: somebody reported
/// that "desktop 0.4.0 is not published" when what they actually meant was that they could not tell
/// which build they had. The app said its name in the title bar and nothing else, so neither could
/// anyone trying to help them.
///
/// Kept out of <see cref="MainWindow"/> so it can be tested without standing up a window — same
/// reason DesktopFolderBatch lives on its own.
/// </summary>
public static class DesktopVersion
{
/// <summary>
/// The version this executable was built with, or null if it somehow carries none.
///
/// The release workflow passes <c>-p:Version=</c> taken from the tag and the SDK turns that into
/// <see cref="AssemblyInformationalVersionAttribute"/>. A developer build with no version set
/// reports the SDK's own 1.0.0, which is the honest answer for something never released.
/// </summary>
public static string? Running() => Of(typeof(DesktopVersion).Assembly);

/// <summary>Same, for a given assembly, so a test can hand it one.</summary>
public static string? Of(Assembly assembly)
{
var informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;

// The SDK appends "+<commit sha>" when the repository is available at build time. Accurate,
// and not what somebody reading a footer is trying to find out.
return Trim(informational);
}

/// <summary>Drops the source-control metadata the SDK appends after a <c>+</c>.</summary>
public static string? Trim(string? informational) =>
string.IsNullOrWhiteSpace(informational) ? null : informational.Split('+')[0];
}
4 changes: 3 additions & 1 deletion src/SignsOfAI.Desktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ public MainWindow()
services.AddSingleton<ILocalPerplexity, DesktopPerplexity>();

// Native HTTP: Ollama on localhost is simply reachable, with no CORS workaround to explain.
services.AddSingleton(HostCapabilities.Desktop);
// 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.
services.AddSingleton(HostCapabilities.Desktop(DesktopVersion.Running()));

// The XAML binds Services="{DynamicResource services}", so the provider has to be in
// Resources before InitializeComponent builds the visual tree.
Expand Down
19 changes: 17 additions & 2 deletions src/SignsOfAI.UI/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
@implements IDisposable
@inject Loc L
@inject IFolderBatch Folders
@inject HostCapabilities Host

<div class="app-shell">
<nav class="app-nav">
<a class="brand" href=""><Icon Name="pen" /> Signs of AI Writing</a>
<span class="dotnet-badge" title="@L["nav.builtwith.title"]">
@* The tooltip used to say "Blazor WebAssembly" in both hosts, which is only true in one. *@
<span class="dotnet-badge" title="@L[Host.RuntimeKey]">
<span class="dot"></span> @L["nav.builtwith"]
</span>
<div class="nav-links">
Expand All @@ -20,6 +22,15 @@
<NavLink class="nav-link" href="baseline">@L["nav.baseline"]</NavLink>
<NavLink class="nav-link" href="originality">@L["nav.originality"]</NavLink>
<NavLink class="nav-link" href="catalog">@L["nav.catalog"]</NavLink>
@* Only where there is something to gain by downloading it. The same signal as the
folder link above, and for the same reason: this host cannot open a folder, so a
better host exists for this machine and hiding that would be the lowest-common-
denominator answer. The desktop still has the page — it answers a different
question there — but nothing points at it from its own chrome. *@
@if (!Folders.IsAvailable)
{
<NavLink class="nav-link" href="download">@L["nav.download"]</NavLink>
}
<a class="nav-link" href="https://github.com/peopleworks/SignsofAI" target="_blank" rel="noopener">GitHub</a>
<LanguageSwitch />
</div>
Expand All @@ -34,7 +45,11 @@
claim about its own author. *@
<strong>PeopleWorks — Pedro Hernández</strong> · @L.M("footer.role")
</p>
<p>Signs of AI Writing · @L.M("footer.line") ·
@* The version is here because a support message arrived reading "desktop 0.4.0 is not
published" from somebody who could not tell which build they had — the app said its name
and nothing else. A browser tab has no version to report: it is always what was last
deployed. *@
<p>Signs of AI Writing@(Host.Version is { } v ? $" {v}" : "") · @L.M(Host.RuntimeKey) ·
<a href="https://github.com/peopleworks/SignsofAI" target="_blank" rel="noopener">@L["footer.opensource"]</a>
</p>
</footer>
Expand Down
97 changes: 97 additions & 0 deletions src/SignsOfAI.UI/Pages/Download.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
@* The page a teacher can be sent to.

Until this existed the only route to the Windows app was a GitHub releases *search* URL, buried
in the notice on /batch: a wall of tags, assets and checksums, in English, for somebody whose
question was "how do I open my PDFs". Half of the desktop app's reason to exist was therefore
invisible to every web visitor.

It also renders for the desktop host itself, where the honest content is the opposite: not an
offer to download what you already have, but the one fact the app never told anybody — which
build you are running. That is the same page answering the question each host actually raises,
which is what HostCapabilities is for. *@
@page "/download"
@inherits LocalizedComponent
@inject HostCapabilities Host

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

<header class="hero">
<h1>@L["dl.h1"]</h1>
<p class="tagline">@L["dl.tagline"]</p>
</header>

@if (Host.Version is { } running)
{
<section class="card dl-running">
<p class="dl-running-line"><Icon Name="check-circle" /> @L.F("dl.running", running)</p>
@if (running != DesktopRelease.Version)
{
@* Deliberately not a claim that the newer one is better, and deliberately not an
auto-update: this app is unsigned, and a program that downloads and runs code on its
own is exactly the behaviour we would tell a teacher to be suspicious of. *@
<p class="hint">@L.F("dl.newer", DesktopRelease.Version)</p>
}
<p class="hint sub">
<a href="@DesktopRelease.AllReleasesUrl" target="_blank" rel="noopener">@L["dl.all"]</a>
</p>
</section>
}
else
{
<section class="card dl-get">
<a class="dl-cta" href="@DesktopRelease.ZipUrl">
<Icon Name="download" />
<span class="dl-cta-main">@L.F("dl.get", DesktopRelease.Version)</span>
<span class="dl-cta-sub">@L["dl.for"]</span>
</a>
<p class="hint sub">
<a href="@DesktopRelease.ReleaseUrl" target="_blank" rel="noopener">@L["dl.notes"]</a>
·
<a href="@DesktopRelease.AllReleasesUrl" target="_blank" rel="noopener">@L["dl.all"]</a>
</p>
</section>
}

<section class="card">
<h2 class="dl-h2">@L["dl.adds.title"]</h2>
<p class="hint">@L["dl.adds.lede"]</p>

<ul class="dl-grid">
@foreach (var add in Adds)
{
<li class="dl-item">
<h3>@L[$"dl.add.{add}.name"]</h3>
<p>@L[$"dl.add.{add}.what"]</p>
@* The contrast is the argument. Saying what the browser does instead keeps this from
reading as a list of features the web version is quietly missing. *@
<p class="dl-vs">@L[$"dl.add.{add}.browser"]</p>
</li>
}
</ul>
</section>

<section class="card">
<h2 class="dl-h2">@L["dl.same.title"]</h2>
<p class="hint">@L.M("dl.same.body")</p>
</section>

<section class="card dl-warn">
<h2 class="dl-h2"><Icon Name="alert" /> @L["dl.warn.title"]</h2>
@* Said here, before the download, rather than left for the user to meet on their own. The app is
unsigned because a code-signing certificate is the one thing this project cannot fund — that
is also the open ask in the .NET Foundation application — and a warning nobody warned you
about is how people learn to click through warnings. *@
<p>@L.M("dl.warn.body")</p>
<ol class="dl-steps">
<li>@L["dl.warn.step1"]</li>
<li>@L["dl.warn.step2"]</li>
<li>@L.M("dl.warn.step3")</li>
</ol>
<p class="hint">@L.M("dl.zip.body")</p>
</section>

@code {
// 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"];
}
39 changes: 39 additions & 0 deletions src/SignsOfAI.UI/Services/DesktopRelease.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace SignsOfAI.UI.Services;

/// <summary>
/// Which Windows build the download page offers.
///
/// Written down rather than looked up, and that is the interesting part. GitHub's
/// <c>/releases/latest</c> redirect resolves to whichever release is newest *overall*, and this
/// repository publishes two independent tag lines on purpose — <c>v*</c> ships the NuGet packages,
/// <c>desktop-v*</c> ships this app. About half the time "latest" is therefore a page with no .zip
/// on it, which is a worse answer than a stale one.
///
/// The cost of writing it down is that it can drift from what is actually published, so it is not
/// left to anybody's discipline: <c>.github/workflows/desktop-release.yml</c> refuses to build a
/// <c>desktop-v*</c> tag whose version does not match <see cref="Version"/>. One edit per release,
/// and a release that forgets the edit does not ship.
///
/// Only the version is a constant. The size and the checksum are not, because they are only known
/// after the runner has built the zip — they live in the release notes, which the page links to.
/// </summary>
public static class DesktopRelease
{
/// <summary>The published version, without the <c>desktop-v</c> prefix its tag carries.</summary>
public const string Version = "0.4.0";

/// <summary>
/// The .zip itself, so the button downloads rather than starting a scavenger hunt through a
/// release page. Interpolated from <see cref="Version"/> so the two cannot disagree.
/// </summary>
public const string ZipUrl =
$"https://github.com/peopleworks/SignsofAI/releases/download/desktop-v{Version}/SignsOfAI-Desktop-{Version}-win-x64.zip";

/// <summary>The release page: the notes, the checksum, and what changed since the last one.</summary>
public const string ReleaseUrl =
$"https://github.com/peopleworks/SignsofAI/releases/tag/desktop-v{Version}";

/// <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";
}
27 changes: 26 additions & 1 deletion src/SignsOfAI.UI/Services/HostCapabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,34 @@ public sealed class HostCapabilities
/// </summary>
public bool ReachesLocalServices { get; init; }

/// <summary>
/// The build the user is looking at, when the host is a thing that gets downloaded and can
/// therefore be out of date. Null in a browser tab, which always serves what was last deployed
/// and has no version to report.
///
/// It exists because of a support message: somebody reported "desktop 0.4.0 is not published"
/// when what they meant was "I cannot tell which build I have". The app said its name in the
/// title bar and nothing else, so neither could anyone helping them.
/// </summary>
public string? Version { get; init; }

/// <summary>
/// The locale key describing how this host runs, for the footer.
///
/// Not decoration: the shared footer claimed "Blazor WebAssembly · runs 100% in your browser"
/// inside a WPF window, where both halves are false. A tool that asks people to show evidence
/// cannot be careless about a claim on every one of its own pages.
/// </summary>
public string RuntimeKey { get; init; } = "footer.runtime.browser";

/// <summary>The browser: sandboxed, and the one that has to ask the user for CORS help.</summary>
public static HostCapabilities Browser { get; } = new() { ReachesLocalServices = false };

/// <summary>A desktop window: native HTTP, no preflight, localhost included.</summary>
public static HostCapabilities Desktop { get; } = new() { ReachesLocalServices = true };
public static HostCapabilities Desktop(string? version) => new()
{
ReachesLocalServices = true,
RuntimeKey = "footer.runtime.desktop",
Version = version,
};
}
69 changes: 68 additions & 1 deletion src/SignsOfAI.UI/wwwroot/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,8 @@ button:disabled { opacity: .45; cursor: not-allowed; }
padding-bottom: .9rem; border-bottom: 1px solid var(--border);
}
.brand { font-weight: 700; font-size: 1.1rem; text-decoration: none; color: var(--text); letter-spacing: -.01em; }
.nav-links { display: flex; gap: .35rem; }
/* Wraps: the nav gained a sixth link and a narrow window must not push the language switch off. */
.nav-links { display: flex; flex-wrap: wrap; gap: .35rem; }
.nav-link {
text-decoration: none; color: var(--text-muted); font-size: .92rem; font-weight: 600;
padding: .35rem .75rem; border-radius: 8px;
Expand Down Expand Up @@ -1089,3 +1090,69 @@ button.ghost.sm { padding: .35rem .7rem; font-size: .82rem; }
.ppl-progress { margin-top: .6rem; }
.ppl-bar { height: 6px; border-radius: 999px; background: var(--border); overflow: hidden; }
.ppl-bar > span { display: block; height: 100%; background: var(--accent, #2563eb); transition: width .25s ease; }

/* ---- Windows app download (/download) ----
Deliberately shares the visual language of the front-door task cards: somebody who learned to
read those four cards reads these four the same way. */
.dl-h2 { font-size: 1.02rem; margin: 0 0 .35rem; }
.dl-h2 svg { vertical-align: -2px; }

/* The call to action. A link, not a button, because it is a download and the browser should be
allowed to say so — right-click, copy address, resume, all of it. */
.dl-cta {
display: grid;
grid-template-columns: auto 1fr;
grid-template-areas: "icon main" "icon sub";
column-gap: .8rem;
align-items: center;
padding: .9rem 1.2rem;
background: var(--brand);
color: var(--brand-ink);
border-radius: var(--radius);
text-decoration: none;
transition: filter .15s, transform .15s;
}
.dl-cta:hover, .dl-cta:focus-visible { filter: brightness(1.08); transform: translateY(-1px); }
.dl-cta svg { grid-area: icon; width: 26px; height: 26px; }
.dl-cta-main { grid-area: main; font-weight: 700; font-size: 1.02rem; }
.dl-cta-sub { grid-area: sub; font-size: .78rem; opacity: .85; }
.dl-get .hint.sub { margin: .6rem 0 0; }

.dl-running-line { margin: 0 0 .3rem; font-size: 1rem; }
.dl-running-line svg { vertical-align: -3px; color: var(--good); margin-right: .3rem; }

.dl-grid {
list-style: none;
margin: .9rem 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: .7rem;
}
.dl-item {
display: flex;
flex-direction: column;
gap: .3rem;
padding: .85rem 1rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.dl-item h3 { margin: 0; font-size: .96rem; font-weight: 650; line-height: 1.3; }
.dl-item p { margin: 0; font-size: .84rem; color: var(--text-muted); line-height: 1.45; }
/* What the browser does instead. Pushed to the bottom so the four line up however long the
description above happens to be. */
.dl-item .dl-vs {
margin-top: auto;
padding-top: .45rem;
border-top: 1px dashed var(--border);
font-size: .78rem;
}

/* Said before the download, not after it. Bordered like a notice rather than an error: the
warning is correct, and the page is explaining it rather than apologising for it. */
.dl-warn { border-color: color-mix(in srgb, var(--notice) 45%, var(--border)); }
.dl-warn .dl-h2 svg { color: var(--notice); }
.dl-steps { margin: .6rem 0 .8rem; padding-left: 1.2rem; font-size: .88rem; line-height: 1.6; }
.dl-steps li { margin-bottom: .2rem; }
.dl-steps code { font-size: .84em; }
Loading
Loading