server: hand out the Android client this server syncs with (2726)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.
It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.
**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.
**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.
**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.
**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.
**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.
The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.
Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
This commit is contained in:
@@ -2,6 +2,17 @@ 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 {
|
||||
version: string;
|
||||
// What decides "is this newer". The name is for people and sorts like a string.
|
||||
version_code: number;
|
||||
size: number;
|
||||
sha256: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PublicConfig {
|
||||
site_name: string;
|
||||
allow_registration: boolean;
|
||||
@@ -9,6 +20,7 @@ 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;
|
||||
}
|
||||
|
||||
// Public, unauthenticated app config (site name, whether signups are open).
|
||||
@@ -21,6 +33,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);
|
||||
const loaded = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -32,6 +47,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;
|
||||
} catch {
|
||||
// Keep defaults if the config endpoint is unreachable.
|
||||
} finally {
|
||||
@@ -44,5 +60,15 @@ export const useConfigStore = defineStore("config", () => {
|
||||
await load();
|
||||
}
|
||||
|
||||
return { siteName, allowRegistration, version, enableUrlUnfurl, trashRetentionDays, loaded, load, reload };
|
||||
return {
|
||||
siteName,
|
||||
allowRegistration,
|
||||
version,
|
||||
enableUrlUnfurl,
|
||||
trashRetentionDays,
|
||||
androidClient,
|
||||
loaded,
|
||||
load,
|
||||
reload,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useDevicesStore } from "../stores/devices";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
@@ -11,6 +12,13 @@ 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.
|
||||
const config = useConfigStore();
|
||||
|
||||
function readableSize(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
const error = ref("");
|
||||
const newName = ref("");
|
||||
@@ -25,6 +33,7 @@ const desktopBusy = ref(false);
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
await config.load();
|
||||
await devices.load();
|
||||
} catch {
|
||||
error.value = "Couldn't load your linked devices.";
|
||||
@@ -151,6 +160,36 @@ 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>
|
||||
|
||||
<!-- One-time token reveal -->
|
||||
<div
|
||||
v-if="freshToken"
|
||||
|
||||
Reference in New Issue
Block a user