downloads: five clients, and the page leads with the one that fits you
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s

The Account page offered the APK and nothing else, because the APK was all
the server held. Step 3 baked in four more, so the single card had to become
a section — and five artifacts is exactly where a downloads page turns into
a table of filenames and stops being a product.

So it LEADS with what fits the machine asking, from the user agent, and keeps
the rest quiet but visible. A wrong guess costs nothing: nothing is behind a
disclosure and every other client is one click away.

Linux gets all three at once, because the UA says "Linux" and nothing about
dpkg or pacman — there is no better answer available. They are named for the
distro rather than the package format, since a person knows which system they
run and not necessarily which packaging it uses. The AppImage carries one
clause of its own: it is 95 MB against 3, and it is also the only bundle that
updates itself in place. Both facts belong to the same decision.

macOS and iOS lead with nothing and say so. There is no build for either, and
"There's no macOS build yet" is the difference between deliberate and broken.

The version renders `unknown` rather than blank, and the download stays
offered — not knowing which build it is, is not a reason to withhold it.

Two things this did NOT do, both deliberate:

The task asked for a Tauri case — do not offer the desktop app to someone
already running it. That case cannot be reached: `/account` redirects to the
board in the desktop app (requiresServer, router/index.ts), because device
tokens are a server-side concept. A branch for it would be dead code.

`.btn-link` mirrors BaseButton's declarations rather than replacing them.
BaseButton is a <button> and cannot carry an href, and unifying the two would
have put every button in the app into an operator pass that CI cannot check —
for a cosmetic gain. The comment names the pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
This commit is contained in:
2026-08-31 07:58:17 -04:00
co-authored by Claude Opus 5
parent 8a75e5f340
commit fd1e4ae487
4 changed files with 231 additions and 45 deletions
+40 -10
View File
@@ -2,15 +2,38 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
// The Android build this server can hand out. Absent — not null — when it has
// none, so `v-if` on it is the whole test; see client_dist.py.
export interface AndroidClient {
// One client build this server can hand out. Platforms it holds nothing for are
// ABSENT from the map rather than present-and-null, so a key test is the whole
// question; see client_dist.py.
//
// Named for its twin in `core/src/sync/client.rs`, which deserializes the same
// payload. That one is deliberately NARROWER — it only ever reads
// `/api/client/android`, so its `version_code` is an `i64` and it declares none of
// the fields below that Android does not use. Widening it to match this is not a
// tidy-up: every phone in the field runs the current shape.
export interface ClientRelease {
// The table row's id — "android", "linux-deb", "linux-appimage", "windows".
platform: string;
// What a person calls it, named for the DISTRO rather than the package format
// ("Debian / Ubuntu", not ".deb"). The server owns this wording so the five
// labels cannot drift apart across the surfaces that show them.
label: string;
version: string;
// What decides "is this newer". The name is for people and sorts like a string.
version_code: number;
//
// Not one type across platforms, deliberately: Android's is an integer because
// Android's own install gate compares one, and the desktop's is Tauri's semver
// key `1.0.<minutes>`. Nothing in this app compares them — the union is here so
// the shape is honest rather than to be read.
version_code: number | string;
size: number;
sha256: string;
// A PATH, never an absolute URL — the client joins it to the server it is
// already talking to.
url: string;
// Present only for the AppImage: the minisign signature the desktop updater
// checks before replacing the running binary.
signature?: string;
}
export interface PublicConfig {
@@ -20,7 +43,14 @@ export interface PublicConfig {
enable_url_unfurl: boolean;
// How many days a note survives in Trash before the server purges it. 0 = forever.
trash_retention_days: number;
android_client?: AndroidClient;
// Every client this server holds, keyed by platform id. Absent on a server that
// holds none, and absent on the desktop's own offline config — the Tauri build
// answers `config_get` locally and has no clients to hand out.
//
// `/api/config` also carries `android_client`, which is NOT declared here: it
// exists for phones in the field polling for their own update, not for this app,
// and reading it here would be a second path to the same fact.
clients?: Record<string, ClientRelease>;
}
// Public, unauthenticated app config (site name, whether signups are open).
@@ -33,9 +63,9 @@ export const useConfigStore = defineStore("config", () => {
// unreachable — and 30 is a safer stand-in than 0, since claiming "kept forever"
// when the server is actually purging is the wrong way to be wrong.
const trashRetentionDays = ref(30);
// Null until proven otherwise: a server with no APK, and an older server that
// never had the field, both correctly show no download.
const androidClient = ref<AndroidClient | null>(null);
// Empty until proven otherwise: a server with no clients, and an older server
// that never had the field, both correctly offer no downloads.
const clients = ref<Record<string, ClientRelease>>({});
const loaded = ref(false);
async function load(): Promise<void> {
@@ -47,7 +77,7 @@ export const useConfigStore = defineStore("config", () => {
version.value = cfg.version;
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
trashRetentionDays.value = cfg.trash_retention_days ?? 30;
androidClient.value = cfg.android_client ?? null;
clients.value = cfg.clients ?? {};
} catch {
// Keep defaults if the config endpoint is unreachable.
} finally {
@@ -66,7 +96,7 @@ export const useConfigStore = defineStore("config", () => {
version,
enableUrlUnfurl,
trashRetentionDays,
androidClient,
clients,
loaded,
load,
reload,