Files
inkwell/frontend/src/components/ClientDownloads.vue
T
bvandeusenandClaude Opus 5.5 871878de41
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
install.sh installs from your own server, not just from the forge
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>
2026-10-08 06:44:39 -04:00

188 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>