Files
inkwell/desktop/src-tauri/src/update.rs
T
bvandeusenandClaude Opus 5.5 ee771e3734 Every lock of the store goes through Db::conn
Db::conn is documented as the one way to take the lock, yet five production and
test sites reached past it with db.0.lock(): the startup summary, the desktop's
trash sweep and config_get, and tests in sharing and update. All five now call
conn().

DRY pass #2, batch 2, F8 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:23:07 -04:00

808 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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/<asset>` 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
//! `<channel>/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<Self> {
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<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>,
pub notes: Option<String>,
/// 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<String>,
}
/// 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<String> {
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<bool, String> {
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<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 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<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())?;
Ok(raw
.as_deref()
.map(Channel::parse)
.unwrap_or(Channel::Stable))
}
#[tauri::command]
pub fn update_channel_get(db: State<'_, Db>) -> Result<Channel, String> {
read_channel(&db)
}
#[tauri::command]
pub fn update_channel_set(channel: Channel, db: State<'_, Db>) -> Result<Channel, String> {
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<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(
app: tauri::AppHandle,
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 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 <branch>`
// 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);
}
}
}