+ @* The tooltip used to say "Blazor WebAssembly" in both hosts, which is only true in one. *@
+
@L["nav.builtwith"]
@@ -20,6 +22,15 @@
@L["nav.baseline"]
@L["nav.originality"]
@L["nav.catalog"]
+ @* 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)
+ {
+
@L["nav.download"]
+ }
GitHub
@@ -34,7 +45,11 @@
claim about its own author. *@
PeopleWorks — Pedro Hernández · @L.M("footer.role")
- 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. *@
+
Signs of AI Writing@(Host.Version is { } v ? $" {v}" : "") · @L.M(Host.RuntimeKey) ·
@L["footer.opensource"]
diff --git a/src/SignsOfAI.UI/Pages/Download.razor b/src/SignsOfAI.UI/Pages/Download.razor
new file mode 100644
index 0000000..d949c37
--- /dev/null
+++ b/src/SignsOfAI.UI/Pages/Download.razor
@@ -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
+
+@L["dl.pagetitle"]
+
+
+ @L["dl.h1"]
+ @L["dl.tagline"]
+
+
+@if (Host.Version is { } running)
+{
+
+ @L.F("dl.running", running)
+ @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. *@
+ @L.F("dl.newer", DesktopRelease.Version)
+ }
+
+ @L["dl.all"]
+
+
+}
+else
+{
+
+}
+
+
+ @L["dl.adds.title"]
+ @L["dl.adds.lede"]
+
+
+
+
+
+ @L["dl.same.title"]
+ @L.M("dl.same.body")
+
+
+
+ @L["dl.warn.title"]
+ @* 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. *@
+ @L.M("dl.warn.body")
+
+ - @L["dl.warn.step1"]
+ - @L["dl.warn.step2"]
+ - @L.M("dl.warn.step3")
+
+ @L.M("dl.zip.body")
+
+
+@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"];
+}
diff --git a/src/SignsOfAI.UI/Services/DesktopRelease.cs b/src/SignsOfAI.UI/Services/DesktopRelease.cs
new file mode 100644
index 0000000..1ff9168
--- /dev/null
+++ b/src/SignsOfAI.UI/Services/DesktopRelease.cs
@@ -0,0 +1,39 @@
+namespace SignsOfAI.UI.Services;
+
+///
+/// Which Windows build the download page offers.
+///
+/// Written down rather than looked up, and that is the interesting part. GitHub's
+/// /releases/latest redirect resolves to whichever release is newest *overall*, and this
+/// repository publishes two independent tag lines on purpose — v* ships the NuGet packages,
+/// desktop-v* 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: .github/workflows/desktop-release.yml refuses to build a
+/// desktop-v* tag whose version does not match . 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.
+///
+public static class DesktopRelease
+{
+ /// The published version, without the desktop-v prefix its tag carries.
+ public const string Version = "0.4.0";
+
+ ///
+ /// The .zip itself, so the button downloads rather than starting a scavenger hunt through a
+ /// release page. Interpolated from so the two cannot disagree.
+ ///
+ public const string ZipUrl =
+ $"https://github.com/peopleworks/SignsofAI/releases/download/desktop-v{Version}/SignsOfAI-Desktop-{Version}-win-x64.zip";
+
+ /// The release page: the notes, the checksum, and what changed since the last one.
+ public const string ReleaseUrl =
+ $"https://github.com/peopleworks/SignsofAI/releases/tag/desktop-v{Version}";
+
+ /// 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";
+}
diff --git a/src/SignsOfAI.UI/Services/HostCapabilities.cs b/src/SignsOfAI.UI/Services/HostCapabilities.cs
index c4fce86..6d54202 100644
--- a/src/SignsOfAI.UI/Services/HostCapabilities.cs
+++ b/src/SignsOfAI.UI/Services/HostCapabilities.cs
@@ -21,9 +21,34 @@ public sealed class HostCapabilities
///
public bool ReachesLocalServices { get; init; }
+ ///
+ /// 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.
+ ///
+ public string? Version { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public string RuntimeKey { get; init; } = "footer.runtime.browser";
+
/// The browser: sandboxed, and the one that has to ask the user for CORS help.
public static HostCapabilities Browser { get; } = new() { ReachesLocalServices = false };
/// A desktop window: native HTTP, no preflight, localhost included.
- public static HostCapabilities Desktop { get; } = new() { ReachesLocalServices = true };
+ public static HostCapabilities Desktop(string? version) => new()
+ {
+ ReachesLocalServices = true,
+ RuntimeKey = "footer.runtime.desktop",
+ Version = version,
+ };
}
diff --git a/src/SignsOfAI.UI/wwwroot/css/app.css b/src/SignsOfAI.UI/wwwroot/css/app.css
index e663678..e43aa16 100644
--- a/src/SignsOfAI.UI/wwwroot/css/app.css
+++ b/src/SignsOfAI.UI/wwwroot/css/app.css
@@ -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;
@@ -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; }
diff --git a/src/SignsOfAI.UI/wwwroot/i18n/en.json b/src/SignsOfAI.UI/wwwroot/i18n/en.json
index f201fdc..2b3cb12 100644
--- a/src/SignsOfAI.UI/wwwroot/i18n/en.json
+++ b/src/SignsOfAI.UI/wwwroot/i18n/en.json
@@ -14,11 +14,12 @@
"nav.analyze": "One text",
"nav.originality": "Overlap",
"nav.batch": "A folder",
+ "nav.download": "Windows app",
"common.cancel": "Cancel",
"batch.pagetitle": "Scan a folder — Signs of AI Writing",
"batch.h1": "Scan a whole folder",
"batch.tagline": "Point it at a folder of documents and it reads every one it can — PDF, Word, ODT, EPUB, RTF and plain text — on this machine. Nothing is uploaded.",
- "batch.unavailable": "Folder scanning needs the desktop app: a browser tab is handed files, never a folder. Download it here, or use Upload document on the Analyze page for one file at a time.",
+ "batch.unavailable": "Folder scanning needs the desktop app: a browser tab is handed files, never a folder. See what the desktop app adds, and download it, or use Upload document on the Analyze page for one file at a time.",
"batch.choose": "Choose a folder",
"batch.recursive": "Include subfolders",
"batch.progress": "Reading {0} of {1}…",
@@ -30,11 +31,42 @@
"batch.col.score": "Score",
"batch.col.findings": "Signals",
"batch.col.note": "Note",
+ "dl.pagetitle": "Windows app — Signs of AI Writing",
+ "dl.h1": "Signs of AI Writing for Windows",
+ "dl.tagline": "The same tool as this page, in a window: it opens the file formats a browser tab cannot, reads a whole folder at once, and measures on your own machine.",
+ "dl.get": "Download version {0}",
+ "dl.for": "Windows 10 (1809) or newer · 64-bit · about 80 MB · no installer",
+ "dl.notes": "Release notes and checksum",
+ "dl.all": "All desktop releases",
+ "dl.running": "You are running the desktop app, version {0}.",
+ "dl.newer": "Version {0} is published. Nothing here updates itself — you download a new one when you want it.",
+ "dl.adds.title": "What the window can do that a tab cannot",
+ "dl.adds.lede": "Four things, and they are the only differences. Same engine, same rules, same numbers.",
+ "dl.add.documents.name": "Opens PDF, ODT, EPUB and RTF",
+ "dl.add.documents.what": "The extractors are already on disk here, so a PDF is a file you open rather than text you paste.",
+ "dl.add.documents.browser": "In a browser: .docx, .txt, .md, .csv and .log.",
+ "dl.add.folder.name": "Reads a whole folder",
+ "dl.add.folder.what": "Point it at a folder of submissions, subfolders included, and it says where to look first — then saves that as one document you can keep and hand to a colleague.",
+ "dl.add.folder.browser": "In a browser: files are handed to the page one at a time; a folder never is.",
+ "dl.add.perplexity.name": "Measures predictability in-process",
+ "dl.add.perplexity.what": "The language model runs inside the app. The weights download once, and after that it works with no connection and no server up.",
+ "dl.add.perplexity.browser": "In a browser: an optional server you point it at, or nothing.",
+ "dl.add.ollama.name": "Reaches Ollama on your own machine",
+ "dl.add.ollama.what": "A model you already run on localhost is simply reachable, with a button that finds which ones you have.",
+ "dl.add.ollama.browser": "In a browser: refused, unless you reconfigure Ollama’s allowed origins yourself.",
+ "dl.same.title": "What it does not change",
+ "dl.same.body": "The same text gets the same number here and in the browser — it is the same engine, compiled from the same source. Nothing is uploaded to run the analysis. Four optional features can send text off the device and not one of them runs unless you turn it on; in the desktop app the perplexity measurement is local too, so three remain.",
+ "dl.warn.title": "Windows will warn you, and it is right to",
+ "dl.warn.body": "The download is not code-signed — a certificate is the one thing this project has no funding for. SmartScreen therefore says Windows protected your PC the first time you run it, and names an unknown publisher. The warning is doing its job. Check the file instead of clicking through it:",
+ "dl.warn.step1": "Right-click the downloaded .zip → Properties, and tick Unblock if it is offered.",
+ "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 info → Run 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.",
+ "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",
"nav.builtwith": "Built with .NET 10 · Blazor",
- "nav.builtwith.title": "Built with .NET 10 and Blazor WebAssembly",
"footer.role": "Microsoft MVP for .NET",
- "footer.line": "built with .NET 10 & Blazor WebAssembly · runs 100% in your browser",
"footer.opensource": "open source",
"lang.switch.aria": "Interface language",
"lang.switch.to": "Switch the interface to {0}",
@@ -387,4 +419,4 @@
"home.report.title": "Writing analysis report",
"batch.report.hint": "Save the scan as a document you can keep or hand to a colleague. It names your students' files.",
"batch.report.title": "Folder scan — reading order"
-}
\ No newline at end of file
+}
diff --git a/src/SignsOfAI.UI/wwwroot/i18n/es.json b/src/SignsOfAI.UI/wwwroot/i18n/es.json
index add2f4a..bf2fc9d 100644
--- a/src/SignsOfAI.UI/wwwroot/i18n/es.json
+++ b/src/SignsOfAI.UI/wwwroot/i18n/es.json
@@ -14,11 +14,12 @@
"nav.analyze": "Un texto",
"nav.originality": "Coincidencias",
"nav.batch": "Una carpeta",
+ "nav.download": "App de Windows",
"common.cancel": "Cancelar",
"batch.pagetitle": "Analizar una carpeta — Señales de escritura IA",
"batch.h1": "Analiza una carpeta entera",
"batch.tagline": "Señálale una carpeta de documentos y lee todos los que puede —PDF, Word, ODT, EPUB, RTF y texto plano— en esta máquina. No se sube nada.",
- "batch.unavailable": "Analizar carpetas requiere la app de escritorio: a una pestaña del navegador se le entregan archivos, nunca una carpeta. Descárgala aquí, o usa Subir documento en la página de análisis para ir de uno en uno.",
+ "batch.unavailable": "Analizar carpetas requiere la app de escritorio: a una pestaña del navegador se le entregan archivos, nunca una carpeta. Mira qué añade la app de escritorio y descárgala, o usa Subir documento en la página de análisis para ir de uno en uno.",
"batch.choose": "Elegir carpeta",
"batch.recursive": "Incluir subcarpetas",
"batch.progress": "Leyendo {0} de {1}…",
@@ -30,11 +31,42 @@
"batch.col.score": "Puntuación",
"batch.col.findings": "Señales",
"batch.col.note": "Nota",
+ "dl.pagetitle": "App de Windows — Señales de escritura IA",
+ "dl.h1": "Señales de escritura IA para Windows",
+ "dl.tagline": "La misma herramienta de esta página, en una ventana: abre los formatos que una pestaña no puede, lee una carpeta entera de una vez y mide en tu propia máquina.",
+ "dl.get": "Descargar la versión {0}",
+ "dl.for": "Windows 10 (1809) o posterior · 64 bits · unos 80 MB · sin instalador",
+ "dl.notes": "Notas de la versión y checksum",
+ "dl.all": "Todas las versiones de escritorio",
+ "dl.running": "Estás usando la app de escritorio, versión {0}.",
+ "dl.newer": "Está publicada la versión {0}. Aquí nada se actualiza solo — la descargas cuando quieras.",
+ "dl.adds.title": "Lo que la ventana puede hacer y una pestaña no",
+ "dl.adds.lede": "Cuatro cosas, y son las únicas diferencias. El mismo motor, las mismas reglas, los mismos números.",
+ "dl.add.documents.name": "Abre PDF, ODT, EPUB y RTF",
+ "dl.add.documents.what": "Los extractores ya están en el disco, así que un PDF es un archivo que abres y no un texto que pegas.",
+ "dl.add.documents.browser": "En el navegador: .docx, .txt, .md, .csv y .log.",
+ "dl.add.folder.name": "Lee una carpeta entera",
+ "dl.add.folder.what": "Señálale una carpeta de entregas, subcarpetas incluidas, y te dice por dónde empezar — y luego lo guarda como un documento que puedes conservar y pasarle a un colega.",
+ "dl.add.folder.browser": "En el navegador: a la página se le entregan archivos de uno en uno; una carpeta nunca.",
+ "dl.add.perplexity.name": "Mide la previsibilidad dentro del proceso",
+ "dl.add.perplexity.what": "El modelo de lenguaje corre dentro de la app. Los pesos se descargan una vez y a partir de ahí funciona sin conexión y sin ningún servidor levantado.",
+ "dl.add.perplexity.browser": "En el navegador: un servidor opcional al que apuntar, o nada.",
+ "dl.add.ollama.name": "Alcanza Ollama en tu propia máquina",
+ "dl.add.ollama.what": "Un modelo que ya corres en localhost es sencillamente alcanzable, con un botón que descubre cuáles tienes.",
+ "dl.add.ollama.browser": "En el navegador: rechazado, salvo que reconfigures tú mismo los orígenes permitidos de Ollama.",
+ "dl.same.title": "Lo que no cambia",
+ "dl.same.body": "El mismo texto obtiene el mismo número aquí y en el navegador — es el mismo motor, compilado de la misma fuente. No se sube nada para hacer el análisis. Hay cuatro funciones opcionales que pueden enviar texto fuera del dispositivo y ninguna se activa si tú no la activas; en la app de escritorio la medición de perplejidad también es local, así que quedan tres.",
+ "dl.warn.title": "Windows te va a avisar, y hace bien",
+ "dl.warn.body": "La descarga no está firmada — un certificado es justo lo que este proyecto no puede costear. Por eso SmartScreen dice Windows protegió su PC la primera vez y habla de un editor desconocido. El aviso está haciendo su trabajo. Comprueba el archivo en vez de saltártelo:",
+ "dl.warn.step1": "Clic derecho en el .zip descargado → Propiedades, y marca Desbloquear si aparece.",
+ "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ón → Ejecutar 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.",
+ "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",
"nav.builtwith": "Hecho con .NET 10 · Blazor",
- "nav.builtwith.title": "Hecho con .NET 10 y Blazor WebAssembly",
"footer.role": "Microsoft MVP en .NET",
- "footer.line": "hecho con .NET 10 y Blazor WebAssembly · funciona 100% en tu navegador",
"footer.opensource": "código abierto",
"lang.switch.aria": "Idioma de la interfaz",
"lang.switch.to": "Cambiar la interfaz a {0}",
@@ -387,4 +419,4 @@
"home.report.title": "Reporte de análisis de escritura",
"batch.report.hint": "Guarda el análisis como un documento para conservar o pasar a un colega. Nombra los archivos de tus estudiantes.",
"batch.report.title": "Análisis de carpeta — orden de lectura"
-}
\ No newline at end of file
+}
diff --git a/tests/SignsOfAI.Core.Tests/LocaleFileTests.cs b/tests/SignsOfAI.Core.Tests/LocaleFileTests.cs
index d2b69e6..a83a3dd 100644
--- a/tests/SignsOfAI.Core.Tests/LocaleFileTests.cs
+++ b/tests/SignsOfAI.Core.Tests/LocaleFileTests.cs
@@ -35,6 +35,12 @@ .. Enum.GetValues().Select(s => "sev." + s.ToString().ToLowerInvariant
.. new[] { "text", "folder", "person", "overlap" }
.SelectMany(t => new[] { $"task.{t}.name", $"task.{t}.what" }),
"task.folder.desktop",
+ // Download.razor builds these from its own list, the same way.
+ .. new[] { "documents", "folder", "perplexity", "ollama" }
+ .SelectMany(a => new[] { $"dl.add.{a}.name", $"dl.add.{a}.what", $"dl.add.{a}.browser" }),
+ // Chosen by the host at startup — HostCapabilities.RuntimeKey — so the footer and the
+ // .NET badge's tooltip describe the runtime the reader is actually looking at.
+ "footer.runtime.browser", "footer.runtime.desktop",
];
[Fact]
diff --git a/tests/SignsOfAI.Desktop.Tests/DesktopVersionTests.cs b/tests/SignsOfAI.Desktop.Tests/DesktopVersionTests.cs
new file mode 100644
index 0000000..9ff11c1
--- /dev/null
+++ b/tests/SignsOfAI.Desktop.Tests/DesktopVersionTests.cs
@@ -0,0 +1,57 @@
+using SignsOfAI.Desktop;
+using SignsOfAI.UI.Services;
+
+namespace SignsOfAI.Desktop.Tests;
+
+///
+/// The desktop app has to be able to say which build it is.
+///
+/// This is guarding a support message rather than a crash: a user reported that "desktop 0.4.0 is
+/// not published" when what they meant was that they could not tell which build they were running.
+/// The plumbing that fixes it is three lines and would be easy to delete by accident, and its
+/// absence looks like nothing at all — the footer simply says one word less.
+///
+public class DesktopVersionTests
+{
+ [Fact]
+ public void The_running_build_reports_a_version()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(DesktopVersion.Running()),
+ "The desktop assembly carries no informational version, so the footer and the download " +
+ "page have nothing to show. A developer build reports the SDK's 1.0.0; getting null here " +
+ "means the attribute was suppressed in the .csproj.");
+ }
+
+ [Theory]
+ [InlineData("0.4.0", "0.4.0")]
+ [InlineData("0.4.0+9f2c1ab3", "0.4.0")] // the SDK appends the commit
+ [InlineData("0.5.0-rc.1+9f2c1ab3", "0.5.0-rc.1")] // a prerelease keeps its own suffix
+ public void Source_control_metadata_is_not_shown_to_the_reader(string informational, string shown) =>
+ Assert.Equal(shown, DesktopVersion.Trim(informational));
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Nothing_is_better_than_an_empty_version(string? informational) =>
+ Assert.Null(DesktopVersion.Trim(informational));
+
+ ///
+ /// The two hosts must not describe themselves the same way. The shared footer used to claim
+ /// "Blazor WebAssembly · runs 100% in your browser" inside a WPF window, where neither half is
+ /// true, and a project that asks people to show evidence cannot be loose about a sentence it
+ /// prints on every one of its own pages.
+ ///
+ [Fact]
+ public void The_desktop_host_describes_itself_as_a_desktop()
+ {
+ var desktop = HostCapabilities.Desktop("0.4.0");
+
+ Assert.Equal("0.4.0", desktop.Version);
+ Assert.True(desktop.ReachesLocalServices);
+ Assert.NotEqual(HostCapabilities.Browser.RuntimeKey, desktop.RuntimeKey);
+
+ // A browser tab is always the deployment that was last pushed, so it has no build to name.
+ Assert.Null(HostCapabilities.Browser.Version);
+ }
+}