AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
4 changed files with 231 additions and 45 deletions
Showing only changes of commit fd1e4ae487 - Show all commits
+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 3–95 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 { 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.<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;
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<string, ClientRelease>;
}
// 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<AndroidClient | null>(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<Record<string, ClientRelease>>({});
const loaded = ref(false);
async function load(): Promise<void> {
@@ -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,
+21
View File
@@ -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 <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 {
@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
+6 -35
View File
@@ -5,6 +5,7 @@ import { useDevicesStore } from "../stores/devices";
import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import ClientDownloads from "../components/ClientDownloads.vue";
import Icon from "../components/Icon.vue";
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.
const devices = useDevicesStore();
const ui = useUiStore();
// The Android build this server holds, if it holds one. Null on a server with no
// APK — the card below is hidden rather than offering a download that 404s.
// Loaded here rather than in ClientDownloads: this view already awaits it, and a
// component that fetches its own config would race the one that does.
const config = useConfigStore();
function readableSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
}
const error = ref("");
const newName = ref("");
const creating = ref(false);
@@ -160,35 +157,9 @@ onMounted(() => {
</BaseButton>
</section>
<!-- The Android client this server hands out (hidden when it has none) -->
<section
v-if="config.androidClient"
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>
<!-- Every client this server holds, the one that fits this machine on top.
Hides itself when the server holds none. -->
<ClientDownloads />
<!-- One-time token reveal -->
<div