Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The download links added in fd1e4ae carried their own copy of BaseButton's
class list, because BaseButton is a <button> and cannot hold an href — and a
download must be an anchor, so the browser's own download manager gets the
3-95 MB transfer instead of a blob this app would have to hold in memory.
A copy is not a solution to that; it is two primary buttons that look alike
until someone changes one. So the shape moves to `.btn` + `.btn-primary` /
`.btn-ghost` in the components layer, where both elements can wear it, and
neither owns it.
The `disabled:` variants stay on BaseButton. An anchor has no :disabled, so
they were never shared and pretending otherwise would put a rule in the
shared definition that only one of its two users can ever match.
Verified there is exactly one shape to unify and no third copy: `px-4 py-2.5`
appears in three other files and all three are something else (a toast, a
dashed quick-add affordance, a retention notice). The smaller brand buttons in
AppShell and NoteEditor are a different size, which is a size-variant question
and not this one. And exactly one call site passes a class to BaseButton —
`shrink-0` — which cannot conflict with anything the shape declares.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
168 lines
6.8 KiB
Vue
168 lines
6.8 KiB
Vue
<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>
|
||
<!-- An anchor, never BaseButton and never a fetch: these are 3–95 MB and
|
||
the browser's own download manager handles the transfer better than
|
||
anything this app would do with a blob. It wears `.btn` — the same
|
||
definition BaseButton wears, so the two cannot drift.
|
||
|
||
`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 shrink-0"
|
||
:class="group.prominent ? 'btn-primary' : 'btn-ghost'"
|
||
>
|
||
Download
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
</template>
|