CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m5s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m15s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m57s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Milestone 325 step 5 (Scribe #3253). curl -fsSL https://notes.example.com/install.sh | sh The server serves the installer at /install.sh with its own address written into it (installer.py). Settings → Public address when set, the request's own address otherwise. The substitution is one variable, given a value that has passed a strict shape check, and the script checks it again; an address that cannot pass makes the route refuse rather than serve a script pointed elsewhere. `public_url` joins the live settings cache so the route needs no database. From a server, the script: - resolves each Linux bundle from the public /api/client/<platform>, and builds the download URL from the platform id rather than reading it from the reply; - asks for a device token (from the terminal, since stdin is the script), or takes TS_TOKEN, and sends it from a file rather than the command line; - checks the sha256 the server published before anything installs; - revokes a prompted token once the download is done; - records `install-server` for the updater (step 6) instead of the channel. The forge path is unchanged, and stays the default for the copy the forge serves. The Account page's downloads card shows the one-line command whenever the server holds a Linux client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
7.9 KiB
Vue
188 lines
7.9 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] : "",
|
||
);
|
||
|
||
// The one-command Linux install (milestone 325 step 5), shown wherever the server
|
||
// holds a Linux client to install. Against the address this page was reached on,
|
||
// which is the one the person can evidently reach; the script the server sends
|
||
// back carries Settings → Public address when that is set.
|
||
const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-")));
|
||
const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`;
|
||
|
||
// 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>
|
||
|
||
<div v-if="hasLinux" class="mt-4">
|
||
<p class="text-xs text-neutral-400">
|
||
Or on Linux, from a terminal. It picks the right package for the system and asks
|
||
for a token from Linked devices to download it with.
|
||
</p>
|
||
<!-- select-all: one click takes the whole command, which is the only thing
|
||
anyone does with it. -->
|
||
<code
|
||
class="mt-1 block select-all break-all rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-700 dark:bg-neutral-900 dark:text-neutral-200"
|
||
>{{ installCommand }}</code
|
||
>
|
||
</div>
|
||
</section>
|
||
</template>
|