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
+3
View File
@@ -133,6 +133,8 @@ pub fn run() {
// Before anything can ask what channel we're on: the installer left a note
// in this directory saying which one the user picked (issue 2183).
update::adopt_installer_channel(&db, &dir);
// And which server, when it installed from one (milestone 325 step 6).
update::adopt_installer_server(&db, &dir);
sweep_local_trash(&db);
// Before the store is handed to the app: `restore` needs to read the
// stored shortcut out of it, and after `manage` the Db has moved.
@@ -213,6 +215,7 @@ pub fn run() {
commands::sync::shares_unshare,
update::update_channel_get,
update::update_channel_set,
update::update_source_set,
update::update_check,
update::update_install,
capture::capture_shortcut_get,
+362 -47
View File
@@ -12,10 +12,16 @@
//! is the whole point of the change. NOTHING HERE MOVED: this code only ever read
//! `<channel>/latest.json`, and that is still where the manifest lands.
//!
//! The feed lives on Fabled-Git rather than on an Inkwell server, deliberately:
//! this app is usable having never linked a server, and an install that can't reach
//! its own updates because it isn't paired with anything would contradict the whole
//! local-first premise.
//! The feed is Fabled-Git by default, deliberately: this app is usable having never
//! linked a server, and an install that can't reach its own updates because it isn't
//! paired with anything would contradict the whole local-first premise.
//!
//! An Inkwell server can be the feed instead (milestone 325 step 6) — the one a
//! self-hoster installed from, whose forge they may have no access to. That reads
//! alarming until you notice what does NOT move: the server hands out the same
//! signed AppImage, and the updater checks it against the public key baked into
//! this build before it replaces anything. A server is a mirror, not a signer. It
//! can pass on official builds; it cannot substitute its own.
//!
//! Updates are signed. The public half is baked into `tauri.conf.json`; the private
//! half exists only as a CI secret, and is generated by the operator — a release
@@ -28,6 +34,7 @@ use tauri::State;
use tauri_plugin_updater::UpdaterExt;
use inkwell_core::local::{store, Db};
use inkwell_core::sync::state;
/// Where the manifests live. Fixed tags, so these URLs are permanent.
const FEED_BASE: &str = "https://git.fabledsword.com/bvandeusen/inkwell/releases/download";
@@ -44,6 +51,20 @@ const INSTALL_MARKER: &str = "install-channel";
/// from a repeat. See `adopt_installer_channel`.
const CHANNEL_SEED_PREF: &str = "update_channel_seed";
/// The server updates come from. Absent or empty means Fabled-Git.
const SERVER_PREF: &str = "update_server";
/// The server the app was installed from, written by `install.sh` when it installed
/// from one. Same contract as `INSTALL_MARKER`, and the same reason for being a file.
const SERVER_MARKER: &str = "install-server";
/// The server marker value we last acted on — `CHANNEL_SEED_PREF`'s twin.
const SERVER_SEED_PREF: &str = "update_server_seed";
/// The one bundle a server publishes in-app updates for: the AppImage is the only one
/// it holds a signature for, and the only Linux bundle that can replace itself.
const SERVER_FEED_PLATFORM: &str = "linux-appimage";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Channel {
@@ -102,10 +123,100 @@ impl Channel {
}
}
/// Where updates come from.
///
/// `read_source` is THE ONE READER. Three things hold an opinion about this — the
/// installer's marker files, this app's prefs, and `plugins.updater.endpoints` in
/// `tauri.conf.json` — and issues 2181–2183 were each one decision with several
/// holders and only one of them set. So: the markers only ever feed the prefs
/// (`adopt_installer_*`), the prefs are what is read, and the config's endpoint is
/// never consulted, because every check below names its own.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Source {
/// Fabled-Git's fixed-tag release for a channel. The default.
Forge(Channel),
/// An Inkwell server's own copy of the AppImage, by its base address.
Server(String),
}
impl Source {
fn feed_url(&self) -> Result<String, String> {
match self {
Source::Forge(channel) => Ok(channel.feed_url()),
Source::Server(base) if cfg!(target_os = "linux") => {
Ok(format!("{base}/api/client/{SERVER_FEED_PLATFORM}/update.json"))
}
Source::Server(base) => Err(linux_only(base)),
}
}
fn server(&self) -> Option<&str> {
match self {
Source::Server(base) => Some(base),
Source::Forge(_) => None,
}
}
fn describe(&self) -> String {
match self {
Source::Forge(channel) => format!("the {} channel", channel.as_str()),
Source::Server(base) => base.clone(),
}
}
}
/// A server address in the one shape this app stores, or `None` if it isn't one.
///
/// The shape `install.sh` and the server's installer route accept, for the same
/// reason: this value is spliced into every update URL, and an address carrying a
/// query, a fragment or whitespace is a paste gone wrong rather than a place to
/// fetch from.
fn normalize_server(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_end_matches('/');
let rest = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))?;
let allowed = |c: char| c.is_ascii_alphanumeric() || ":/._~-".contains(c);
if rest.is_empty() || !rest.chars().all(allowed) {
return None;
}
Some(trimmed.to_string())
}
/// Said when this app is on Windows and pointed at a server.
fn linux_only(base: &str) -> String {
format!(
"{base} only publishes in-app updates for the Linux AppImage. Switch App \
updates back to Fabled-Git on this system."
)
}
const NOT_AN_ADDRESS: &str =
"That isn't a server address. It should look like https://notes.example.com.";
/// A server that answered with no AppImage. Never "up to date": the app would sit
/// on its build forever believing it current, which is #2183's shape again.
fn no_server_build(base: &str) -> String {
format!(
"{base} has no desktop build to update from. Ask whoever runs it, or switch \
App updates back to Fabled-Git."
)
}
/// Why a server's update can be seen and not installed.
fn needs_link(base: &str) -> String {
format!(
"Updates from {base} download with this app's link to it, the same as sync. \
Link this app to {base} in Sync to install them."
)
}
/// What the UI needs to describe the update situation without a second call.
#[derive(Debug, Serialize)]
pub struct UpdateStatus {
pub channel: Channel,
/// The server updates come from, or `None` for Fabled-Git.
pub source: Option<String>,
pub current_version: String,
/// The newer version on offer, or `None` when already up to date.
pub available: Option<String>,
@@ -167,7 +278,7 @@ pub fn adopt_installer_channel(db: &Db, data_dir: &Path) {
);
return;
};
match adopt(db, channel) {
match adopt(db, CHANNEL_PREF, CHANNEL_SEED_PREF, channel.as_str()) {
Ok(true) => log::info!(
"following the {} update channel, as recorded by the installer",
channel.as_str()
@@ -177,21 +288,97 @@ pub fn adopt_installer_channel(db: &Db, data_dir: &Path) {
}
}
/// Apply `channel` unless we already applied this same marker value. Returns whether
/// anything changed.
fn adopt(db: &Db, channel: Channel) -> Result<bool, String> {
/// Adopt the server the installer recorded, exactly as the channel above is adopted
/// and for the same reasons: once per new marker value, so a choice made in the app
/// afterwards sticks, and reinstalling from a different server is honoured.
pub fn adopt_installer_server(db: &Db, data_dir: &Path) {
let path = data_dir.join(SERVER_MARKER);
let Ok(raw) = std::fs::read_to_string(&path) else {
return;
};
let Some(server) = normalize_server(&raw) else {
log::warn!(
"ignoring an unreadable install server marker at {}",
path.display()
);
return;
};
match adopt(db, SERVER_PREF, SERVER_SEED_PREF, &server) {
Ok(true) => log::info!("taking updates from {server}, as the installer recorded"),
Ok(false) => {}
Err(e) => log::warn!("could not apply the installer's update server: {e}"),
}
}
/// Write `value` to `pref` unless this same marker value was already applied.
/// Returns whether anything changed.
fn adopt(db: &Db, pref: &str, seed_pref: &str, value: &str) -> Result<bool, String> {
let conn = db.conn()?;
let adopted = store::pref(&conn, CHANNEL_SEED_PREF).map_err(|e| e.to_string())?;
let adopted = store::pref(&conn, seed_pref).map_err(|e| e.to_string())?;
// Already acted on this marker — whatever the pref says now is the user's own
// choice, and re-applying would quietly undo it.
if adopted.as_deref() == Some(channel.as_str()) {
if adopted.as_deref() == Some(value) {
return Ok(false);
}
store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).map_err(|e| e.to_string())?;
store::set_pref(&conn, CHANNEL_SEED_PREF, channel.as_str()).map_err(|e| e.to_string())?;
store::set_pref(&conn, pref, value).map_err(|e| e.to_string())?;
store::set_pref(&conn, seed_pref, value).map_err(|e| e.to_string())?;
Ok(true)
}
/// Where updates come from right now. See `Source`: this is the only place it is
/// decided.
fn read_source(db: &Db) -> Result<Source, String> {
let conn = db.conn()?;
let server = store::pref(&conn, SERVER_PREF).map_err(|e| e.to_string())?;
if let Some(base) = server.as_deref().and_then(normalize_server) {
return Ok(Source::Server(base));
}
let channel = store::pref(&conn, CHANNEL_PREF).map_err(|e| e.to_string())?;
let channel = channel.as_deref().map(Channel::parse);
Ok(Source::Forge(channel.unwrap_or(Channel::Stable)))
}
/// The device token to download from `base` with: the sync link's, when this app
/// is linked to that same server. The bytes are for the server's accounts only, and
/// the link is the one credential this app already holds for it.
fn token_for(db: &Db, base: &str) -> Result<Option<String>, String> {
let conn = db.conn()?;
let link = state::read(&conn).map_err(|e| e.to_string())?;
let linked = link.server_url.as_deref().and_then(normalize_server);
if linked.as_deref() == Some(base) {
Ok(link.device_token)
} else {
Ok(None)
}
}
/// An updater pointed at `source`, carrying `token` when there is one. The plugin
/// sends the same headers on the download as on the check, so the token reaches the
/// server's download route — and the server builds that URL on the host this check
/// asked, so it goes nowhere else.
fn updater_for(
app: &tauri::AppHandle,
source: &Source,
token: Option<&str>,
) -> Result<tauri_plugin_updater::Updater, String> {
let url = source
.feed_url()?
.parse()
.map_err(|e| format!("the update feed address is malformed: {e}"))?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| e.to_string())?;
if let Some(token) = token {
builder = builder
.header("Authorization", format!("Bearer {token}"))
.map_err(|e| e.to_string())?;
}
builder
.build()
.map_err(|e| format!("updates aren't configured for this build: {e}"))
}
fn read_channel(db: &State<'_, Db>) -> Result<Channel, String> {
let conn = db.conn()?;
let raw = store::pref(&conn, CHANNEL_PREF).map_err(|e| e.to_string())?;
@@ -214,6 +401,28 @@ pub fn update_channel_set(channel: Channel, db: State<'_, Db>) -> Result<Channel
Ok(channel)
}
/// Take updates from `server`, or from Fabled-Git when it is `None`. Returns the
/// address as stored, so the UI shows what will actually be asked.
#[tauri::command]
pub fn update_source_set(
server: Option<String>,
db: State<'_, Db>,
) -> Result<Option<String>, String> {
let value = match server.as_deref().map(normalize_server) {
None => String::new(),
Some(Some(base)) => base,
Some(None) => return Err(NOT_AN_ADDRESS.to_string()),
};
let conn = db.conn()?;
store::set_pref(&conn, SERVER_PREF, &value).map_err(|e| e.to_string())?;
if value.is_empty() {
log::info!("taking updates from Fabled-Git");
} else {
log::info!("taking updates from {value}");
}
Ok((!value.is_empty()).then_some(value))
}
/// Ask the feed whether there's something newer. Never installs anything.
#[tauri::command]
pub async fn update_check(
@@ -221,38 +430,39 @@ pub async fn update_check(
db: State<'_, Db>,
) -> Result<UpdateStatus, String> {
let channel = read_channel(&db)?;
let source = read_source(&db)?;
let current_version = app.package_info().version.to_string();
let blocked_reason = self_update_blocker();
let token = match source.server() {
Some(base) => token_for(&db, base)?,
None => None,
};
let updater = updater_for(&app, &source, token.as_deref())?;
let url = channel
.feed_url()
.parse()
.map_err(|e| format!("the update feed address is malformed: {e}"))?;
let updater = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| e.to_string())?
.build()
.map_err(|e| format!("updates aren't configured for this build: {e}"))?;
// A missing manifest is the ordinary state of a channel nobody has published to
// yet — report it as "nothing available" rather than as a failure to act on.
// Matched on the message rather than an error variant so this doesn't break on a
// plugin minor that renames one.
let found = match updater.check().await {
Ok(found) => found,
Err(e) => {
let detail = e.to_string();
if is_missing_manifest(&detail) {
None
} else {
return Err(describe_check_error(&detail));
match source.server() {
Some(base) if is_missing_manifest(&detail) => return Err(no_server_build(base)),
Some(_) => return Err(describe_check_error(&detail)),
// A missing manifest on the forge is the ordinary state of a channel
// nobody has published to yet — "nothing available", not a failure.
None if is_missing_manifest(&detail) => None,
None => return Err(describe_check_error(&detail)),
}
}
};
let blocked_reason = self_update_blocker().or_else(|| match source.server() {
Some(base) if token.is_none() => Some(needs_link(base)),
_ => None,
});
Ok(UpdateStatus {
channel,
source: source.server().map(str::to_string),
current_version,
available: found.as_ref().map(|u| u.version.clone()),
notes: found.as_ref().and_then(|u| u.body.clone()),
@@ -270,31 +480,32 @@ pub async fn update_install(app: tauri::AppHandle, db: State<'_, Db>) -> Result<
if let Some(reason) = self_update_blocker() {
return Err(reason);
}
let channel = read_channel(&db)?;
let url = channel
.feed_url()
.parse()
.map_err(|e| format!("the update feed address is malformed: {e}"))?;
let updater = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| e.to_string())?
.build()
.map_err(|e| format!("updates aren't configured for this build: {e}"))?;
let source = read_source(&db)?;
let token = match source.server() {
Some(base) => Some(token_for(&db, base)?.ok_or_else(|| needs_link(base))?),
None => None,
};
let updater = updater_for(&app, &source, token.as_deref())?;
let found = updater
.check()
.await
.map_err(|e| describe_check_error(&e.to_string()))?;
let found = match updater.check().await {
Ok(found) => found,
Err(e) => {
let detail = e.to_string();
return Err(match source.server() {
Some(base) if is_missing_manifest(&detail) => no_server_build(base),
_ => describe_check_error(&detail),
});
}
};
let Some(update) = found else {
return Err("There's no update to install — this is already the newest build.".to_string());
};
log::info!(
"installing update {} over {} ({} channel)",
"installing update {} over {} (from {})",
update.version,
app.package_info().version,
channel.as_str()
source.describe()
);
update
.download_and_install(|_chunk, _total| {}, || {})
@@ -486,6 +697,110 @@ mod tests {
assert!(describe_check_error("invalid signature").contains("invalid signature"));
}
fn data_dir_with_server_marker(contents: &str) -> std::path::PathBuf {
let dir = scratch_path("server-marker");
std::fs::create_dir_all(&dir).expect("scratch dir");
std::fs::write(dir.join(SERVER_MARKER), contents).expect("write marker");
dir
}
fn set_server(db: &Db, value: &str) {
let conn = db.0.lock().expect("lock");
store::set_pref(&conn, SERVER_PREF, value).expect("set pref");
}
#[test]
fn with_no_server_recorded_updates_come_from_the_forge() {
// The operator's own installs: nothing here may change for them.
let db = db();
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
set_channel(&db, Channel::Dev);
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Dev)));
}
#[test]
fn an_install_from_a_server_takes_its_updates_from_that_server() {
let db = db();
let dir = data_dir_with_server_marker("https://notes.example.com/\n");
adopt_installer_server(&db, &dir);
assert_eq!(
read_source(&db),
Ok(Source::Server("https://notes.example.com".to_string()))
);
}
#[test]
fn choosing_the_forge_in_the_app_survives_the_next_launch() {
let db = db();
let dir = data_dir_with_server_marker("https://notes.example.com");
adopt_installer_server(&db, &dir);
set_server(&db, "");
adopt_installer_server(&db, &dir);
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
}
#[test]
fn a_damaged_server_marker_changes_nothing() {
let db = db();
adopt_installer_server(&db, &data_dir_with_server_marker("notes.example.com"));
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
}
#[test]
fn only_an_address_is_a_server() {
assert_eq!(
normalize_server(" https://notes.example.com:8443/inkwell/ "),
Some("https://notes.example.com:8443/inkwell".to_string())
);
assert_eq!(
normalize_server("http://192.168.1.20:5000"),
Some("http://192.168.1.20:5000".to_string())
);
for bad in [
"notes.example.com",
"ftp://notes.example.com",
"https://",
"https://notes.example.com/?q=1",
"https://notes.example.com/#x",
"https://notes example.com",
] {
assert_eq!(normalize_server(bad), None, "{bad:?} was accepted");
}
}
#[cfg(target_os = "linux")]
#[test]
fn a_server_feed_is_its_appimage_manifest() {
let source = Source::Server("https://notes.example.com".to_string());
assert_eq!(
source.feed_url(),
Ok("https://notes.example.com/api/client/linux-appimage/update.json".to_string())
);
}
#[test]
fn the_download_token_is_the_link_to_that_same_server() {
let db = db();
assert_eq!(token_for(&db, "https://notes.example.com"), Ok(None));
{
let conn = db.0.lock().expect("lock");
state::set_link(&conn, "https://notes.example.com/", "tok").expect("link");
}
assert_eq!(
token_for(&db, "https://notes.example.com"),
Ok(Some("tok".to_string()))
);
// Never sent to a server it wasn't issued by.
assert_eq!(token_for(&db, "https://other.example.com"), Ok(None));
}
#[test]
fn a_server_with_nothing_to_offer_says_so() {
// Not "up to date" — that answer is the one that would never be corrected.
let msg = no_server_build("https://notes.example.com");
assert!(msg.contains("no desktop build"), "got {msg}");
}
#[test]
fn the_channel_name_round_trips() {
for channel in [Channel::Stable, Channel::Dev] {
+4
View File
@@ -229,6 +229,8 @@ export type UpdateChannel = "stable" | "dev";
export interface UpdateStatus {
channel: UpdateChannel;
/** The server updates come from, or null for Fabled-Git (and its channel). */
source: string | null;
current_version: string;
/** The newer version on offer, or null when already up to date. */
available: string | null;
@@ -241,6 +243,8 @@ export interface UpdateStatus {
export const updates = {
channel: () => invoke<UpdateChannel>("update_channel_get"),
setChannel: (channel: UpdateChannel) => invoke<UpdateChannel>("update_channel_set", { channel }),
/** Take updates from `server`, or from Fabled-Git when null. Returns what was stored. */
setSource: (server: string | null) => invoke<string | null>("update_source_set", { server }),
/** Ask the feed what's out there. Never installs anything. */
check: () => invoke<UpdateStatus>("update_check"),
/**
+69 -1
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"
>
<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>
+43 -1
View File
@@ -76,10 +76,11 @@ import json
from dataclasses import dataclass
from pathlib import Path
from quart import Blueprint, jsonify, send_from_directory
from quart import Blueprint, jsonify, request, send_from_directory
from .auth import login_required
from .config import Config
from .proxy import is_https
from .responses import json_error
@@ -328,6 +329,47 @@ async def client_metadata(platform_id: str):
return jsonify(found)
@bp.get("/api/client/<platform_id>/update.json")
async def client_update_manifest(platform_id: str):
"""The same build, in the shape the desktop's in-app updater reads.
Tauri's updater takes a manifest, not this module's metadata: `version` is the
ordering key it compares (the desktop's `1.0.<minutes>`, i.e. `version_code`),
and `url` + `signature` at the top level are its single-build form. So this is a
translation of `release()`, never a second description of the build.
Only for a SIGNED platform. The updater verifies the signature against the key
baked into the app before it replaces anything, so a server mirrors builds here
and cannot sign them: the trust anchor does not move with the feed. A platform
with no signature has no manifest, and a server holding no AppImage 404s, which
the desktop reports as "this server has no update to offer", never as "up to
date".
`url` must be absolute for the updater, which is the one place this module
hands out more than a path. It is built from the address THIS request arrived
on, not from Settings, because the updater sends the same Authorization header
to it that it sent here: the token goes back to the host that was asked, and
nowhere else.
Public, like the metadata above. The bytes `url` names are not.
"""
platform = BY_ID.get(platform_id)
if platform is None or not platform.signed:
return json_error(f"this server publishes no in-app updates for {platform_id}", 404)
found = release(platform_id)
if found is None:
return json_error(f"this server has no {platform_id} client", 404)
origin = f"{'https' if is_https() else 'http'}://{request.host}"
return jsonify(
{
"version": found["version_code"],
"notes": f"Inkwell {found['version']}",
"url": origin + found["url"],
"signature": found["signature"],
}
)
@bp.get("/api/client/<platform_id>/download")
@login_required
async def client_download(platform_id: str):
+29
View File
@@ -393,3 +393,32 @@ async def test_the_bytes_need_authentication_even_though_the_version_does_not(ap
place(platform_id)
resp = await app.test_client().get(f"/api/client/{platform_id}/download")
assert resp.status_code == 401
async def test_the_appimage_has_an_updater_manifest_built_from_the_same_build(app):
"""Tauri's single-build form: the ordering key it compares, the signature it
checks, and an absolute URL on the host that was asked."""
place("linux-appimage", signature="sig-bytes")
resp = await app.test_client().get(
"/api/client/linux-appimage/update.json", headers={"Host": "notes.example.com"}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["version"] == code_for("linux-appimage")
assert body["signature"] == "sig-bytes"
assert body["url"] == "http://notes.example.com/api/client/linux-appimage/download"
@pytest.mark.parametrize("platform_id", [p.id for p in PLATFORMS if not p.signed])
async def test_an_unsigned_platform_has_no_updater_manifest(app, platform_id):
"""No signature, nothing the updater could verify, so nothing to offer it."""
place(platform_id)
resp = await app.test_client().get(f"/api/client/{platform_id}/update.json")
assert resp.status_code == 404
async def test_a_server_without_the_appimage_404s_its_updater_manifest(app):
"""The desktop turns this into "this server has no update to offer" rather than
"up to date", so the 404 has to be there to turn."""
resp = await app.test_client().get("/api/client/linux-appimage/update.json")
assert resp.status_code == 404