diff --git a/frontend/src/components/ClientDownloads.vue b/frontend/src/components/ClientDownloads.vue new file mode 100644 index 0000000..633cbd4 --- /dev/null +++ b/frontend/src/components/ClientDownloads.vue @@ -0,0 +1,164 @@ + + + diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index 3a442c2..d93e022 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -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.`. 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; } // 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(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>({}); const loaded = ref(false); async function load(): Promise { @@ -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, diff --git a/frontend/src/style.css b/frontend/src/style.css index 4a7f742..d45a4fd 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -253,6 +253,27 @@ body { @apply inline-flex min-h-[2.25rem] items-center justify-center px-3; } } + /* A DOWNLOAD is an anchor, never a button. These are 3-95 MB files, and the + * browser's own download manager handles the transfer better than anything this + * app would do with a blob - but only an can carry an href, and BaseButton + * is a