//! In-app updates (M10.9). //! //! Two channels, because two audiences: `stable` follows every merge to `main`, //! `dev` follows every green push to `dev`. Each reads a `latest.json` published as //! an asset on a release whose TAG NEVER CHANGES — verified necessary, because //! Forgejo has no `/releases/latest/download/` route (it 404s), so "newest" //! cannot be named in a URL. A fixed tag can. //! //! `stable` followed tagged `v*` releases until M314 step 3, and its manifest pointed //! at bundles living on a different release. It holds its own bundles now, exactly as //! `dev` always has — so a build reaches stable users with no tag cut anywhere, which //! 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 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 //! signing key that has passed through anyone else's hands is not a signing key. use std::path::Path; use serde::{Deserialize, Serialize}; 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"; const CHANNEL_PREF: &str = "update_channel"; /// The channel the app follows, as recorded by whatever installed it. Written into /// the app-data directory by `desktop/packaging/install.sh`; both sides must agree on /// this name. const INSTALL_MARKER: &str = "install-channel"; /// The marker value we last acted on. Deliberately separate from `CHANNEL_PREF`: it /// records what the INSTALLER said, so the two can be compared and a change told apart /// 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 { Stable, Dev, } impl Channel { fn as_str(self) -> &'static str { match self { Channel::Stable => "stable", Channel::Dev => "dev", } } /// Anything unrecognized reads as `stable`. A corrupted or hand-edited value /// must not silently opt someone into pre-release builds. fn parse(raw: &str) -> Self { match raw.trim() { "dev" => Channel::Dev, _ => Channel::Stable, } } /// Strict parse, for the installer's marker file only. /// /// Unrecognized reads as "no opinion" here rather than as `stable` (which is what /// `parse` does), because the two are asked different questions. `parse` reads a /// pref that is definitely *this app's* setting, so it has to answer with a /// channel. The marker is a file written by something else, and a truncated or /// hand-edited one must not silently move someone OFF the channel they chose. /// Ignoring it is the only reading that can't do harm in either direction. fn from_marker(raw: &str) -> Option { match raw.trim() { "stable" => Some(Channel::Stable), "dev" => Some(Channel::Dev), _ => None, } } /// The release tag holding this channel's builds — not the channel's name. /// /// `dev` used to be both, and a tag named `dev` shadowed the branch of the same /// name, so `git push origin dev` failed as ambiguous (Scribe #2184). The channel /// is still `dev` everywhere a person sees it and in the stored pref; only the tag /// moved. Mirrors `packaging/channel-tag.sh`, which is what CI publishes with. fn release_tag(self) -> &'static str { match self { Channel::Stable => "stable", Channel::Dev => "dev-rolling", } } fn feed_url(self) -> String { format!("{FEED_BASE}/{}/latest.json", self.release_tag()) } } /// 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, pub notes: Option, /// False when this install can't apply an update to itself (see /// `self_update_blocker`). The UI must not offer a button that cannot work. pub can_install: bool, /// Why not, in words meant for the person reading them. pub blocked_reason: Option, } /// Whether this install can replace itself, and if not, why. /// /// The updater rewrites the running bundle in place, which only works for formats /// that ARE a single self-contained file. On Linux that means the AppImage and /// nothing else: a `.deb` or pacman install is owned by the package manager, and /// silently overwriting files it tracks would corrupt its database. Tauri detects /// the AppImage case by the `APPIMAGE` env var the runtime sets. fn self_update_blocker() -> Option { if cfg!(target_os = "linux") && std::env::var_os("APPIMAGE").is_none() { return Some( "This copy was installed by your package manager, so it updates the same \ way — `apt upgrade`, `pacman -Syu`, or re-running the install script. \ In-app updates work on the AppImage build." .to_string(), ); } None } /// Adopt the channel the installer recorded, when it differs from the one we last /// adopted. Called once at startup, before the frontend can ask what the channel is. /// /// The installer knows which channel the user asked for; the app is what acts on it. /// Until this existed, `install.sh --channel dev` left the pref at its `stable` /// default, so a dev build checked the stable feed — which advertises an OLDER /// version — and reported "up to date" forever (issue 2183). Two places held the same /// decision and only one of them was set. /// /// Comparing against the last-adopted value, rather than only seeding when the pref is /// unset, is what makes both directions work: choosing a channel in the app sticks /// across launches (the marker hasn't changed since we adopted it), while re-running /// the installer on a DIFFERENT channel is honoured (it has). Seeding-when-unset would /// have fixed only the very first install and left the second one silently wrong. /// /// Every failure is logged and stepped over. No marker at all is the ordinary state of /// a build installed some other way — from source, from a downloaded AppImage, or by a /// Windows installer that has no channel concept — and none of that should keep the /// app from opening. pub fn adopt_installer_channel(db: &Db, data_dir: &Path) { let path = data_dir.join(INSTALL_MARKER); let Ok(raw) = std::fs::read_to_string(&path) else { return; }; let Some(channel) = Channel::from_marker(&raw) else { log::warn!( "ignoring an unreadable install channel marker at {}", path.display() ); return; }; 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() ), Ok(false) => {} Err(e) => log::warn!("could not apply the installer's update channel: {e}"), } } /// 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, 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(value) { return Ok(false); } 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 Some((url, token)) = state::credentials(&conn, None).map_err(|e| e.to_string())? else { return Ok(None); }; Ok((normalize_server(&url).as_deref() == Some(base)).then_some(token)) } /// 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())?; Ok(raw .as_deref() .map(Channel::parse) .unwrap_or(Channel::Stable)) } #[tauri::command] pub fn update_channel_get(db: State<'_, Db>) -> Result { read_channel(&db) } #[tauri::command] pub fn update_channel_set(channel: Channel, db: State<'_, Db>) -> Result { let conn = db.conn()?; store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).map_err(|e| e.to_string())?; log::info!("update channel set to {}", channel.as_str()); 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, 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( app: tauri::AppHandle, db: State<'_, Db>, ) -> Result { let channel = read_channel(&db)?; let source = read_source(&db)?; let current_version = app.package_info().version.to_string(); let token = match source.server() { Some(base) => token_for(&db, base)?, None => None, }; let updater = updater_for(&app, &source, token.as_deref())?; // 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(); 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()), can_install: found.is_some() && blocked_reason.is_none(), blocked_reason, }) } /// Download, verify and apply the update, then relaunch. /// /// Refuses up front on an install that can't replace itself, rather than failing /// halfway through with a permissions error nobody can interpret. #[tauri::command] pub async fn update_install(app: tauri::AppHandle, db: State<'_, Db>) -> Result<(), String> { if let Some(reason) = self_update_blocker() { return Err(reason); } 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 = 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 {} (from {})", update.version, app.package_info().version, source.describe() ); update .download_and_install(|_chunk, _total| {}, || {}) .await .map_err(|e| format!("the update couldn't be installed: {e}"))?; // Only reached if the install succeeded. `restart` diverges, so it's the tail. log::info!("update installed; restarting"); app.restart() } /// Whether a check failure just means "this channel has nothing published yet". fn is_missing_manifest(detail: &str) -> bool { let lower = detail.to_lowercase(); lower.contains("404") || lower.contains("not found") } /// Turn a check failure into something worth reading. The default rendering of a /// transport error names the URL and nothing else, which tells a user nothing about /// what they could do next. fn describe_check_error(detail: &str) -> String { let lower = detail.to_lowercase(); if lower.contains("error sending request") || lower.contains("dns") || lower.contains("connect") { return "Couldn't reach the update server — check your connection.".to_string(); } format!("Couldn't check for updates: {detail}") } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; fn db() -> Db { inkwell_core::local::open_in_memory().expect("in-memory db") } /// A scratch path nothing else will collide with. Process id keeps concurrent /// `cargo test` runs apart, the counter keeps tests within one run apart — which /// is all uuid was doing here, without the dependency (the core owns uuid now, /// and pulling it into the desktop crate for two test filenames would be silly). fn scratch_path(tag: &str) -> std::path::PathBuf { static SEQ: AtomicU32 = AtomicU32::new(0); let n = SEQ.fetch_add(1, Ordering::Relaxed); std::env::temp_dir().join(format!("ts-{tag}-{}-{n}", std::process::id())) } /// The channel `update_check` would actually use. fn effective(db: &Db) -> Channel { let conn = db.conn().expect("lock"); store::pref(&conn, CHANNEL_PREF) .expect("read pref") .as_deref() .map(Channel::parse) .unwrap_or(Channel::Stable) } fn set_channel(db: &Db, channel: Channel) { let conn = db.conn().expect("lock"); store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).expect("set pref"); } /// A scratch directory holding a marker file, named uniquely so tests can run in /// parallel. Dropped by the OS' temp cleanup; nothing here is worth a new dependency. fn data_dir_with_marker(contents: &str) -> std::path::PathBuf { let dir = scratch_path("marker"); std::fs::create_dir_all(&dir).expect("scratch dir"); std::fs::write(dir.join(INSTALL_MARKER), contents).expect("write marker"); dir } #[test] fn a_dev_install_follows_the_dev_channel() { // The bug this whole mechanism exists for (2183): the installer knew, the app // didn't, and a dev install checked the stable feed forever. let db = db(); assert_eq!( effective(&db), Channel::Stable, "default before any install" ); adopt_installer_channel(&db, &data_dir_with_marker("dev\n")); assert_eq!(effective(&db), Channel::Dev); } #[test] fn choosing_a_channel_in_the_app_survives_the_next_launch() { // Adoption runs on EVERY startup, so the marker must not keep re-asserting // itself over a choice the user made afterwards. let db = db(); let dir = data_dir_with_marker("dev"); adopt_installer_channel(&db, &dir); set_channel(&db, Channel::Stable); adopt_installer_channel(&db, &dir); assert_eq!(effective(&db), Channel::Stable); } #[test] fn reinstalling_on_a_different_channel_moves_the_app() { // The half a seed-only-when-unset fix would have missed: install stable, then // install dev. The pref is already set, but the user just asked for dev. let db = db(); adopt_installer_channel(&db, &data_dir_with_marker("stable")); assert_eq!(effective(&db), Channel::Stable); adopt_installer_channel(&db, &data_dir_with_marker("dev")); assert_eq!(effective(&db), Channel::Dev); } #[test] fn a_damaged_marker_changes_nothing() { // Ignored, NOT read as stable — a corrupt file must not move someone off the // channel they're on. let db = db(); set_channel(&db, Channel::Dev); adopt_installer_channel(&db, &data_dir_with_marker("de")); assert_eq!(effective(&db), Channel::Dev); assert_eq!(Channel::from_marker("nightly"), None); assert_eq!(Channel::from_marker(""), None); assert_eq!(Channel::from_marker(" dev\n"), Some(Channel::Dev)); } #[test] fn no_marker_at_all_is_not_an_error() { // Built from source, or installed by anything that isn't install.sh. let db = db(); set_channel(&db, Channel::Dev); let empty = scratch_path("nomarker"); std::fs::create_dir_all(&empty).expect("scratch dir"); adopt_installer_channel(&db, &empty); assert_eq!(effective(&db), Channel::Dev); } #[test] fn no_channel_feed_is_named_like_a_branch() { // A release tag spelled like a branch makes `git push origin ` // ambiguous once the tag is fetched (Scribe #2184). `dev` and `main` are the // branches this repo pushes. for channel in [Channel::Stable, Channel::Dev] { assert!( !["dev", "main"].contains(&channel.release_tag()), "{channel:?} publishes on a tag named like a branch" ); } } #[test] fn each_channel_has_its_own_fixed_feed() { assert!(Channel::Stable.feed_url().ends_with("/stable/latest.json")); assert!(Channel::Dev .feed_url() .ends_with("/dev-rolling/latest.json")); assert_ne!(Channel::Stable.feed_url(), Channel::Dev.feed_url()); } #[test] fn the_feed_is_https() { // The manifest names the URL and signature of a binary that is about to // replace this one. The signature is what actually protects it, but there's // no reason to hand an attacker the manifest to tamper with in the first place. assert!(Channel::Stable.feed_url().starts_with("https://")); assert!(Channel::Dev.feed_url().starts_with("https://")); } #[test] fn an_unknown_channel_falls_back_to_stable() { // A corrupted or hand-edited pref must never silently opt someone into // pre-release builds — the safe default is the conservative one. assert_eq!(Channel::parse("dev"), Channel::Dev); assert_eq!(Channel::parse("stable"), Channel::Stable); assert_eq!(Channel::parse("nightly"), Channel::Stable); assert_eq!(Channel::parse(""), Channel::Stable); } #[test] fn an_unpublished_channel_is_not_reported_as_a_failure() { // Before the first publish, or on a channel nobody uses, the feed simply // isn't there. That's "you're up to date", not something to alarm anyone with. assert!(is_missing_manifest("http status: 404 Not Found")); assert!(is_missing_manifest("Release Not Found")); assert!(!is_missing_manifest("invalid signature")); } #[test] fn an_unreachable_server_reads_as_a_connection_problem() { let msg = describe_check_error("error sending request for url (https://…)"); assert!(msg.contains("connection"), "got {msg}"); // Anything unrecognized still surfaces its detail rather than being swallowed. 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.conn().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.conn().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] { assert_eq!(Channel::parse(channel.as_str()), channel); } } }