CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m36s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m24s
Step 3 of M314. Note 3127 §0's diagnostic is "is `main` publishing sufficient for a user to receive the build" — and here it was not. The desktop and Android lanes BUILT on main and published nothing: `Publish release` was gated on `refs/tags/v*`, the channel publishes on `refs/heads/dev`, the manifest job on dev-or-tag. So the stable channel moved only when somebody cut a tag, which made a `v*` tag load-bearing rather than the optional bookmark the model wants. Both channels are rolling fixed-tag releases now. `dev` from dev, `stable` from main, same machinery — `publish-release.sh` already took RELEASE_TAG, `write-manifest.sh` already pruned, and both already PATCHed a stale description on 409 (#2182). This is wiring, not new mechanism. ## The defect this carried `ci.yml`'s "Fetch the Android client to bake in" read `releases/download/dev` UNCONDITIONALLY, on every branch. Every image baked in the dev APK — `:latest` included — so a stable server served a dev-channel client to anyone who downloaded it from there. That has nothing to do with versioning; it is fixed here because this is the step that finally gives `stable` an APK to point at. It also means Android needs no channel machinery of its own. The APK is served FROM the image, so the channel is already a property of which image you run — note 3127 §7's "nothing to hand off" shape, arrived at here by accident. One branch-conditional line, not a second channel in `client_dist.py` as this milestone first assumed. ## The break this nearly shipped `install.sh --channel stable` read the version out of `stable/latest.json` and then fetched `releases/tags/v<version>` for the bundles — correct while stable was a manifest-only pointer, and broken the moment stable holds its own. Stable is the DEFAULT channel, so `curl … | sh` would have failed for everyone between this commit and the first merge to main. Both channels are one lookup now: fetch the fixed-tag release, install what is on it. A transitional fallback covers the window where `stable` still has no bundles, marked for deletion in step 7 — without it the default channel is broken for however long it takes to merge, and that window is gated on an operator request rather than on this lane. ## The two writers problem `stable`'s manifest was written by tag builds. It is written by main now, and the tag path stops writing it — two writers for one channel is a race with no winner worth having. A `v*` tag still writes its own versioned manifest; its build consequence goes entirely in step 7. Also corrected: `update.rs`'s header still described stable as following `v*` tags. Nothing in that file moved — it only ever read `<channel>/latest.json` — but the comment was a lie, and it is the file somebody reads to understand the feed. #3143 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
468 lines
19 KiB
Rust
468 lines
19 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 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 std::path::Path;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::State;
|
|
use tauri_plugin_updater::UpdaterExt;
|
|
|
|
use thoughtsync_core::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";
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// 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.0.lock().map_err(|e| e.to_string())?;
|
|
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.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::*;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
|
fn db() -> Db {
|
|
thoughtsync_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 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);
|
|
}
|
|
}
|
|
}
|