AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user