Files
inkwell/desktop/src-tauri/src/update.rs
T
bvandeusenandClaude Opus 5.5 05c82c2362
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Web typecheck and unit tests (push) Successful in 16s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Canceled after 12m20s
core, desktop: Db::conn everywhere, Change::default, notes_where, and shared row mappings
From the audit (#5179, core and desktop half).

- Every `db.0.lock().map_err(|e| e.to_string())?` (about 50 sites in core and
  the desktop) is now `db.conn()?`. The few sites that deliberately handle
  a poisoned lock differently, and the tests, keep their own spelling.
- push::Change derives Default, so its four constructors name only the
  fields they set.
- store: list_notes, reminders, titles and search share notes_where (ids
  from a query, each loaded through load_note). Labels share
  LABEL_SELECT/label_row, and saved filters share
  SAVED_FILTER_SELECT/saved_filter_row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:57:24 -04:00

496 lines
20 KiB
Rust

//! 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 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.
//!
//! 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};
/// 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";
#[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())
}
}
/// What the UI needs to describe the update situation without a second call.
#[derive(Debug, Serialize)]
pub struct UpdateStatus {
pub channel: Channel,
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) {
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}"),
}
}
/// Apply `channel` unless we already applied this same marker value. Returns whether
/// anything changed.
fn adopt(db: &Db, channel: Channel) -> Result<bool, String> {
let conn = db.conn()?;
let adopted = store::pref(&conn, CHANNEL_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()) {
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())?;
Ok(true)
}
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)
}
/// 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 current_version = app.package_info().version.to_string();
let blocked_reason = self_update_blocker();
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));
}
}
};
Ok(UpdateStatus {
channel,
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 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 found = updater
.check()
.await
.map_err(|e| describe_check_error(&e.to_string()))?;
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)",
update.version,
app.package_info().version,
channel.as_str()
);
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.0.lock().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.0.lock().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"));
}
#[test]
fn the_channel_name_round_trips() {
for channel in [Channel::Stable, Channel::Dev] {
assert_eq!(Channel::parse(channel.as_str()), channel);
}
}
}