desktop: in-app updates follow the server you installed from
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
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 11s
CI & Build / integration (push) Successful in 1m17s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 40s

Milestone 325 step 6 (Scribe #3254).

The server publishes its AppImage in the updater's own format at
/api/client/linux-appimage/update.json: the ordering key as `version`, the
signature, and an absolute download URL built on the host that was asked,
so the token the updater attaches goes nowhere else. Unsigned platforms and
a server with no AppImage 404.

The desktop's update source is now Fabled-Git (and its channel) or one
server:
- `read_source` is the one reader. The installer's `install-server` marker
  feeds the `update_server` pref once per new value, exactly as the channel
  marker feeds its pref; tauri.conf.json's endpoint is never consulted.
- From a server, the check and the download carry the sync link's token
  when the app is linked to that same server. Without one the update shows
  and says to link rather than offering a button that 401s.
- A server with no build says so. A 404 is "up to date" only on the forge,
  where it means an unpublished channel.
- Sync → App updates offers the source once there is a server to offer (the
  chosen one, or the linked one), and only shows the channel for the forge.

The trust anchor does not move: whatever the source, the updater verifies
the AppImage against the public key built into the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 06:56:36 -04:00
co-authored by Claude Opus 5.5
parent 871878de41
commit b03c9cf81a
6 changed files with 511 additions and 50 deletions
+70 -2
View File
@@ -96,6 +96,16 @@ const checkedOnce = ref(false);
const updateAvailable = computed(() => !!update.value?.available);
// Where updates come from: null is Fabled-Git. Learned from the check, because the
// check is what reads it (update.rs, `read_source`), so the screen shows the source
// that was actually asked.
const source = ref<string | null>(null);
// The server worth offering as a source: the one already chosen, else the one this
// app syncs with. With neither there is nothing to choose, and no choice is shown.
const offeredServer = computed(
() => source.value ?? status.value?.server_url?.replace(/\/+$/, "") ?? null,
);
// --- Quick capture -----------------------------------------------------------
// A desktop-local preference, so it lives here beside the update channel rather
// than in admin Settings: that screen is the SERVER's, and this is a property of
@@ -127,6 +137,7 @@ async function checkUpdates() {
try {
update.value = await updateBridge.check();
channel.value = update.value.channel;
source.value = update.value.source;
} catch (e) {
updateError.value = errorMessage(e, "The update check failed.");
} finally {
@@ -149,6 +160,20 @@ async function switchChannel(next: UpdateChannel) {
}
}
async function switchSource(next: string | null) {
if (next === source.value) return;
updateError.value = "";
try {
source.value = await updateBridge.setSource(next);
// Same reason as a channel switch: the last answer came from somewhere else.
update.value = null;
checkedOnce.value = false;
await checkUpdates();
} catch (e) {
updateError.value = errorMessage(e, "The update check failed.");
}
}
async function installUpdate() {
installing.value = true;
updateError.value = "";
@@ -584,7 +609,47 @@ onBeforeUnmount(() => stopSyncListener?.());
This is version {{ update?.current_version ?? "—" }}.
</p>
<fieldset class="mt-4">
<fieldset v-if="offeredServer" class="mt-4">
<legend class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Updates from
</legend>
<div class="mt-2 flex flex-col gap-2">
<label class="flex items-start gap-2 text-sm">
<input
type="radio"
class="mt-1"
name="update-source"
:checked="source === null"
@change="switchSource(null)"
/>
<span>
<span class="font-medium">Fabled-Git</span>
<span class="block text-neutral-500 dark:text-neutral-400">
Where Inkwell is published, on the channel below.
</span>
</span>
</label>
<label class="flex items-start gap-2 text-sm">
<input
type="radio"
class="mt-1"
name="update-source"
:checked="source !== null"
@change="switchSource(offeredServer)"
/>
<span>
<span class="font-medium">{{ offeredServer }}</span>
<span class="block text-neutral-500 dark:text-neutral-400">
The build that server holds. It passes on official builds only: every
update is checked against the signature built into this app first.
</span>
</span>
</label>
</div>
</fieldset>
<!-- A server holds one build, so a channel only means something on the forge. -->
<fieldset v-if="source === null" class="mt-4">
<legend class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Channel
</legend>
@@ -641,7 +706,10 @@ onBeforeUnmount(() => stopSyncListener?.());
v-else-if="checkedOnce && !updateError"
class="mt-4 text-sm text-neutral-500 dark:text-neutral-400"
>
You're on the newest {{ channel === "dev" ? "development" : "stable" }} build.
<template v-if="source">You're on the newest build {{ source }} has.</template>
<template v-else>
You're on the newest {{ channel === "dev" ? "development" : "stable" }} build.
</template>
</p>
<p v-if="updateError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ updateError }}</p>