Files
thoughtsync/desktop/src-tauri/src/update.rs
T
bvandeusenandClaude Opus 5 2e8717a057
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m4s
Desktop (Tauri) / Update manifest (push) Successful in 3s
desktop: rustfmt the two new preference helpers
Verified locally this time rather than in CI. The ci-tauri image is already on
this machine, so `cargo fmt --check` can run in a throwaway container against
the exact toolchain CI uses — no test run, no build, no local stack, just the
formatter. Four consecutive pushes had failed on formatting alone; that class
of failure is now catchable before it costs a cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 19:27:27 -04:00

278 lines
10 KiB
Rust

//! In-app updates (M10.9).
//!
//! Two channels, because two audiences: `stable` follows tagged `v*` releases,
//! `dev` follows every green push. 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.
//!
//! The feed lives on Fabled-Git rather than on a ThoughtSync 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 serde::{Deserialize, Serialize};
use tauri::State;
use tauri_plugin_updater::UpdaterExt;
use crate::local::{store, Db};
/// Where the manifests live. Fixed tags, so these URLs are permanent.
const FEED_BASE: &str = "https://git.fabledsword.com/bvandeusen/thoughtsync/releases/download";
const CHANNEL_PREF: &str = "update_channel";
#[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,
}
}
fn feed_url(self) -> String {
format!("{FEED_BASE}/{}/latest.json", self.as_str())
}
}
/// 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
}
fn read_channel(db: &State<'_, Db>) -> Result<Channel, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
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.0.lock().map_err(|e| e.to_string())?;
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::*;
#[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/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);
}
}
}