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
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
// Every client this server holds, with the one that fits the visitor on top.
//
// Five artifacts is where a downloads page turns into a table of filenames and
// stops being a product. So this LEADS with the download that fits the machine
// asking and keeps the rest quiet but visible — nothing is behind a disclosure,
// because a wrong guess must cost a person nothing.
import { computed } from "vue";
import { useConfigStore, type ClientRelease } from "../stores/config";
const config = useConfigStore();
type Family = "android" | "windows" | "linux" | "mac" | "ios" | "other";
/**
* Which OS is asking, from the user agent.
*
* ORDER IS THE WHOLE ALGORITHM. Android's UA contains "Linux", an iPad's contains
* "Mac OS X", and a Chromebook's contains "X11" — so each narrow test has to run
* before the broad one that would otherwise swallow it.
*
* `navigator.userAgent` rather than `userAgentData`: the reduced UA Chrome now
* sends still carries the platform token, which is the only thing being asked
* for, and one code path beats two for a guess that is allowed to be wrong.
*/
function detectFamily(ua: string): Family {
if (/Android/i.test(ua)) return "android";
if (/Windows/i.test(ua)) return "windows";
if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
if (/Mac OS X|Macintosh/i.test(ua)) return "mac";
// CrOS lands here on purpose: a Chromebook's Linux container is a Debian one,
// which is the first thing the Linux group offers.
if (/Linux|X11|CrOS/i.test(ua)) return "linux";
return "other";
}
// What to lead with per family, in the order someone on it should see them.
//
// Linux gets all three because the UA says "Linux" and nothing about dpkg or
// pacman — there is no more specific answer to be had, so the three are named for
// the DISTRO a person knows rather than the package format they may not.
//
// macOS and iOS lead with nothing. There is no build for either, and an empty
// lead is the honest way to say so — see `missingPlatform` below.
const LEAD: Record<Family, string[]> = {
android: ["android"],
windows: ["windows"],
linux: ["linux-deb", "linux-pacman", "linux-appimage"],
mac: [],
ios: [],
other: [],
};
const FAMILY_TITLE: Record<Family, string> = {
android: "Android",
windows: "Windows",
linux: "Linux",
mac: "macOS",
ios: "iOS",
other: "This machine",
};
// Read once. The UA does not change while the page is open, and making it
// reactive would only invite someone to think it could.
const family = detectFamily(navigator.userAgent);
// The lead offers this server actually holds. A platform in LEAD that the server
// has no build for simply is not here — the guess never conjures a download.
const lead = computed(() =>
LEAD[family]
.map((id) => config.clients[id])
.filter((c): c is ClientRelease => Boolean(c)),
);
const others = computed(() => {
const leading = new Set(lead.value.map((c) => c.platform));
// Object.values keeps the server's own PLATFORMS order, which is a deliberate
// one (phone first, then the desktop bundles) and not worth re-deciding here.
return Object.values(config.clients).filter((c) => !leading.has(c.platform));
});
const groups = computed(() => {
const out: { title: string; releases: ClientRelease[]; prominent: boolean }[] = [];
if (lead.value.length) {
out.push({ title: FAMILY_TITLE[family], releases: lead.value, prominent: true });
}
if (others.value.length) {
out.push({
// Without a lead there is no "other" — the whole list is the choice.
title: lead.value.length ? "Other platforms" : "Choose a platform",
releases: others.value,
prominent: false,
});
}
return out;
});
// Said plainly, so a Mac reads as "not yet" rather than as a page that failed to
// find its own downloads.
const missingPlatform = computed(() =>
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
);
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
// only distinction that matters at the small end.
function readableSize(bytes: number): string {
const mb = bytes / 1024 / 1024;
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
}
</script>
<template>
<!-- Nothing at all on a server with no clients a brand-new instance before its
first image carrying them. An empty section would be a promise it can't keep. -->
<section v-if="groups.length" class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800">
<h2 class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Get the apps</h2>
<p class="mt-0.5 text-xs text-neutral-400">
Served by this server, so they always speak the same sync protocol.
</p>
<p v-if="missingPlatform" class="mt-1 text-xs text-neutral-400">
There's no {{ missingPlatform }} build yet.
</p>
<div v-for="group in groups" :key="group.title" class="mt-4">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-400">{{ group.title }}</p>
<ul class="mt-2 flex flex-col gap-2">
<li
v-for="client in group.releases"
:key="client.platform"
class="flex items-center justify-between gap-4"
>
<div class="min-w-0">
<p class="text-sm text-neutral-800 dark:text-neutral-100">{{ client.label }}</p>
<p class="mt-0.5 text-xs text-neutral-400">
<!-- `unknown` rather than a blank or a plausible default: with no
second source to contradict it, a wrong version here is a wrong
answer nothing can catch. Not knowing which build it is, is also
not a reason to withhold the download. -->
Version {{ client.version || "unknown" }} · {{ readableSize(client.size) }}<span
v-if="client.platform === 'linux-appimage'"
>, and the only one that updates itself in place</span
>
</p>
</div>
<!-- A plain anchor, never BaseButton and never a fetch: these are 395 MB
and the browser's own download manager handles the transfer better
than anything this app would do with a blob. `download` carries no
filename because the server already names the file in its
Content-Disposition, which browsers prefer over this attribute
anyway — a value here would be inert and read as if it weren't. -->
<a
:href="client.url"
download
class="btn-link shrink-0"
:class="group.prominent ? 'btn-link-primary' : 'btn-link-ghost'"
>
Download
</a>
</li>
</ul>
</div>
</section>
</template>
+40 -10
View File
@@ -2,15 +2,38 @@ import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { repo } from "../adapters"; import { repo } from "../adapters";
// The Android build this server can hand out. Absent — not null — when it has // One client build this server can hand out. Platforms it holds nothing for are
// none, so `v-if` on it is the whole test; see client_dist.py. // ABSENT from the map rather than present-and-null, so a key test is the whole
export interface AndroidClient { // 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; version: string;
// What decides "is this newer". The name is for people and sorts like a 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; size: number;
sha256: string; sha256: string;
// A PATH, never an absolute URL — the client joins it to the server it is
// already talking to.
url: string; url: string;
// Present only for the AppImage: the minisign signature the desktop updater
// checks before replacing the running binary.
signature?: string;
} }
export interface PublicConfig { export interface PublicConfig {
@@ -20,7 +43,14 @@ export interface PublicConfig {
enable_url_unfurl: boolean; enable_url_unfurl: boolean;
// How many days a note survives in Trash before the server purges it. 0 = forever. // How many days a note survives in Trash before the server purges it. 0 = forever.
trash_retention_days: number; 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). // 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" // 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. // when the server is actually purging is the wrong way to be wrong.
const trashRetentionDays = ref(30); const trashRetentionDays = ref(30);
// Null until proven otherwise: a server with no APK, and an older server that // Empty until proven otherwise: a server with no clients, and an older server
// never had the field, both correctly show no download. // that never had the field, both correctly offer no downloads.
const androidClient = ref<AndroidClient | null>(null); const clients = ref<Record<string, ClientRelease>>({});
const loaded = ref(false); const loaded = ref(false);
async function load(): Promise<void> { async function load(): Promise<void> {
@@ -47,7 +77,7 @@ export const useConfigStore = defineStore("config", () => {
version.value = cfg.version; version.value = cfg.version;
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true; enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
trashRetentionDays.value = cfg.trash_retention_days ?? 30; trashRetentionDays.value = cfg.trash_retention_days ?? 30;
androidClient.value = cfg.android_client ?? null; clients.value = cfg.clients ?? {};
} catch { } catch {
// Keep defaults if the config endpoint is unreachable. // Keep defaults if the config endpoint is unreachable.
} finally { } finally {
@@ -66,7 +96,7 @@ export const useConfigStore = defineStore("config", () => {
version, version,
enableUrlUnfurl, enableUrlUnfurl,
trashRetentionDays, trashRetentionDays,
androidClient, clients,
loaded, loaded,
load, load,
reload, reload,
+21
View File
@@ -253,6 +253,27 @@ body {
@apply inline-flex min-h-[2.25rem] items-center justify-center px-3; @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 <a> can carry an href, and BaseButton
* is a <button>. So the button SHAPE lives here where an anchor can wear it.
*
* Declarations mirror BaseButton.vue exactly, minus its disabled: variants (an
* anchor has no :disabled). The two are a pair: changing the look of one without
* the other is how a page ends up with two kinds of primary button.
*/
.btn-link {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm
font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
dark:focus-visible:ring-offset-neutral-950;
}
.btn-link-primary {
@apply bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700;
}
.btn-link-ghost {
@apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800;
}
.nav-link { .nav-link {
@apply flex items-center gap-2 rounded-lg px-3 py-2 font-medium text-neutral-600 transition @apply flex items-center gap-2 rounded-lg px-3 py-2 font-medium text-neutral-600 transition
hover:bg-neutral-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand hover:bg-neutral-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
+6 -35
View File
@@ -5,6 +5,7 @@ import { useDevicesStore } from "../stores/devices";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue"; import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue"; import BaseInput from "../components/BaseInput.vue";
import ClientDownloads from "../components/ClientDownloads.vue";
import Icon from "../components/Icon.vue"; import Icon from "../components/Icon.vue";
import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge"; import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge";
@@ -12,14 +13,10 @@ import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../
// Android apps authenticate sync with a device bearer token issued here. // Android apps authenticate sync with a device bearer token issued here.
const devices = useDevicesStore(); const devices = useDevicesStore();
const ui = useUiStore(); const ui = useUiStore();
// The Android build this server holds, if it holds one. Null on a server with no // Loaded here rather than in ClientDownloads: this view already awaits it, and a
// APK — the card below is hidden rather than offering a download that 404s. // component that fetches its own config would race the one that does.
const config = useConfigStore(); const config = useConfigStore();
function readableSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
}
const error = ref(""); const error = ref("");
const newName = ref(""); const newName = ref("");
const creating = ref(false); const creating = ref(false);
@@ -160,35 +157,9 @@ onMounted(() => {
</BaseButton> </BaseButton>
</section> </section>
<!-- The Android client this server hands out (hidden when it has none) --> <!-- Every client this server holds, the one that fits this machine on top.
<section Hides itself when the server holds none. -->
v-if="config.androidClient" <ClientDownloads />
class="mb-6 flex items-center justify-between gap-4 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
>
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Android app</p>
<p class="mt-0.5 text-xs text-neutral-400">
Version {{ config.androidClient.version }} ·
{{ readableSize(config.androidClient.size) }} · served by this server, so it always
speaks the same sync protocol.
</p>
</div>
<!-- A plain anchor, not BaseButton and not a fetch: this is 55 MB, and the
browser's own download manager handles it better than anything this app
would do with a blob. Styled to match BaseButton's primary variant,
which is a <button> and cannot carry an href. -->
<a
:href="config.androidClient.url"
:download="`thoughtsync-${config.androidClient.version}.apk`"
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-brand px-4 py-2.5
text-sm font-semibold text-neutral-900 shadow-sm transition hover:bg-brand-600
active:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
dark:focus-visible:ring-offset-neutral-950"
>
Download
</a>
</section>
<!-- One-time token reveal --> <!-- One-time token reveal -->
<div <div