//! 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<String>,
appimage_url: String,
/// The AppImage's SHA-256, from the signed sums.
sha256: Vec<u8>,
}
#[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<Release>, 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<Task<()>>,
_schedule: Option<Task<()>>,
_quit: Option<Subscription>,
}
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::<Updater>().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::<Updater>()
}
/// 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::<Updater>().notice.as_ref()?;
cx.global_mut::<Updater>().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::<Updater>();
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>();
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::<Updater>()._task = Some(task);
}
/// Downloads the available update and stages it beside the AppImage.
pub fn download(cx: &mut App) {
let Some(release) = cx.global::<Updater>().stage.release().cloned() else { return };
if matches!(cx.global::<Updater>().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::<Result<(), String>>(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::<Updater>().stage
&& percent(done, total) != percent(*was, *of)
&& let Stage::Downloading { done: was, total: of, .. } = &mut cx.global_mut::<Updater>().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::<Updater>();
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::<Updater>()._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::<Updater>().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>();
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::<Updater>().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::<Updater>().restart_when_ready = true;
match cx.global::<Updater>().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::<Updater>().stage {
Stage::Available(_) | Stage::Failed { release: Some(_), .. } => Self::download(cx),
Stage::Ready(_) => Self::restart(cx),
_ => cx.global_mut::<Updater>().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::<Updater>().restart_when_ready
}
fn set_stage(stage: Stage, cx: &mut App) {
cx.global_mut::<Updater>().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<PathBuf> {
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::<u64>().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<ApiRelease>,
}
#[derive(Deserialize)]
struct ApiRelease {
tag: String,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
#[serde(default)]
assets: Vec<ApiAsset>,
}
#[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<Vec<u8>, 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<Option<Release>, 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<u8>, 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<String> {
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<Vec<u8>> {
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<u8> {
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::<u64>().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<u8> = 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());
}
}