diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cccd76c..49c0f49 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/desktop/src-tauri/src/update.rs b/desktop/src-tauri/src/update.rs index 61f8239..bd7bd4c 100644 --- a/desktop/src-tauri/src/update.rs +++ b/desktop/src-tauri/src/update.rs @@ -12,10 +12,16 @@ //! is the whole point of the change. NOTHING HERE MOVED: this code only ever read //! `/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 { + 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 { + 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, pub current_version: String, /// The newer version on offer, or `None` when already up to date. pub available: Option, @@ -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 { +/// 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 { 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 { + 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, 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 { + 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 { 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, + db: State<'_, Db>, +) -> Result, 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 { 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] { diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index 35cce97..ff67ea0 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -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("update_channel_get"), setChannel: (channel: UpdateChannel) => invoke("update_channel_set", { channel }), + /** Take updates from `server`, or from Fabled-Git when null. Returns what was stored. */ + setSource: (server: string | null) => invoke("update_source_set", { server }), /** Ask the feed what's out there. Never installs anything. */ check: () => invoke("update_check"), /** diff --git a/frontend/src/views/SyncView.vue b/frontend/src/views/SyncView.vue index 828de81..e50d82e 100644 --- a/frontend/src/views/SyncView.vue +++ b/frontend/src/views/SyncView.vue @@ -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(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 ?? "—" }}.

-
+
+ + Updates from + +
+ + +
+
+ + +
Channel @@ -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. + +

{{ updateError }}

diff --git a/src/inkwell/client_dist.py b/src/inkwell/client_dist.py index fdfe524..c99e3ee 100644 --- a/src/inkwell/client_dist.py +++ b/src/inkwell/client_dist.py @@ -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//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.`, 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//download") @login_required async def client_download(platform_id: str): diff --git a/tests/test_client_dist.py b/tests/test_client_dist.py index 4b0957c..e01c8bf 100644 --- a/tests/test_client_dist.py +++ b/tests/test_client_dist.py @@ -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