//! Updates the app from its releases on Vectrum (vectrum.cc/vectrum/code), //! the way T3 Code's desktop app does: it looks for a new version now and //! then (or when asked), downloads it when the user says so, and installs it //! when they restart. //! //! Only an AppImage updates itself; elsewhere a new version opens its //! release page. A release must carry `SHA256SUMS` and `SHA256SUMS.sig`, an //! Ed25519 signature over the sums made with the private half of a key in //! [`RELEASE_KEYS`] (`build.sh -r` signs). The AppImage, and the release //! notes in `CHANGES.md`, must hash to what the signed sums say, so neither //! the server nor anything between can slip in another file. A download is //! staged beside the AppImage and moved over it when the app restarts or //! quits. use std::{ fs, io::{Read as _, Write as _}, path::{Path, PathBuf}, time::Duration, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use gpui_kit::*; use serde::Deserialize; use crate::settings; /// Where releases come from. `VECTRUM_UPDATE_URL` points elsewhere, as a /// test server does; releases from there still need the signature. const SERVER: &str = "https://vectrum.cc"; const REPO: &str = "vectrum/code"; /// The public halves of the keys releases are signed with, base64. More than /// one lets a new key take over. const RELEASE_KEYS: &[&str] = &["wPnCiqfvFFXBY1PwhnGhcyK8ELkKasxlzSwMpZBDNxU="]; /// How long after starting the first look for a new version waits, and how /// often it looks again. const FIRST_CHECK: Duration = Duration::from_secs(15); const CHECK_EVERY: Duration = Duration::from_secs(6 * 60 * 60); /// Downloads larger than this are refused, whatever the server says. const MAX_SIZE: u64 = 512 * 1024 * 1024; /// How many changes the release notes list before "N more". pub const NOTES_SHOWN: usize = 12; pub fn current_version() -> &'static str { env!("CARGO_PKG_VERSION") } /// A newer version on the server, checked against the signed sums. #[derive(Clone, Debug, PartialEq)] pub struct Release { pub version: String, /// Its page on the server. pub page: String, /// What changed, one line each. pub notes: Vec, appimage_url: String, /// The AppImage's SHA-256, from the signed sums. sha256: Vec, } #[derive(Clone, Debug, Default, PartialEq)] pub enum Stage { #[default] Idle, Checking, Available(Release), Downloading { release: Release, done: u64, total: u64 }, /// Downloaded, checked and staged; a restart installs it. Ready(Release), /// A check or download went wrong; `release` is what it was getting. Failed { release: Option, error: String }, } impl Stage { pub fn release(&self) -> Option<&Release> { match self { Stage::Available(release) | Stage::Ready(release) | Stage::Downloading { release, .. } => Some(release), Stage::Failed { release, .. } => release.as_ref(), Stage::Idle | Stage::Checking => None, } } } /// The app's updater, a global the views watch. #[derive(Default)] pub struct Updater { pub stage: Stage, /// Said once after a check or download the user started, for a notice, /// and whether it's an error. notice: Option<(String, bool)>, /// Restart as soon as the download is ready: a client asked this host /// to update. restart_when_ready: bool, _task: Option>, _schedule: Option>, _quit: Option, } impl Global for Updater {} impl Updater { pub fn init(cx: &mut App) { let schedule = cx.spawn(async move |cx: &mut AsyncApp| { cx.background_executor().timer(FIRST_CHECK).await; loop { cx.update(|cx| { if settings::get(|settings| settings.app_update_checks) { Self::check(false, cx); } }); cx.background_executor().timer(CHECK_EVERY).await; } }); // An update downloaded but not restarted into goes in on the way out, // so the next start is the new version. let quit = cx.on_app_quit(|cx| { if let Stage::Ready(release) = &cx.global::().stage && let Some(target) = target() && let Err(err) = install(&target, &release.version) { eprintln!("vectrum-code: couldn't install the update: {err}"); } async {} }); cx.set_global(Updater { _schedule: Some(schedule), _quit: Some(quit), ..Default::default() }); } pub fn global(cx: &App) -> &Updater { cx.global::() } /// Takes what a check or download the user started has to say. pub fn take_notice(cx: &mut App) -> Option<(String, bool)> { // Only touched when there's one, since touching it tells the watchers. cx.global::().notice.as_ref()?; cx.global_mut::().notice.take() } /// Whether the app can replace itself: it runs as an AppImage. pub fn can_install() -> bool { target().is_some() } /// Looks for a newer version. A check the user asked for (`manual`) /// says how it went; an automatic one leaves a download or a staged /// update be. pub fn check(manual: bool, cx: &mut App) { let updater = cx.global::(); match &updater.stage { Stage::Checking | Stage::Downloading { .. } => return, Stage::Ready(_) if !manual => return, _ => {} } let previous = updater.stage.clone(); Self::set_stage(Stage::Checking, cx); let task = cx.spawn(async move |cx: &mut AsyncApp| { let found = cx.background_spawn(async { latest(&server()) }).await; cx.update(|cx| { let updater = cx.global_mut::(); updater.stage = match found { // A staged update of the same version stays ready. Ok(Some(release)) => match previous { Stage::Ready(staged) if staged.version == release.version => Stage::Ready(staged), _ => Stage::Available(release), }, Ok(None) => { if manual { updater.notice = Some((format!("Vectrum Code {} is the latest version.", current_version()), false)); } Stage::Idle } Err(error) => { if manual { updater.notice = Some((format!("Couldn't check for updates: {error}"), true)); } if manual || updater.restart_when_ready { Stage::Failed { release: None, error } } else { // A missed automatic check is no news. match previous { Stage::Checking => Stage::Idle, previous => previous, } } } }; let restart = updater.restart_when_ready; if restart { Self::continue_remote_update(cx); } }); }); cx.global_mut::()._task = Some(task); } /// Downloads the available update and stages it beside the AppImage. pub fn download(cx: &mut App) { let Some(release) = cx.global::().stage.release().cloned() else { return }; if matches!(cx.global::().stage, Stage::Downloading { .. } | Stage::Ready(_)) { return; } let Some(target) = target() else { crate::ui::workspace::open_url(&release.page); return; }; let (progress_tx, progress) = smol::channel::unbounded::<(u64, u64)>(); let (done_tx, done) = smol::channel::bounded::>(1); { let fetching = release.clone(); let spawned = std::thread::Builder::new().name("app-update".into()).spawn(move || { let result = fetch_and_stage(&fetching, &target, |done, total| { let _ = progress_tx.try_send((done, total)); }); let _ = done_tx.send_blocking(result); }); if let Err(err) = spawned { Self::set_stage(Stage::Failed { release: Some(release), error: err.to_string() }, cx); return; } } Self::set_stage(Stage::Downloading { release: release.clone(), done: 0, total: 0 }, cx); let task = cx.spawn(async move |cx: &mut AsyncApp| { loop { let next = smol::future::or(async { Err(done.recv().await) }, async { Ok(progress.recv().await) }).await; match next { Ok(Ok((done, total))) => cx.update(|cx| { // Told on whole percents, not on every packet. let percent = |done: u64, total: u64| (done * 100).checked_div(total).unwrap_or(0); if let Stage::Downloading { done: was, total: of, .. } = &cx.global::().stage && percent(done, total) != percent(*was, *of) && let Stage::Downloading { done: was, total: of, .. } = &mut cx.global_mut::().stage { (*was, *of) = (done, total); } }), Ok(Err(_)) => {} Err(result) => { let result = result.unwrap_or_else(|_| Err("The download stopped.".into())); cx.update(|cx| { let updater = cx.global_mut::(); let restart = updater.restart_when_ready; match result { Ok(()) => { updater.stage = Stage::Ready(release); if restart { Self::restart(cx); } } Err(error) => { updater.notice = Some((format!("Couldn't download the update: {error}"), true)); updater.stage = Stage::Failed { release: Some(release), error }; updater.restart_when_ready = false; } } }); break; } } } }); cx.global_mut::()._task = Some(task); } /// Moves the staged update over the AppImage and starts it in this /// one's place. pub fn restart(cx: &mut App) { let Stage::Ready(release) = &cx.global::().stage else { return }; let Some(target) = target() else { return }; if let Err(error) = install(&target, &release.version) { let release = release.clone(); let updater = cx.global_mut::(); updater.notice = Some((format!("Couldn't install the update: {error}"), true)); updater.stage = Stage::Failed { release: Some(release), error }; updater.restart_when_ready = false; return; } cx.global_mut::().stage = Stage::Idle; cx.set_restart_path(target); cx.restart(); } /// Updates to the newest version and restarts into it, all in one, as a /// client asks of the host it's connected to. Returns why it can't. pub fn update_for_remote(cx: &mut App) -> Result<(), String> { if !Self::can_install() { return Err("This device's app isn't an AppImage, so it can't update itself.".into()); } cx.global_mut::().restart_when_ready = true; match cx.global::().stage { Stage::Ready(_) => Self::restart(cx), Stage::Checking | Stage::Downloading { .. } => {} _ => Self::check(false, cx), } Ok(()) } /// The next step of an update a client asked for, after a check. fn continue_remote_update(cx: &mut App) { match &cx.global::().stage { Stage::Available(_) | Stage::Failed { release: Some(_), .. } => Self::download(cx), Stage::Ready(_) => Self::restart(cx), _ => cx.global_mut::().restart_when_ready = false, } } /// Whether an update a client asked for is on its way. pub fn remote_update_running(cx: &App) -> bool { cx.global::().restart_when_ready } fn set_stage(stage: Stage, cx: &mut App) { cx.global_mut::().stage = stage; } } fn server() -> String { std::env::var("VECTRUM_UPDATE_URL") .ok() .filter(|url| !url.is_empty()) .unwrap_or_else(|| SERVER.to_string()) .trim_end_matches('/') .to_string() } /// The AppImage this app runs from, which an update replaces. Programs /// started from an AppImage inherit its `APPIMAGE`, so it only counts when /// this program runs from inside the AppImage's mount (`APPDIR`). fn target() -> Option { let mount = PathBuf::from(std::env::var_os("APPDIR")?).canonicalize().ok()?; let exe = std::env::current_exe().ok()?.canonicalize().ok()?; if mount.parent().is_none() || !exe.starts_with(&mount) { return None; } let path = PathBuf::from(std::env::var_os("APPIMAGE")?).canonicalize().ok()?; path.is_file().then_some(path) } /// Where a download goes while it comes in, and where it waits for the /// restart: hidden files beside the AppImage, so the move is atomic. fn staged(target: &Path, version: &str) -> (PathBuf, PathBuf) { let name = target.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); let dir = target.parent().unwrap_or(Path::new("/")); (dir.join(format!(".{name}.{version}.part")), dir.join(format!(".{name}.{version}.update"))) } // ---- versions ----------------------------------------------------------------- /// `0.2.4` or `0.2.4-local`: the numbers, and whether it's a release (no /// suffix), which comes after its pre-releases. fn parse_version(version: &str) -> Option<(u64, u64, u64, bool)> { let (numbers, suffix) = match version.find(['-', '+']) { Some(at) => (&version[..at], Some(&version[at + 1..])), None => (version, None), }; let mut parts = numbers.split('.').map(|part| part.parse::().ok()); let version = (parts.next()??, parts.next()??, parts.next()??, suffix.is_none()); parts.next().is_none().then_some(version) } /// Whether `candidate` is a later version than `current`. pub fn is_newer(candidate: &str, current: &str) -> bool { match (parse_version(candidate), parse_version(current)) { (Some(candidate), Some(current)) => candidate > current, _ => false, } } // ---- the server --------------------------------------------------------------- #[derive(Deserialize)] struct Releases { releases: Vec, } #[derive(Deserialize)] struct ApiRelease { tag: String, #[serde(default)] draft: bool, #[serde(default)] prerelease: bool, #[serde(default)] assets: Vec, } #[derive(Deserialize)] struct ApiAsset { name: String, } fn appimage_name(version: &str) -> String { format!("Vectrum_Code-{version}-{}.AppImage", std::env::consts::ARCH) } fn agent() -> ureq::Agent { ureq::AgentBuilder::new() .timeout_connect(Duration::from_secs(15)) .timeout_read(Duration::from_secs(30)) .user_agent(concat!("vectrum-code/", env!("CARGO_PKG_VERSION"))) .https_only(!server().starts_with("http://127.0.0.1") && !server().starts_with("http://localhost")) .build() } /// GETs a small file whole. fn get_bytes(url: &str, limit: u64) -> Result, String> { let response = agent().get(url).call().map_err(|err| short_error(&err))?; let mut body = Vec::new(); response.into_reader().take(limit + 1).read_to_end(&mut body).map_err(|err| err.to_string())?; if body.len() as u64 > limit { return Err(format!("{url} is larger than expected")); } Ok(body) } fn short_error(err: &ureq::Error) -> String { match err { ureq::Error::Status(code, _) => format!("the server answered HTTP {code}"), ureq::Error::Transport(transport) => transport.to_string(), } } /// The newest release for this machine that's newer than this app, with its /// sums checked against the signature. fn latest(server: &str) -> Result, String> { let body = get_bytes(&format!("{server}/api/v1/repos/{REPO}/releases"), 4 * 1024 * 1024)?; let releases: Releases = serde_json::from_slice(&body).map_err(|err| format!("the server's release list is unreadable: {err}"))?; let has = |release: &ApiRelease, name: &str| release.assets.iter().any(|asset| asset.name == name); let Some(newest) = releases .releases .iter() .filter(|release| !release.draft && !release.prerelease && parse_version(&release.tag).is_some()) .filter(|release| { has(release, &appimage_name(&release.tag)) && has(release, "SHA256SUMS") && has(release, "SHA256SUMS.sig") }) .max_by(|a, b| parse_version(&a.tag).cmp(&parse_version(&b.tag))) else { return Ok(None); }; if !is_newer(&newest.tag, current_version()) { return Ok(None); } let version = newest.tag.clone(); let download = |name: &str| format!("{server}/{REPO}/releases/download/{version}/{name}"); let sums = get_bytes(&download("SHA256SUMS"), 64 * 1024)?; let signature = get_bytes(&download("SHA256SUMS.sig"), 1024)?; verify_signature(&sums, &signature)?; let sums = parse_sums(&String::from_utf8_lossy(&sums)); let appimage_sha256 = sums .iter() .find(|(_, name)| *name == appimage_name(&version)) .map(|(hash, _)| hash.clone()) .ok_or_else(|| format!("the signed sums of {version} don't list its AppImage"))?; // The notes are only shown, but they come from the same signed list. let notes = match sums.iter().find(|(_, name)| name == "CHANGES.md") { Some((hash, _)) => get_bytes(&download("CHANGES.md"), 256 * 1024) .ok() .filter(|body| sha256(body) == *hash) .map(|body| parse_notes(&String::from_utf8_lossy(&body))) .unwrap_or_default(), None => Vec::new(), }; Ok(Some(Release { page: format!("{server}/{REPO}/releases/tag/{version}"), appimage_url: download(&appimage_name(&version)), version, notes, sha256: appimage_sha256, })) } /// Checks `sums` against `signature` (64 bytes, or their base64) with each /// release key. fn verify_signature(sums: &[u8], signature: &[u8]) -> Result<(), String> { verify_with(RELEASE_KEYS, sums, signature) } fn verify_with(keys: &[&str], sums: &[u8], signature: &[u8]) -> Result<(), String> { use ring::signature::{ED25519, UnparsedPublicKey}; let signature = if signature.len() == 64 { signature.to_vec() } else { BASE64.decode(String::from_utf8_lossy(signature).trim()).map_err(|_| "the release's signature is unreadable".to_string())? }; let signed = keys.iter().filter_map(|key| BASE64.decode(key).ok()).any(|key| { UnparsedPublicKey::new(&ED25519, key).verify(sums, &signature).is_ok() }); if signed { Ok(()) } else { Err("the release isn't signed with Vectrum Code's release key".into()) } } /// `sha256sum` output: the hash and the file name on each line. fn parse_sums(text: &str) -> Vec<(Vec, String)> { text.lines() .filter_map(|line| { let (hash, name) = line.split_once(char::is_whitespace)?; let name = name.trim_start().trim_start_matches('*'); let hash = decode_hex(hash)?; (hash.len() == 32 && !name.is_empty()).then(|| (hash, name.to_string())) }) .collect() } /// The release notes' list items, without their bullets. fn parse_notes(text: &str) -> Vec { text.lines() .filter_map(|line| line.trim().strip_prefix("- ").or_else(|| line.trim().strip_prefix("* "))) .map(|line| line.trim().to_string()) .filter(|line| !line.is_empty()) .collect() } fn decode_hex(text: &str) -> Option> { if !text.len().is_multiple_of(2) { return None; } (0..text.len()).step_by(2).map(|at| u8::from_str_radix(text.get(at..at + 2)?, 16).ok()).collect() } fn sha256(bytes: &[u8]) -> Vec { ring::digest::digest(&ring::digest::SHA256, bytes).as_ref().to_vec() } // ---- downloading and installing ------------------------------------------------- /// Downloads the release's AppImage beside `target`, checks it, and leaves /// it staged for [`install`]. fn fetch_and_stage( release: &Release, target: &Path, progress: impl Fn(u64, u64), ) -> Result<(), String> { use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; let (part, ready) = staged(target, &release.version); forget_staged(target); let response = agent().get(&release.appimage_url).call().map_err(|err| short_error(&err))?; let total = response.header("Content-Length").and_then(|length| length.parse::().ok()).unwrap_or(0); if total > MAX_SIZE { return Err("the download is larger than an update can be".into()); } // Never write through whatever already sits at that name. let _ = fs::remove_file(&part); let mut file = fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&part) .map_err(|err| format!("can't write beside {}: {err}", target.display()))?; let result = (|| { let mut reader = response.into_reader().take(MAX_SIZE + 1); let mut hash = ring::digest::Context::new(&ring::digest::SHA256); let mut buffer = vec![0; 64 * 1024]; let mut done = 0u64; let mut head: Vec = Vec::with_capacity(16); loop { let read = reader.read(&mut buffer).map_err(|err| err.to_string())?; if read == 0 { break; } let chunk = &buffer[..read]; if head.len() < 16 { head.extend(chunk.iter().take(16 - head.len())); } hash.update(chunk); file.write_all(chunk).map_err(|err| err.to_string())?; done += read as u64; if done > MAX_SIZE { return Err("the download is larger than an update can be".into()); } progress(done, total.max(done)); } if total != 0 && done != total { return Err("the download ended early".into()); } if hash.finish().as_ref() != release.sha256.as_slice() { return Err("the download doesn't match the release's signed checksum".into()); } // An ELF file with the AppImage type 2 mark. if head.len() < 11 || &head[..4] != b"\x7fELF" || &head[8..11] != b"AI\x02" { return Err("the download isn't an AppImage".into()); } file.sync_all().map_err(|err| err.to_string())?; fs::set_permissions(&part, fs::Permissions::from_mode(0o755)).map_err(|err| err.to_string())?; fs::rename(&part, &ready).map_err(|err| err.to_string()) })(); if result.is_err() { let _ = fs::remove_file(&part); } result } /// Removes updates staged for `target` earlier, as when a newer one came /// out before the restart. fn forget_staged(target: &Path) { let (Some(dir), Some(name)) = (target.parent(), target.file_name()) else { return }; let prefix = format!(".{}.", name.to_string_lossy()); for entry in fs::read_dir(dir).into_iter().flatten().flatten() { let file = entry.file_name().to_string_lossy().into_owned(); if file.starts_with(&prefix) && (file.ends_with(".update") || file.ends_with(".part")) { let _ = fs::remove_file(entry.path()); } } } /// Moves a staged update over the AppImage. The running app keeps its own /// copy open, so this is safe while it runs. fn install(target: &Path, version: &str) -> Result<(), String> { let (_, ready) = staged(target, version); if !ready.is_file() { return Err("the downloaded update is gone; download it again".into()); } fs::rename(&ready, target).map_err(|err| format!("can't replace {}: {err}", target.display())) } #[cfg(test)] mod tests { // Not a glob: GPUI exports its own `test` attribute. use super::{BASE64, RELEASE_KEYS, is_newer, parse_notes, parse_sums, verify_with}; use base64::Engine as _; #[test] fn versions() { assert!(is_newer("0.2.5", "0.2.4")); assert!(is_newer("0.10.0", "0.9.9")); assert!(is_newer("0.2.4", "0.2.4-local")); assert!(!is_newer("0.2.4", "0.2.4")); assert!(!is_newer("0.2.3", "0.2.4")); assert!(!is_newer("0.2.4-local", "0.2.4")); assert!(!is_newer("latest", "0.2.4")); assert!(!is_newer("0.2.4.1", "0.2.4")); } #[test] fn sums_and_notes() { let sums = parse_sums( "94fcdba3843199f5abc70194e39868edff9a083c61b83e1cc931adcd72047a4e Vectrum_Code-0.2.4-x86_64.AppImage\n\ 3f146a7859fa09896e21f0f8612a54eeb0ab35fdd3d75e90b2a66e68e3245ad5 *vectrum-code-0.2.4-x86_64\n\ nothex CHANGES.md\n", ); assert_eq!(sums.len(), 2); assert_eq!(sums[0].1, "Vectrum_Code-0.2.4-x86_64.AppImage"); assert_eq!(sums[1].1, "vectrum-code-0.2.4-x86_64"); assert_eq!(sums[0].0[0], 0x94); assert_eq!(parse_notes("# 0.2.5\n\n- One\n* Two\nplain\n- \n"), vec!["One", "Two"]); } #[test] fn signatures() { use ring::signature::{Ed25519KeyPair, KeyPair as _}; let rng = ring::rand::SystemRandom::new(); let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); let key = BASE64.encode(pair.public_key().as_ref()); let sums = b"abc file\n"; let signature = pair.sign(sums); assert!(verify_with(&[&key], sums, signature.as_ref()).is_ok()); assert!(verify_with(&[&key], sums, BASE64.encode(signature.as_ref()).as_bytes()).is_ok()); assert!(verify_with(&[&key], b"abd file\n", signature.as_ref()).is_err()); assert!(verify_with(RELEASE_KEYS, sums, signature.as_ref()).is_err()); } }