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
278 lines
10 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|