Vectrum
Sign In

Merge branch 'vectrum/appimage-autoupdate-client-server'

JjohannSocommitted 2 parents b2c6102 03c6875commit0420e33

Showing 17 changed files with 1133 additions and 15 deletions.

Merge commit — showing changes against the first parent.

+1−0Cargo.lockView File
@@ -7489,6 +7489,7 @@ dependencies = [
74897489 "mermaid-rs-renderer",
74907490 "openh264",
74917491 "qrcode",
7492+ "ring",
74927493 "serde",
74937494 "serde_json",
74947495 "smol",
+1−0Cargo.tomlView File
@@ -20,6 +20,7 @@ alacritty_terminal = "0.26"
2020 openh264 = "0.9"
2121 mermaid-rs-renderer = { version = "0.3.1", default-features = false }
2222 qrcode = { version = "0.14.1", default-features = false }
23+ ring = "0.17"
2324
2425 [profile.dev]
2526 debug = "line-tables-only"
+18−1README.mdView File
@@ -214,6 +214,15 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
214214 with settings that differ from yours, a dialog asks whether to give it your settings or
215215 leave its own as they are. The connection is a WebSocket guarded by the key but not
216216 encrypted, so use it on trusted networks or over a VPN.
217+ - Updates from the releases on [vectrum.cc](https://vectrum.cc/vectrum/code/releases), as in
218+ T3 Code: the button in the sidebar's corner checks for a new version (every few hours on its
219+ own, unless Settings → General → **Automatic update checks** is off), then downloads it, then
220+ restarts into it, and only when you click; hovering it lists what changed. The AppImage
221+ replaces itself, and only with a download whose checksum is in the release's `SHA256SUMS`
222+ and whose sums carry the release key's Ed25519 signature. Run elsewhere, the button opens
223+ the release page instead. With this app up to date, the same button shows **Update N
224+ servers** for connected devices on an older version: each downloads and checks the release
225+ itself, replaces its AppImage and restarts, and the connection comes back on the new version.
217226 - An Android companion app ([`android/`](android/README.md)), in Kotlin and Jetpack Compose:
218227 a remote client that looks like the desktop, in the same themes. Pair it by scanning the QR
219228 code under Settings → Connections; the sidebar, threads, composer and its pickers,
@@ -343,6 +352,8 @@ The shortcuts follow T3 Code's default keymap.
343352 `~/.local/bin`, `~/.npm-global/bin`, `~/.bun/bin`, `~/.volta/bin` and nvm's Node versions.
344353 - `VECTRUM_CODE_JCODE_BIN`: path to the `jcode` binary. Otherwise it's looked up on `PATH`,
345354 then in `~/.local/bin` and `~/.cargo/bin`.
355+ - `VECTRUM_UPDATE_URL`: where to look for updates instead of `https://vectrum.cc`, such as a
356+ test server. Releases from there still need the release key's signature.
346357
347358 Favorite models and everything in the settings view are saved in
348359 `~/.config/vectrum-code/settings.json`. The environment variables above win over the binary
@@ -359,7 +370,11 @@ the image, target dir and cargo cache are kept in `target/ubuntu-24.04`. `--nati
359370 the host toolchain instead, for the host's glibc or newer. `-v 0.1.2` first sets the
360371 version (in `Cargo.toml` and `Cargo.lock`, committed as "Version 0.1.2"), and `--release` (or
361372 `-r`) publishes the build on Vectrum: it tags the commit with the version, pushes the tag, and
362- uploads the AppImage, the binary and `SHA256SUMS`, which publishes the release. So
373+ uploads the AppImage, the binary, `CHANGES.md` (the commit subjects since the last release,
374+ which the update button lists), `SHA256SUMS` and `SHA256SUMS.sig`, which publishes the
375+ release. The signature is made with the Ed25519 key in `VECTRUM_SIGNING_KEY` or
376+ `~/.config/vectrum/release-signing-key.pem`, whose public half must be in `RELEASE_KEYS` in
377+ `src/update.rs`; installed apps only update to releases it signed, so keep it safe. So
363378 `./build.sh -v 0.1.2 -r` makes and publishes 0.1.2. Releasing needs a clean working tree, a
364379 version not tagged yet, and a read & write API token in `VECTRUM_TOKEN` or
365380 `~/.config/vectrum/token`; the version commit stays on your branch until you push it.
@@ -385,6 +400,8 @@ after changing them, `cargo test export_android_themes -- --ignored` rewrites
385400 - `src/models.rs`: the model catalog across providers, and favorites.
386401 - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs.
387402 - `src/settings.rs`: the saved settings file, cached in memory.
403+ - `src/update.rs`: the app's updater: release checks, signature and checksum checks, the
404+ staged download beside the AppImage, and the restart.
388405 - `src/terminal.rs`: a shell in a pseudo-terminal, emulated by `alacritty_terminal`, and the
389406 bytes keys and pastes send to it.
390407 - `src/ui/terminal.rs`: one terminal's view: the grid in the mono font, cursor, selection,
+38−5build.shView File
@@ -20,9 +20,12 @@
2020 # and Cargo.lock and commits them as "Version X"
2121 # -r, --release then publish the build as a release on Vectrum: tag the
2222 # commit with the version, push the tag, and upload the
23- # AppImage, the binary (and the APK with -a) and SHA256SUMS
24- # (the first upload publishes the release). Each release
25- # needs a new version.
23+ # AppImage, the binary (and the APK with -a), CHANGES.md
24+ # (the commits since the last release), SHA256SUMS and
25+ # its signature SHA256SUMS.sig (the first upload
26+ # publishes the release). Each release needs a new
27+ # version. The app only installs updates whose sums are
28+ # signed with the release key.
2629 # -a, --android also build the Android app (android/) as a release APK.
2730 # It has the desktop version as its versionName, and
2831 # versionCode major*1000000 + minor*1000 + patch (0.1.9 →
@@ -38,6 +41,14 @@
3841 # ~/.config/vectrum/token (or $VECTRUM_TOKEN_FILE)
3942 # VECTRUM_URL the server, https://vectrum.cc by default
4043 # VECTRUM_REPO owner/repo, taken from the origin remote by default
44+ # VECTRUM_SIGNING_KEY the Ed25519 release key (PEM) whose public half is in
45+ # src/update.rs; ~/.config/vectrum/release-signing-key.pem
46+ # by default. Make one with
47+ # openssl genpkey -algorithm ed25519 -out <file>
48+ # and put the output of
49+ # openssl pkey -in <file> -pubout -outform DER | tail -c 32 | base64
50+ # in RELEASE_KEYS. Keep it safe: installed apps only
51+ # update to releases it signed.
4152 set -euo pipefail
4253
4354 log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
@@ -122,6 +133,13 @@ if (( RELEASE )); then
122133 # ssh://git@git.vectrum.cc:2222/vectrum/code.git, https://…/vectrum/code → vectrum/code
123134 REPO="${VECTRUM_REPO:-$(git -C "$ROOT" remote get-url origin | sed -E 's#\.git$##; s#^.*[:/]([^/:]+/[^/:]+)$#\1#')}"
124135 [[ "$REPO" == */* ]] || die "Couldn't tell the repository from the origin remote; set VECTRUM_REPO=owner/repo"
136+
137+ SIGNING_KEY="${VECTRUM_SIGNING_KEY:-$HOME/.config/vectrum/release-signing-key.pem}"
138+ [[ -r "$SIGNING_KEY" ]] || die "No release signing key at $SIGNING_KEY: set VECTRUM_SIGNING_KEY (see --help)"
139+ command -v openssl >/dev/null || die "Signing the release needs openssl"
140+ PUBLIC_KEY="$(openssl pkey -in "$SIGNING_KEY" -pubout -outform DER | tail -c 32 | base64)"
141+ grep -qF "\"$PUBLIC_KEY\"" "$ROOT/src/update.rs" ||
142+ die "$SIGNING_KEY isn't a release key the app knows: add $PUBLIC_KEY to RELEASE_KEYS in src/update.rs"
125143 fi
126144
127145 if [[ -n "$NEW_VERSION" && "$NEW_VERSION" != "$VERSION" ]]; then
@@ -278,7 +296,21 @@ if (( RELEASE )); then
278296 cp "$APK" "$STAGE/"
279297 UPLOADS+=("$(basename "$APK")")
280298 fi
299+
300+ # What changed since the last release, from the commit subjects.
301+ PREVIOUS="$(git -C "$ROOT" tag --merged "$HEAD" --sort=-v:refname | grep -vxF "$TAG" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
302+ {
303+ printf '# %s %s\n\n' "$PRETTY" "$VERSION"
304+ git -C "$ROOT" log --no-merges --format='- %s' "${PREVIOUS:+$PREVIOUS..}$HEAD" | grep -vE '^- Version [0-9]' || true
305+ } > "$STAGE/CHANGES.md"
306+ UPLOADS+=(CHANGES.md)
307+
281308 (cd "$STAGE" && sha256sum -- * > SHA256SUMS)
309+ log "Signing SHA256SUMS"
310+ openssl pkeyutl -sign -inkey "$SIGNING_KEY" -rawin -in "$STAGE/SHA256SUMS" -out "$STAGE/SHA256SUMS.sig"
311+ openssl pkeyutl -verify -pubin -inkey <(openssl pkey -in "$SIGNING_KEY" -pubout) -rawin \
312+ -in "$STAGE/SHA256SUMS" -sigfile "$STAGE/SHA256SUMS.sig" >/dev/null ||
313+ die "The signature on SHA256SUMS doesn't verify"
282314
283315 if [[ -z "$LOCAL_TAG" ]]; then
284316 git -C "$ROOT" tag -a "$TAG" -m "$PRETTY $VERSION" "$HEAD"
@@ -288,8 +320,9 @@ if (( RELEASE )); then
288320 git -C "$ROOT" push -q origin "refs/tags/$TAG"
289321 fi
290322
291- # The checksums go last, so the release never shows them alone.
292- for name in "${UPLOADS[@]}" SHA256SUMS; do
323+ # The checksums go last, after their signature, so the app never sees
324+ # a release with only part of it.
325+ for name in "${UPLOADS[@]}" SHA256SUMS.sig SHA256SUMS; do
293326 file="$STAGE/$name"
294327 log "Uploading $name"
295328 # The token goes in through a file descriptor, so it never shows in ps.
+2−0src/main.rsView File
@@ -20,6 +20,7 @@ mod store;
2020 mod terminal;
2121 mod textgen;
2222 mod ui;
23+ mod update;
2324 mod usage;
2425 mod worktree;
2526
@@ -111,6 +112,7 @@ fn main() {
111112 models::Catalog::init(cx);
112113 ui::browser::BrowserWatch::init(cx);
113114 ui::device::DeviceWatch::init(cx);
115+ update::Updater::init(cx);
114116
115117 cx.bind_keys([
116118 // T3 Code's default keymap, for the commands the app has.
+12−2src/remote/client.rsView File
@@ -225,12 +225,22 @@ struct Link {
225225 route: Route,
226226 /// Where the host says it listens.
227227 routes: Vec<Route>,
228+ /// The host's app version, and whether it can update itself.
229+ version: String,
230+ updatable: bool,
228231 }
229232
230233 /// Tells the workspace about a new connection.
231234 fn announce(events: &smol::channel::Sender<ClientEvent>, link: &Link) {
232235 let _ = events.send_blocking(ClientEvent::Route(link.route.clone()));
233236 let _ = events.send_blocking(ClientEvent::Message(ServerMessage::Routes { routes: link.routes.clone() }));
237+ let _ = events.send_blocking(ClientEvent::Message(ServerMessage::Welcome {
238+ host: link.host.clone(),
239+ version: link.version.clone(),
240+ protocol: PROTOCOL,
241+ routes: link.routes.clone(),
242+ updatable: link.updatable,
243+ }));
234244 let _ = events.send_blocking(ClientEvent::Status(ConnectionStatus::Connected(link.host.clone())));
235245 }
236246
@@ -302,9 +312,9 @@ fn open(route: &Route, hello: &Hello) -> Result<Link, Failure> {
302312 match socket.read().map_err(|err| other(&err))? {
303313 Message::Text(text) => {
304314 return match serde_json::from_str::<ServerMessage>(text.as_str()) {
305- Ok(ServerMessage::Welcome { host, routes, .. }) => {
315+ Ok(ServerMessage::Welcome { host, routes, version, updatable, .. }) => {
306316 let _ = socket.get_mut().set_read_timeout(Some(POLL));
307- Ok(Link { socket, host, route: route.clone(), routes })
317+ Ok(Link { socket, host, route: route.clone(), routes, version, updatable })
308318 }
309319 Ok(ServerMessage::Denied { reason }) => Err(Failure::Denied(reason)),
310320 _ => Err(other(&"the host answered something unexpected")),
+33−0src/remote/mod.rsView File
@@ -30,6 +30,11 @@
3030 //! again in [`ServerMessage::Routes`] when they change; the client saves
3131 //! them with the connection, tries them fastest first, and moves back to a
3232 //! faster one as soon as it answers.
33+ //!
34+ //! The welcome also says the host's version and whether it can update
35+ //! itself. A client that's newer asks it to with [`ClientMessage::Update`]:
36+ //! the host downloads the same signed release the client's updater would,
37+ //! reports how it goes in [`ServerMessage::UpdateStatus`], and restarts.
3338
3439 pub mod client;
3540 pub mod device_proxy;
@@ -242,6 +247,10 @@ pub enum ClientMessage {
242247 /// `base` and `head`), for one file or all of them. Older hosts don't
243248 /// answer.
244249 Diff { request: u64, thread: String, base: String, head: String, path: Option<String>, ignore_whitespace: bool },
250+ /// Updates the host's app to the newest release and restarts it, all in
251+ /// one; it answers with [`ServerMessage::UpdateStatus`] until it goes
252+ /// down. Only hosts that say they're `updatable` take it.
253+ Update,
245254 }
246255
247256 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -254,6 +263,11 @@ pub enum ServerMessage {
254263 /// Where else the host listens. Older hosts don't say.
255264 #[serde(default)]
256265 routes: Vec<Route>,
266+ /// Whether the host can update itself when asked with
267+ /// [`ClientMessage::Update`]: it runs as an AppImage. Older hosts
268+ /// can't.
269+ #[serde(default)]
270+ updatable: bool,
257271 },
258272 Denied { reason: String },
259273 State { projects: Vec<ProjectInfo>, threads: Vec<ThreadSummary> },
@@ -290,6 +304,10 @@ pub enum ServerMessage {
290304 /// The answer to a [`ClientMessage::Diff`]: the changed files, or what
291305 /// went wrong.
292306 DiffReply { request: u64, files: Vec<RemoteFileDiff>, error: Option<String> },
307+ /// How an update a client asked for goes: `checking`, `downloading`
308+ /// (with `progress`, 0 to 1), `restarting`, or `failed` with `error`.
309+ /// The connection drops when the host restarts.
310+ UpdateStatus { state: String, progress: Option<f32>, error: Option<String> },
293311 }
294312
295313 /// A provider instance on the host and what its last health check found.
@@ -995,6 +1013,15 @@ mod tests {
9951013 _ => panic!("not settings"),
9961014 }
9971015
1016+ let text = serde_json::to_string(&ClientMessage::Update).unwrap();
1017+ assert_eq!(text, r#"{"type":"update"}"#);
1018+ // An older host's welcome says nothing of updating.
1019+ let welcome = r#"{"type":"welcome","host":"h","version":"0.2.4","protocol":1}"#;
1020+ assert!(matches!(
1021+ serde_json::from_str::<ServerMessage>(welcome).unwrap(),
1022+ ServerMessage::Welcome { updatable: false, .. }
1023+ ));
1024+
9981025 let text = serde_json::to_string(&ClientMessage::Catalog).unwrap();
9991026 assert_eq!(text, r#"{"type":"catalog"}"#);
10001027 assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), ClientMessage::Catalog);
@@ -1083,6 +1110,12 @@ mod tests {
10831110 _ => panic!("didn't say which route"),
10841111 }
10851112 assert!(matches!(wait(&client.events), Some(ClientEvent::Message(ServerMessage::Routes { .. }))));
1113+ match wait(&client.events) {
1114+ Some(ClientEvent::Message(ServerMessage::Welcome { version, .. })) => {
1115+ assert_eq!(version, env!("CARGO_PKG_VERSION"))
1116+ }
1117+ _ => panic!("didn't pass on the welcome"),
1118+ }
10861119 match wait(&client.events) {
10871120
10881121 Some(ClientEvent::Status(ConnectionStatus::Connected(host))) => assert_eq!(host, "test-host"),
+7−2src/remote/server.rsView File
@@ -191,8 +191,13 @@ fn serve(
191191 return;
192192 }
193193 };
194- let welcome =
195- ServerMessage::Welcome { host: host.to_string(), version: env!("CARGO_PKG_VERSION").into(), protocol: PROTOCOL, routes };
194+ let welcome = ServerMessage::Welcome {
195+ host: host.to_string(),
196+ version: env!("CARGO_PKG_VERSION").into(),
197+ protocol: PROTOCOL,
198+ routes,
199+ updatable: crate::update::Updater::can_install(),
200+ };
196201 if socket.send(Message::text(serde_json::to_string(&welcome).unwrap_or_default())).is_err() {
197202 return;
198203 }
+6−0src/settings.rsView File
@@ -530,6 +530,10 @@ pub struct Settings {
530530 /// Ctrl+Q, Alt+F4 and the window's close button ask before closing.
531531 pub confirm_close: bool,
532532
533+ // ---- app updates ----
534+ /// Look for a new version of the app on vectrum.cc now and then.
535+ pub app_update_checks: bool,
536+
533537 // ---- notifications and background work ----
534538 /// A desktop notification when a thread finishes or needs you while the
535539 /// window is in the background.
@@ -663,6 +667,7 @@ impl Default for Settings {
663667 confirm_delete: true,
664668 quit_shortcut: QuitShortcut::Hold,
665669 confirm_close: true,
670+ app_update_checks: true,
666671 notify: false,
667672 notify_sound: false,
668673 in_app_notifications: false,
@@ -775,6 +780,7 @@ impl Settings {
775780 ("Delete confirmation", self.confirm_delete != d.confirm_delete),
776781 ("Quit shortcut", self.quit_shortcut != d.quit_shortcut),
777782 ("Close confirmation", self.confirm_close != d.confirm_close),
783+ ("Automatic update checks", self.app_update_checks != d.app_update_checks),
778784 ("Text generation model", (&self.text_model, &self.text_reasoning) != (&d.text_model, &d.text_reasoning)),
779785 (
780786 "Worktree branch naming",
+2−0src/ui/icons.rsView File
@@ -125,6 +125,8 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
125125 PictureInPicture2,
126126 Power,
127127 GripHorizontal,
128+ CircleArrowUp,
129+ RotateCw,
128130 ]);
129131
130132 /// Forge logos Lucide doesn't have, from Simple Icons (CC0).
+39−1src/ui/settings/general.rsView File
@@ -20,6 +20,7 @@ use crate::settings::{
2020 };
2121 use crate::ui::metal::{self, MetalButton};
2222 use crate::ui::{pickers, workspace};
23+ use crate::update::{Stage, Updater};
2324
2425 const WORKSPACES: &[(WorkspaceMode, &str)] =
2526 &[(WorkspaceMode::Local, "Current checkout"), (WorkspaceMode::Worktree, "New worktree")];
@@ -582,12 +583,49 @@ impl SettingsView {
582583 .render(cx);
583584
584585 // ---- About ----
586+ let (update_status, update_action) = match &Updater::global(cx).stage {
587+ Stage::Idle => ("Current version of the application.".to_string(), None),
588+ Stage::Checking => ("Checking for updates…".to_string(), None),
589+ Stage::Available(release) => (format!("Version {} is available.", release.version), Some("Download")),
590+ Stage::Downloading { release, done, total } => {
591+ let percent = if *total == 0 { 0 } else { done * 100 / total };
592+ (format!("Downloading version {}… {percent}%", release.version), None)
593+ }
594+ Stage::Ready(release) => (format!("Version {} is downloaded and installs on restart.", release.version), Some("Restart")),
595+ Stage::Failed { release, error } => (
596+ format!("Updating failed: {error}"),
597+ Some(if release.is_some() { "Try again" } else { "Check again" }),
598+ ),
599+ };
585600 let about = Section::new("About")
586601 .row(
587602 row("Version")
588603 .id("version")
589604 .title_suffix(code(env!("CARGO_PKG_VERSION"), p))
590- .description("Current version of the application."),
605+ .description(update_status)
606+ .control(match update_action {
607+ Some(label) => MetalButton::new("app-update-action").compact().label(label).on_click(|_, _, cx| {
608+ match &Updater::global(cx).stage {
609+ Stage::Ready(_) => Updater::restart(cx),
610+ Stage::Failed { release: None, .. } => Updater::check(true, cx),
611+ _ => Updater::download(cx),
612+ }
613+ }),
614+ None => MetalButton::new("check-for-updates")
615+ .compact()
616+ .label("Check for updates")
617+ .disabled(matches!(Updater::global(cx).stage, Stage::Checking | Stage::Downloading { .. }))
618+ .on_click(|_, _, cx| Updater::check(true, cx)),
619+ }),
620+ )
621+ .row(
622+ row("Automatic update checks")
623+ .id("automatic-update-checks")
624+ .description("Look for a new version on vectrum.cc every few hours. Updates download only when you say so.")
625+ .reset(reset(&view, cx, |s| s.app_update_checks, |s, on| s.app_update_checks = on))
626+ .control(switch("app-update-checks", saved.app_update_checks, &view, p, |settings, on| {
627+ settings.app_update_checks = on
628+ })),
591629 )
592630 .render(cx);
593631
+2−0src/ui/settings/mod.rsView File
@@ -178,6 +178,8 @@ impl SettingsView {
178178 });
179179
180180 let mut subscriptions = vec![
181+ // About's update status.
182+ cx.observe_global::<crate::update::Updater>(|_, cx| cx.notify()),
181183 cx.subscribe_in(&search, window, |this, _, event: &InputEvent, window, cx| match event {
182184 InputEvent::Change => {
183185 this.active_result = 0;
+2−1src/ui/settings/search.rsView File
@@ -105,7 +105,8 @@ pub const ITEMS: &[Item] = &[
105105 item("delete-confirmation", "Delete confirmation", Page::General, "ask before thread chat history"),
106106 item("quit-confirmation", "Quit shortcut", Page::General, "confirmation exit direct hold double press twice ctrl q"),
107107 item("text-generation-model", "Text generation model", Page::General, "generated thread titles default provider haiku"),
108- item("version", "Version", Page::General, "about release update vectrum code"),
108+ item("version", "Version", Page::General, "about release update upgrade check download restart vectrum code"),
109+ item("automatic-update-checks", "Automatic update checks", Page::General, "app update upgrade new version release vectrum.cc background"),
109110 item("diagnostics", "Diagnostics", Page::General, "logs processes resource cpu memory kill"),
110111 item("open-source-licenses", "Open source licenses", Page::General, "notices dependencies fonts geist crates ofl mit apache"),
111112 folded("legacy-plan-mode", "Plan mode (legacy)", Page::General, "build plan composer old shift tab", "legacy"),
+11−0src/ui/workspace.rsView File
@@ -31,6 +31,7 @@ use crate::git::{self, GitInfo};
3131 use crate::models::{Catalog, ModelEntry, Provider, split_key};
3232 use crate::session::{NewSession, Reasoning, Session, from_millis, to_millis};
3333 use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode};
34+ use crate::update::Updater;
3435 use crate::store::{self, AppState, SavedProject};
3536 use crate::ui::browser::{BrowserPanel, BrowserPanelEvent};
3637 use crate::ui::chat::{ChatEvent, ChatView, display_path};
@@ -367,6 +368,16 @@ impl Workspace {
367368 device::service().stop_all();
368369 async {}
369370 }),
371+ // The updater's button, its notices, and clients waiting on an
372+ // update they asked for.
373+ cx.observe_global_in::<Updater>(window, |this, window, cx| {
374+ if let Some((notice, error)) = Updater::take_notice(cx) {
375+ let notice = if error { Notification::error(notice) } else { Notification::info(notice) };
376+ window.push_notification(notice, cx);
377+ }
378+ this.relay_update_status(cx);
379+ cx.notify();
380+ }),
370381 // The System theme mode follows the desktop's light/dark setting.
371382 cx.observe_window_appearance(window, |_, window, cx| metal::sync_appearance(window, cx)),
372383 cx.observe_window_activation(window, |this, window, cx| {
+140−2src/ui/workspace/remote.rsView File
@@ -8,7 +8,7 @@ use std::{
88 path::PathBuf,
99 rc::Rc,
1010 sync::atomic::{AtomicU64, Ordering},
11- time::{Duration, SystemTime},
11+ time::{Duration, Instant, SystemTime},
1212 };
1313
1414 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
@@ -35,6 +35,7 @@ use crate::terminal::RemoteRequest;
3535 use crate::ui::folder_browser;
3636 use crate::ui::metal::MetalButton;
3737 use crate::ui::terminal::TerminalView;
38+ use crate::update::{Stage, Updater, current_version, is_newer};
3839
3940 /// How often changed threads are sent to subscribed clients.
4041 pub(super) const FLUSH: Duration = Duration::from_millis(150);
@@ -48,9 +49,27 @@ pub(super) struct Environment {
4849 pub(super) client: Rc<RemoteClient>,
4950 /// Its environment settings, once it shared them.
5051 pub(super) settings: Option<Settings>,
52+ /// The app version it runs, and whether it can update itself, from its
53+ /// welcome.
54+ pub(super) version: Option<String>,
55+ pub(super) updatable: bool,
56+ /// How an update this device asked of it goes, until it's back.
57+ pub(super) updating: Option<RemoteUpdate>,
5158 _events: Task<()>,
5259 }
5360
61+ /// An update this device asked another to make: how far it got, as it last
62+ /// said, and when it was asked.
63+ pub(super) struct RemoteUpdate {
64+ pub(super) state: String,
65+ pub(super) progress: Option<f32>,
66+ pub(super) since: Instant,
67+ }
68+
69+ /// How long an update may keep a device away before the button stops
70+ /// waiting for it.
71+ const UPDATE_PATIENCE: Duration = Duration::from_secs(180);
72+
5473 /// One client's interest in a thread: the hashes of the transcript items it
5574 /// was last sent, or `None` before the first send.
5675 type Subscription = Option<Vec<u64>>;
@@ -69,6 +88,10 @@ pub(super) struct RemoteState {
6988 /// The environment settings clients were last sent.
7089 last_settings: String,
7190 pub(super) environments: Vec<Environment>,
91+ /// Clients that asked this device to update, told how it goes.
92+ update_requesters: Vec<u64>,
93+ /// What they were last told.
94+ last_update_status: Option<(String, Option<u8>, Option<String>)>,
7295 /// Shells clients run here, by connection and the client's id for them.
7396 shells: HashMap<(u64, u64), HostShell>,
7497 /// This device's terminals whose shells run on another device, by the
@@ -179,6 +202,9 @@ impl Workspace {
179202 route: None,
180203 client,
181204 settings: None,
205+ version: None,
206+ updatable: false,
207+ updating: None,
182208 _events: task,
183209 });
184210 }
@@ -450,6 +476,7 @@ impl Workspace {
450476 }
451477 ServerEvent::Disconnected { conn } => {
452478 self.forget_device_watcher(conn);
479+ self.remote.update_requesters.retain(|requester| *requester != conn);
453480 self.remote.peers.retain(|peer| peer.conn != conn);
454481 self.remote.subscriptions.remove(&conn);
455482 self.remote.shells.retain(|(owner, _), _| *owner != conn);
@@ -576,6 +603,18 @@ impl Workspace {
576603 ClientMessage::TerminalClose { terminal } => {
577604 self.remote.shells.remove(&(conn, terminal));
578605 }
606+ ClientMessage::Update => match Updater::update_for_remote(cx) {
607+ Ok(()) => {
608+ if !self.remote.update_requesters.contains(&conn) {
609+ self.remote.update_requesters.push(conn);
610+ }
611+ self.remote.last_update_status = None;
612+ self.relay_update_status(cx);
613+ }
614+ Err(error) => {
615+ self.reply(conn, ServerMessage::UpdateStatus { state: "failed".into(), progress: None, error: Some(error) })
616+ }
617+ },
579618 ClientMessage::Catalog => {
580619 let catalog = Catalog::global(cx);
581620 let instances = catalog.instances.iter().map(|state| RemoteInstance::from_state(state, catalog)).collect();
@@ -750,6 +789,76 @@ impl Workspace {
750789 }
751790 }
752791
792+ /// Tells the clients that asked this device to update how it goes.
793+ pub(super) fn relay_update_status(&mut self, cx: &mut Context<Self>) {
794+ if self.remote.update_requesters.is_empty() {
795+ return;
796+ }
797+ let running = Updater::remote_update_running(cx);
798+ let (state, progress, error) = match &Updater::global(cx).stage {
799+ Stage::Failed { error, .. } => ("failed", None, Some(error.clone())),
800+ Stage::Downloading { done, total, .. } => {
801+ ("downloading", Some(if *total == 0 { 0. } else { *done as f32 / *total as f32 }), None)
802+ }
803+ Stage::Ready(_) => ("restarting", None, None),
804+ _ if running => ("checking", None, None),
805+ _ => ("failed", None, Some("It already runs the newest release.".to_string())),
806+ };
807+ let key = (state.to_string(), progress.map(|progress| (progress * 100.) as u8), error.clone());
808+ if self.remote.last_update_status.as_ref() != Some(&key) {
809+ self.remote.last_update_status = Some(key);
810+ let message = ServerMessage::UpdateStatus { state: state.into(), progress, error };
811+ for conn in &self.remote.update_requesters {
812+ self.reply(*conn, message.clone());
813+ }
814+ }
815+ if !running {
816+ self.remote.update_requesters.clear();
817+ self.remote.last_update_status = None;
818+ }
819+ }
820+
821+ /// Connected devices on an older version that can update themselves.
822+ pub(super) fn outdated_environments(&self) -> Vec<&Environment> {
823+ self.remote
824+ .environments
825+ .iter()
826+ .filter(|environment| {
827+ matches!(environment.status, ConnectionStatus::Connected(_))
828+ && environment.updatable
829+ && environment.version.as_deref().is_some_and(|version| is_newer(current_version(), version))
830+ })
831+ .collect()
832+ }
833+
834+ /// Devices updating at this device's request, including while they
835+ /// restart, until they're back or have been gone too long.
836+ pub(super) fn updating_environments(&self) -> Vec<&Environment> {
837+ self.remote
838+ .environments
839+ .iter()
840+ .filter(|environment| environment.updating.as_ref().is_some_and(|update| update.since.elapsed() < UPDATE_PATIENCE))
841+ .collect()
842+ }
843+
844+ /// Asks every outdated device to update and restart.
845+ pub(super) fn update_environments(&mut self, cx: &mut Context<Self>) {
846+ let ids: Vec<String> = self.outdated_environments().iter().map(|env| env.host.id.clone()).collect();
847+ for id in ids {
848+ self.send_to_environment(&id, &ClientMessage::Update);
849+ if let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == id) {
850+ environment.updating = Some(RemoteUpdate { state: "checking".into(), progress: None, since: Instant::now() });
851+ }
852+ }
853+ cx.notify();
854+ // Gives up on a device that doesn't come back, so the button does too.
855+ cx.spawn(async move |this, cx| {
856+ cx.background_executor().timer(UPDATE_PATIENCE).await;
857+ let _ = this.update(cx, |_, cx| cx.notify());
858+ })
859+ .detach();
860+ }
861+
753862 // ---- connecting to other devices -------------------------------------------
754863
755864 fn on_client_event(&mut self, env: &str, event: ClientEvent, window: &mut Window, cx: &mut Context<Self>) {
@@ -845,7 +954,36 @@ impl Workspace {
845954 self.move_devices_link(env, &route.address);
846955 }
847956 ClientEvent::Message(ServerMessage::Routes { routes }) => self.learn_routes(env, &routes),
848- ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {}
957+ ClientEvent::Message(ServerMessage::Welcome { version, updatable, .. }) => {
958+ if let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) {
959+ // Back after the update, or never gone.
960+ environment.updating = None;
961+ environment.version = Some(version);
962+ environment.updatable = updatable;
963+ }
964+ cx.notify();
965+ }
966+ ClientEvent::Message(ServerMessage::UpdateStatus { state, progress, error }) => {
967+ let name = self.environment_name(env);
968+ if let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) {
969+ environment.updating = match error {
970+ Some(error) => {
971+ window.push_notification(
972+ Notification::error(error).title(format!("Couldn't update {name}")),
973+ cx,
974+ );
975+ None
976+ }
977+ None => Some(RemoteUpdate {
978+ state,
979+ progress,
980+ since: environment.updating.as_ref().map_or_else(Instant::now, |update| update.since),
981+ }),
982+ };
983+ }
984+ cx.notify();
985+ }
986+ ClientEvent::Message(ServerMessage::Denied { .. }) => {}
849987 // Only sent in reply to requests this app doesn't make.
850988 ClientEvent::Message(ServerMessage::Catalog { .. }) => {}
851989 ClientEvent::Message(ServerMessage::DiffReply { request, files, error }) => {
+158−1src/ui/workspace/sidebar.rsView File
@@ -21,6 +21,7 @@ use crate::settings;
2121 use crate::ui::icons::Lucide;
2222 use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
2323 use crate::ui::pickers;
24+ use crate::update::{NOTES_SHOWN, Stage, Updater};
2425 use crate::{SelectNext, SelectPrevious, Dismiss};
2526
2627 mod legacy;
@@ -1779,9 +1780,165 @@ impl Workspace {
17791780 .tooltip("Usage")
17801781 .selected(self.usage_open)
17811782 .on_click(cx.listener(|this, _, window, cx| this.toggle_usage(window, cx))),
1782- ),
1783+ )
1784+ .child(div().flex_1())
1785+ .child(self.render_update_button(cx)),
17831786 )
17841787 }
1788+
1789+ /// T3's update button in the corner: check for updates, then download
1790+ /// one, then restart into it; with this app up to date, update the
1791+ /// connected devices that are behind. Its hover card says what changed.
1792+ fn render_update_button(&self, cx: &mut Context<Self>) -> AnyElement {
1793+ let p = metal::palette(cx);
1794+ let updating = self.updating_environments();
1795+ let servers_updating = updating.len();
1796+ // One that's updating says how far it got.
1797+ let updating_stage = match updating.as_slice() {
1798+ [environment] => environment.updating.as_ref().map(|update| match (update.state.as_str(), update.progress) {
1799+ ("downloading", Some(progress)) => format!(": downloading, {}%", (progress * 100.) as u32),
1800+ ("downloading", None) => ": downloading".to_string(),
1801+ ("restarting", _) => ": restarting".to_string(),
1802+ _ => String::new(),
1803+ }),
1804+ _ => None,
1805+ };
1806+ let servers = self.outdated_environments().iter().filter(|env| env.updating.is_none()).count();
1807+ // Each state is its own button, so a change drops the old one's
1808+ // tooltip rather than leaving it up with stale words.
1809+ let button = |id: &'static str| MetalButton::new(id).ghost();
1810+ let spinner = |id: &'static str| {
1811+ Icon::new(IconName::LoaderCircle).size(px(16.)).with_animation(
1812+ id,
1813+ Animation::new(std::time::Duration::from_millis(900)).repeat(),
1814+ |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1815+ )
1816+ };
1817+ let with_dot = |icon: Icon, color: Hsla| {
1818+ div()
1819+ .relative()
1820+ .size(px(16.))
1821+ .child(icon.size(px(16.)))
1822+ .child(div().absolute().top(px(-2.)).right(px(-2.)).size(px(6.)).rounded_full().bg(color))
1823+ };
1824+ match &Updater::global(cx).stage {
1825+ Stage::Downloading { .. } => {
1826+ button("update-downloading").leading(spinner("update-downloading")).hoverable_tooltip(update_card).into_any_element()
1827+ }
1828+ Stage::Ready(_) => button("update-restart")
1829+ .leading(with_dot(Icon::new(Lucide::RotateCw), p.green_deep))
1830+ .hoverable_tooltip(update_card)
1831+ .on_click(cx.listener(|this, _, window, cx| this.restart_to_update(window, cx)))
1832+ .into_any_element(),
1833+ Stage::Available(_) | Stage::Failed { release: Some(_), .. } => {
1834+ let failed = matches!(Updater::global(cx).stage, Stage::Failed { .. });
1835+ button(if failed { "update-retry" } else { "update-download" })
1836+ .leading(with_dot(Icon::new(Lucide::Download), if failed { p.red } else { p.ink }))
1837+ .hoverable_tooltip(update_card)
1838+ .on_click(cx.listener(|_, _, _, cx| Updater::download(cx)))
1839+ .into_any_element()
1840+ }
1841+ _ if servers_updating > 0 => button("servers-updating")
1842+ .leading(spinner("servers-updating"))
1843+ .tooltip(format!(
1844+ "Updating {servers_updating} server{}{}…",
1845+ if servers_updating == 1 { "" } else { "s" },
1846+ updating_stage.unwrap_or_default()
1847+ ))
1848+ .into_any_element(),
1849+ _ if servers > 0 => button("update-servers")
1850+ .icon(Lucide::CircleArrowUp)
1851+ .tooltip(format!("Update {servers} server{}", if servers == 1 { "" } else { "s" }))
1852+ .on_click(cx.listener(|this, _, _, cx| this.update_environments(cx)))
1853+ .into_any_element(),
1854+ Stage::Checking => button("update-checking").leading(spinner("update-checking")).tooltip("Checking for updates…").into_any_element(),
1855+ _ => button("update-check")
1856+ .icon(IconName::RefreshCw)
1857+ .tooltip("Check for updates")
1858+ .on_click(cx.listener(|_, _, _, cx| Updater::check(true, cx)))
1859+ .into_any_element(),
1860+ }
1861+ }
1862+
1863+ /// Restarts into the downloaded update, asking first while threads work.
1864+ fn restart_to_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1865+ let working = self
1866+ .projects
1867+ .iter()
1868+ .flat_map(|project| &project.sessions)
1869+ .filter(|session| session.read(cx).remote.is_none() && session.read(cx).is_working())
1870+ .count();
1871+ if working == 0 {
1872+ Updater::restart(cx);
1873+ return;
1874+ }
1875+ let message = format!(
1876+ "Restart to update?\n{working} thread{} still working; restarting stops {}.",
1877+ if working == 1 { " is" } else { "s are" },
1878+ if working == 1 { "it" } else { "them" },
1879+ );
1880+ super::actions::confirm(&message, false, window, cx, |_, cx| Updater::restart(cx));
1881+ }
1882+ }
1883+
1884+ /// The update button's hover card: what a click does, and the release's
1885+ /// notes with a link to the rest. It reads the updater as it draws, so a
1886+ /// download's progress ticks while it's open.
1887+ fn update_card(window: &mut Window, cx: &mut App) -> AnyView {
1888+ Tooltip::element(|_, cx| {
1889+ let p = metal::palette(cx);
1890+ let stage = &Updater::global(cx).stage;
1891+ let Some(release) = stage.release() else { return v_flex() };
1892+ let version = &release.version;
1893+ let title = match stage {
1894+ Stage::Downloading { done, total, .. } => {
1895+ let percent = if *total == 0 { 0 } else { done * 100 / total };
1896+ format!("Downloading update {version}… {percent}%")
1897+ }
1898+ Stage::Ready(_) => format!("Update {version} downloaded. Click to restart and install."),
1899+ Stage::Failed { error, .. } => format!("Couldn't download update {version}: {error}. Click to try again."),
1900+ _ if Updater::can_install() => format!("Update {version} available. Click to download."),
1901+ _ => format!("Update {version} available. Click to open its download page."),
1902+ };
1903+ let shown = release.notes.len().min(NOTES_SHOWN);
1904+ let more = release.notes.len() - shown;
1905+ let page = release.page.clone();
1906+ let label = match more {
1907+ 0 => "Release on Vectrum".to_string(),
1908+ 1 => "1 more change on Vectrum".to_string(),
1909+ more => format!("{more} more changes on Vectrum"),
1910+ };
1911+ v_flex()
1912+ .max_w(px(340.))
1913+ .gap(px(8.))
1914+ .child(div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title))
1915+ .when(shown > 0, |this| {
1916+ this.child(div().text_size(px(12.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child("What's changed"))
1917+ .child(v_flex().gap(px(4.)).children(release.notes[..shown].iter().map(|note| {
1918+ h_flex()
1919+ .items_start()
1920+ .gap(px(6.))
1921+ .text_size(px(12.))
1922+ .text_color(p.ink2)
1923+ .child("•")
1924+ .child(div().flex_1().min_w_0().child(note.clone()))
1925+ })))
1926+ })
1927+ .child(
1928+ h_flex()
1929+ .id("update-release-page")
1930+ .gap(px(4.))
1931+ .cursor_pointer()
1932+ .text_size(px(12.))
1933+ .text_color(p.ink2)
1934+ .underline()
1935+ .hover(|style| style.text_color(p.ink))
1936+ .on_click(move |_, _, _| super::open_url(&page))
1937+ .child(label)
1938+ .child(Icon::new(IconName::ExternalLink).size(px(12.))),
1939+ )
1940+ })
1941+ .build(window, cx)
17851942 }
17861943
17871944 /// Two things in one spot: `rest`, and `hover` while the `group` is
+661−0src/update.rsaddedView File
@@ -0,0 +1,661 @@
1+ //! Updates the app from its releases on Vectrum (vectrum.cc/vectrum/code),
2+ //! the way T3 Code's desktop app does: it looks for a new version now and
3+ //! then (or when asked), downloads it when the user says so, and installs it
4+ //! when they restart.
5+ //!
6+ //! Only an AppImage updates itself; elsewhere a new version opens its
7+ //! release page. A release must carry `SHA256SUMS` and `SHA256SUMS.sig`, an
8+ //! Ed25519 signature over the sums made with the private half of a key in
9+ //! [`RELEASE_KEYS`] (`build.sh -r` signs). The AppImage, and the release
10+ //! notes in `CHANGES.md`, must hash to what the signed sums say, so neither
11+ //! the server nor anything between can slip in another file. A download is
12+ //! staged beside the AppImage and moved over it when the app restarts or
13+ //! quits.
14+
15+ use std::{
16+ fs,
17+ io::{Read as _, Write as _},
18+ path::{Path, PathBuf},
19+ time::Duration,
20+ };
21+
22+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
23+ use gpui_kit::*;
24+ use serde::Deserialize;
25+
26+ use crate::settings;
27+
28+ /// Where releases come from. `VECTRUM_UPDATE_URL` points elsewhere, as a
29+ /// test server does; releases from there still need the signature.
30+ const SERVER: &str = "https://vectrum.cc";
31+ const REPO: &str = "vectrum/code";
32+ /// The public halves of the keys releases are signed with, base64. More than
33+ /// one lets a new key take over.
34+ const RELEASE_KEYS: &[&str] = &["wPnCiqfvFFXBY1PwhnGhcyK8ELkKasxlzSwMpZBDNxU="];
35+ /// How long after starting the first look for a new version waits, and how
36+ /// often it looks again.
37+ const FIRST_CHECK: Duration = Duration::from_secs(15);
38+ const CHECK_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
39+ /// Downloads larger than this are refused, whatever the server says.
40+ const MAX_SIZE: u64 = 512 * 1024 * 1024;
41+ /// How many changes the release notes list before "N more".
42+ pub const NOTES_SHOWN: usize = 12;
43+
44+ pub fn current_version() -> &'static str {
45+ env!("CARGO_PKG_VERSION")
46+ }
47+
48+ /// A newer version on the server, checked against the signed sums.
49+ #[derive(Clone, Debug, PartialEq)]
50+ pub struct Release {
51+ pub version: String,
52+ /// Its page on the server.
53+ pub page: String,
54+ /// What changed, one line each.
55+ pub notes: Vec<String>,
56+ appimage_url: String,
57+ /// The AppImage's SHA-256, from the signed sums.
58+ sha256: Vec<u8>,
59+ }
60+
61+ #[derive(Clone, Debug, Default, PartialEq)]
62+ pub enum Stage {
63+ #[default]
64+ Idle,
65+ Checking,
66+ Available(Release),
67+ Downloading { release: Release, done: u64, total: u64 },
68+ /// Downloaded, checked and staged; a restart installs it.
69+ Ready(Release),
70+ /// A check or download went wrong; `release` is what it was getting.
71+ Failed { release: Option<Release>, error: String },
72+ }
73+
74+ impl Stage {
75+ pub fn release(&self) -> Option<&Release> {
76+ match self {
77+ Stage::Available(release) | Stage::Ready(release) | Stage::Downloading { release, .. } => Some(release),
78+ Stage::Failed { release, .. } => release.as_ref(),
79+ Stage::Idle | Stage::Checking => None,
80+ }
81+ }
82+ }
83+
84+ /// The app's updater, a global the views watch.
85+ #[derive(Default)]
86+ pub struct Updater {
87+ pub stage: Stage,
88+ /// Said once after a check or download the user started, for a notice,
89+ /// and whether it's an error.
90+ notice: Option<(String, bool)>,
91+ /// Restart as soon as the download is ready: a client asked this host
92+ /// to update.
93+ restart_when_ready: bool,
94+ _task: Option<Task<()>>,
95+ _schedule: Option<Task<()>>,
96+ _quit: Option<Subscription>,
97+ }
98+
99+ impl Global for Updater {}
100+
101+ impl Updater {
102+ pub fn init(cx: &mut App) {
103+ let schedule = cx.spawn(async move |cx: &mut AsyncApp| {
104+ cx.background_executor().timer(FIRST_CHECK).await;
105+ loop {
106+ cx.update(|cx| {
107+ if settings::get(|settings| settings.app_update_checks) {
108+ Self::check(false, cx);
109+ }
110+ });
111+ cx.background_executor().timer(CHECK_EVERY).await;
112+ }
113+ });
114+ // An update downloaded but not restarted into goes in on the way out,
115+ // so the next start is the new version.
116+ let quit = cx.on_app_quit(|cx| {
117+ if let Stage::Ready(release) = &cx.global::<Updater>().stage
118+ && let Some(target) = target()
119+ && let Err(err) = install(&target, &release.version)
120+ {
121+ eprintln!("vectrum-code: couldn't install the update: {err}");
122+ }
123+ async {}
124+ });
125+ cx.set_global(Updater { _schedule: Some(schedule), _quit: Some(quit), ..Default::default() });
126+ }
127+
128+ pub fn global(cx: &App) -> &Updater {
129+ cx.global::<Updater>()
130+ }
131+
132+ /// Takes what a check or download the user started has to say.
133+ pub fn take_notice(cx: &mut App) -> Option<(String, bool)> {
134+ // Only touched when there's one, since touching it tells the watchers.
135+ cx.global::<Updater>().notice.as_ref()?;
136+ cx.global_mut::<Updater>().notice.take()
137+ }
138+
139+ /// Whether the app can replace itself: it runs as an AppImage.
140+ pub fn can_install() -> bool {
141+ target().is_some()
142+ }
143+
144+ /// Looks for a newer version. A check the user asked for (`manual`)
145+ /// says how it went; an automatic one leaves a download or a staged
146+ /// update be.
147+ pub fn check(manual: bool, cx: &mut App) {
148+ let updater = cx.global::<Updater>();
149+ match &updater.stage {
150+ Stage::Checking | Stage::Downloading { .. } => return,
151+ Stage::Ready(_) if !manual => return,
152+ _ => {}
153+ }
154+ let previous = updater.stage.clone();
155+ Self::set_stage(Stage::Checking, cx);
156+ let task = cx.spawn(async move |cx: &mut AsyncApp| {
157+ let found = cx.background_spawn(async { latest(&server()) }).await;
158+ cx.update(|cx| {
159+ let updater = cx.global_mut::<Updater>();
160+ updater.stage = match found {
161+ // A staged update of the same version stays ready.
162+ Ok(Some(release)) => match previous {
163+ Stage::Ready(staged) if staged.version == release.version => Stage::Ready(staged),
164+ _ => Stage::Available(release),
165+ },
166+ Ok(None) => {
167+ if manual {
168+ updater.notice = Some((format!("Vectrum Code {} is the latest version.", current_version()), false));
169+ }
170+ Stage::Idle
171+ }
172+ Err(error) => {
173+ if manual {
174+ updater.notice = Some((format!("Couldn't check for updates: {error}"), true));
175+ }
176+ if manual || updater.restart_when_ready {
177+ Stage::Failed { release: None, error }
178+ } else {
179+ // A missed automatic check is no news.
180+ match previous {
181+ Stage::Checking => Stage::Idle,
182+ previous => previous,
183+ }
184+ }
185+ }
186+ };
187+ let restart = updater.restart_when_ready;
188+ if restart {
189+ Self::continue_remote_update(cx);
190+ }
191+ });
192+ });
193+ cx.global_mut::<Updater>()._task = Some(task);
194+ }
195+
196+ /// Downloads the available update and stages it beside the AppImage.
197+ pub fn download(cx: &mut App) {
198+ let Some(release) = cx.global::<Updater>().stage.release().cloned() else { return };
199+ if matches!(cx.global::<Updater>().stage, Stage::Downloading { .. } | Stage::Ready(_)) {
200+ return;
201+ }
202+ let Some(target) = target() else {
203+ crate::ui::workspace::open_url(&release.page);
204+ return;
205+ };
206+ let (progress_tx, progress) = smol::channel::unbounded::<(u64, u64)>();
207+ let (done_tx, done) = smol::channel::bounded::<Result<(), String>>(1);
208+ {
209+ let fetching = release.clone();
210+ let spawned = std::thread::Builder::new().name("app-update".into()).spawn(move || {
211+ let result = fetch_and_stage(&fetching, &target, |done, total| {
212+ let _ = progress_tx.try_send((done, total));
213+ });
214+ let _ = done_tx.send_blocking(result);
215+ });
216+ if let Err(err) = spawned {
217+ Self::set_stage(Stage::Failed { release: Some(release), error: err.to_string() }, cx);
218+ return;
219+ }
220+ }
221+ Self::set_stage(Stage::Downloading { release: release.clone(), done: 0, total: 0 }, cx);
222+ let task = cx.spawn(async move |cx: &mut AsyncApp| {
223+ loop {
224+ let next = smol::future::or(async { Err(done.recv().await) }, async { Ok(progress.recv().await) }).await;
225+ match next {
226+ Ok(Ok((done, total))) => cx.update(|cx| {
227+ // Told on whole percents, not on every packet.
228+ let percent = |done: u64, total: u64| (done * 100).checked_div(total).unwrap_or(0);
229+ if let Stage::Downloading { done: was, total: of, .. } = &cx.global::<Updater>().stage
230+ && percent(done, total) != percent(*was, *of)
231+ && let Stage::Downloading { done: was, total: of, .. } = &mut cx.global_mut::<Updater>().stage
232+ {
233+ (*was, *of) = (done, total);
234+ }
235+ }),
236+ Ok(Err(_)) => {}
237+ Err(result) => {
238+ let result = result.unwrap_or_else(|_| Err("The download stopped.".into()));
239+ cx.update(|cx| {
240+ let updater = cx.global_mut::<Updater>();
241+ let restart = updater.restart_when_ready;
242+ match result {
243+ Ok(()) => {
244+ updater.stage = Stage::Ready(release);
245+ if restart {
246+ Self::restart(cx);
247+ }
248+ }
249+ Err(error) => {
250+ updater.notice = Some((format!("Couldn't download the update: {error}"), true));
251+ updater.stage = Stage::Failed { release: Some(release), error };
252+ updater.restart_when_ready = false;
253+ }
254+ }
255+ });
256+ break;
257+ }
258+ }
259+ }
260+ });
261+ cx.global_mut::<Updater>()._task = Some(task);
262+ }
263+
264+ /// Moves the staged update over the AppImage and starts it in this
265+ /// one's place.
266+ pub fn restart(cx: &mut App) {
267+ let Stage::Ready(release) = &cx.global::<Updater>().stage else { return };
268+ let Some(target) = target() else { return };
269+ if let Err(error) = install(&target, &release.version) {
270+ let release = release.clone();
271+ let updater = cx.global_mut::<Updater>();
272+ updater.notice = Some((format!("Couldn't install the update: {error}"), true));
273+ updater.stage = Stage::Failed { release: Some(release), error };
274+ updater.restart_when_ready = false;
275+ return;
276+ }
277+ cx.global_mut::<Updater>().stage = Stage::Idle;
278+ cx.set_restart_path(target);
279+ cx.restart();
280+ }
281+
282+ /// Updates to the newest version and restarts into it, all in one, as a
283+ /// client asks of the host it's connected to. Returns why it can't.
284+ pub fn update_for_remote(cx: &mut App) -> Result<(), String> {
285+ if !Self::can_install() {
286+ return Err("This device's app isn't an AppImage, so it can't update itself.".into());
287+ }
288+ cx.global_mut::<Updater>().restart_when_ready = true;
289+ match cx.global::<Updater>().stage {
290+ Stage::Ready(_) => Self::restart(cx),
291+ Stage::Checking | Stage::Downloading { .. } => {}
292+ _ => Self::check(false, cx),
293+ }
294+ Ok(())
295+ }
296+
297+ /// The next step of an update a client asked for, after a check.
298+ fn continue_remote_update(cx: &mut App) {
299+ match &cx.global::<Updater>().stage {
300+ Stage::Available(_) | Stage::Failed { release: Some(_), .. } => Self::download(cx),
301+ Stage::Ready(_) => Self::restart(cx),
302+ _ => cx.global_mut::<Updater>().restart_when_ready = false,
303+ }
304+ }
305+
306+ /// Whether an update a client asked for is on its way.
307+ pub fn remote_update_running(cx: &App) -> bool {
308+ cx.global::<Updater>().restart_when_ready
309+ }
310+
311+ fn set_stage(stage: Stage, cx: &mut App) {
312+ cx.global_mut::<Updater>().stage = stage;
313+ }
314+ }
315+
316+ fn server() -> String {
317+ std::env::var("VECTRUM_UPDATE_URL")
318+ .ok()
319+ .filter(|url| !url.is_empty())
320+ .unwrap_or_else(|| SERVER.to_string())
321+ .trim_end_matches('/')
322+ .to_string()
323+ }
324+
325+ /// The AppImage this app runs from, which an update replaces. Programs
326+ /// started from an AppImage inherit its `APPIMAGE`, so it only counts when
327+ /// this program runs from inside the AppImage's mount (`APPDIR`).
328+ fn target() -> Option<PathBuf> {
329+ let mount = PathBuf::from(std::env::var_os("APPDIR")?).canonicalize().ok()?;
330+ let exe = std::env::current_exe().ok()?.canonicalize().ok()?;
331+ if mount.parent().is_none() || !exe.starts_with(&mount) {
332+ return None;
333+ }
334+ let path = PathBuf::from(std::env::var_os("APPIMAGE")?).canonicalize().ok()?;
335+ path.is_file().then_some(path)
336+ }
337+
338+ /// Where a download goes while it comes in, and where it waits for the
339+ /// restart: hidden files beside the AppImage, so the move is atomic.
340+ fn staged(target: &Path, version: &str) -> (PathBuf, PathBuf) {
341+ let name = target.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
342+ let dir = target.parent().unwrap_or(Path::new("/"));
343+ (dir.join(format!(".{name}.{version}.part")), dir.join(format!(".{name}.{version}.update")))
344+ }
345+
346+ // ---- versions -----------------------------------------------------------------
347+
348+ /// `0.2.4` or `0.2.4-local`: the numbers, and whether it's a release (no
349+ /// suffix), which comes after its pre-releases.
350+ fn parse_version(version: &str) -> Option<(u64, u64, u64, bool)> {
351+ let (numbers, suffix) = match version.find(['-', '+']) {
352+ Some(at) => (&version[..at], Some(&version[at + 1..])),
353+ None => (version, None),
354+ };
355+ let mut parts = numbers.split('.').map(|part| part.parse::<u64>().ok());
356+ let version = (parts.next()??, parts.next()??, parts.next()??, suffix.is_none());
357+ parts.next().is_none().then_some(version)
358+ }
359+
360+ /// Whether `candidate` is a later version than `current`.
361+ pub fn is_newer(candidate: &str, current: &str) -> bool {
362+ match (parse_version(candidate), parse_version(current)) {
363+ (Some(candidate), Some(current)) => candidate > current,
364+ _ => false,
365+ }
366+ }
367+
368+ // ---- the server ---------------------------------------------------------------
369+
370+ #[derive(Deserialize)]
371+ struct Releases {
372+ releases: Vec<ApiRelease>,
373+ }
374+
375+ #[derive(Deserialize)]
376+ struct ApiRelease {
377+ tag: String,
378+ #[serde(default)]
379+ draft: bool,
380+ #[serde(default)]
381+ prerelease: bool,
382+ #[serde(default)]
383+ assets: Vec<ApiAsset>,
384+ }
385+
386+ #[derive(Deserialize)]
387+ struct ApiAsset {
388+ name: String,
389+ }
390+
391+ fn appimage_name(version: &str) -> String {
392+ format!("Vectrum_Code-{version}-{}.AppImage", std::env::consts::ARCH)
393+ }
394+
395+ fn agent() -> ureq::Agent {
396+ ureq::AgentBuilder::new()
397+ .timeout_connect(Duration::from_secs(15))
398+ .timeout_read(Duration::from_secs(30))
399+ .user_agent(concat!("vectrum-code/", env!("CARGO_PKG_VERSION")))
400+ .https_only(!server().starts_with("http://127.0.0.1") && !server().starts_with("http://localhost"))
401+ .build()
402+ }
403+
404+ /// GETs a small file whole.
405+ fn get_bytes(url: &str, limit: u64) -> Result<Vec<u8>, String> {
406+ let response = agent().get(url).call().map_err(|err| short_error(&err))?;
407+ let mut body = Vec::new();
408+ response.into_reader().take(limit + 1).read_to_end(&mut body).map_err(|err| err.to_string())?;
409+ if body.len() as u64 > limit {
410+ return Err(format!("{url} is larger than expected"));
411+ }
412+ Ok(body)
413+ }
414+
415+ fn short_error(err: &ureq::Error) -> String {
416+ match err {
417+ ureq::Error::Status(code, _) => format!("the server answered HTTP {code}"),
418+ ureq::Error::Transport(transport) => transport.to_string(),
419+ }
420+ }
421+
422+ /// The newest release for this machine that's newer than this app, with its
423+ /// sums checked against the signature.
424+ fn latest(server: &str) -> Result<Option<Release>, String> {
425+ let body = get_bytes(&format!("{server}/api/v1/repos/{REPO}/releases"), 4 * 1024 * 1024)?;
426+ let releases: Releases =
427+ serde_json::from_slice(&body).map_err(|err| format!("the server's release list is unreadable: {err}"))?;
428+ let has = |release: &ApiRelease, name: &str| release.assets.iter().any(|asset| asset.name == name);
429+ let Some(newest) = releases
430+ .releases
431+ .iter()
432+ .filter(|release| !release.draft && !release.prerelease && parse_version(&release.tag).is_some())
433+ .filter(|release| {
434+ has(release, &appimage_name(&release.tag)) && has(release, "SHA256SUMS") && has(release, "SHA256SUMS.sig")
435+ })
436+ .max_by(|a, b| parse_version(&a.tag).cmp(&parse_version(&b.tag)))
437+ else {
438+ return Ok(None);
439+ };
440+ if !is_newer(&newest.tag, current_version()) {
441+ return Ok(None);
442+ }
443+ let version = newest.tag.clone();
444+ let download = |name: &str| format!("{server}/{REPO}/releases/download/{version}/{name}");
445+ let sums = get_bytes(&download("SHA256SUMS"), 64 * 1024)?;
446+ let signature = get_bytes(&download("SHA256SUMS.sig"), 1024)?;
447+ verify_signature(&sums, &signature)?;
448+ let sums = parse_sums(&String::from_utf8_lossy(&sums));
449+ let appimage_sha256 = sums
450+ .iter()
451+ .find(|(_, name)| *name == appimage_name(&version))
452+ .map(|(hash, _)| hash.clone())
453+ .ok_or_else(|| format!("the signed sums of {version} don't list its AppImage"))?;
454+ // The notes are only shown, but they come from the same signed list.
455+ let notes = match sums.iter().find(|(_, name)| name == "CHANGES.md") {
456+ Some((hash, _)) => get_bytes(&download("CHANGES.md"), 256 * 1024)
457+ .ok()
458+ .filter(|body| sha256(body) == *hash)
459+ .map(|body| parse_notes(&String::from_utf8_lossy(&body)))
460+ .unwrap_or_default(),
461+ None => Vec::new(),
462+ };
463+ Ok(Some(Release {
464+ page: format!("{server}/{REPO}/releases/tag/{version}"),
465+ appimage_url: download(&appimage_name(&version)),
466+ version,
467+ notes,
468+ sha256: appimage_sha256,
469+ }))
470+ }
471+
472+ /// Checks `sums` against `signature` (64 bytes, or their base64) with each
473+ /// release key.
474+ fn verify_signature(sums: &[u8], signature: &[u8]) -> Result<(), String> {
475+ verify_with(RELEASE_KEYS, sums, signature)
476+ }
477+
478+ fn verify_with(keys: &[&str], sums: &[u8], signature: &[u8]) -> Result<(), String> {
479+ use ring::signature::{ED25519, UnparsedPublicKey};
480+ let signature = if signature.len() == 64 {
481+ signature.to_vec()
482+ } else {
483+ BASE64.decode(String::from_utf8_lossy(signature).trim()).map_err(|_| "the release's signature is unreadable".to_string())?
484+ };
485+ let signed = keys.iter().filter_map(|key| BASE64.decode(key).ok()).any(|key| {
486+ UnparsedPublicKey::new(&ED25519, key).verify(sums, &signature).is_ok()
487+ });
488+ if signed { Ok(()) } else { Err("the release isn't signed with Vectrum Code's release key".into()) }
489+ }
490+
491+ /// `sha256sum` output: the hash and the file name on each line.
492+ fn parse_sums(text: &str) -> Vec<(Vec<u8>, String)> {
493+ text.lines()
494+ .filter_map(|line| {
495+ let (hash, name) = line.split_once(char::is_whitespace)?;
496+ let name = name.trim_start().trim_start_matches('*');
497+ let hash = decode_hex(hash)?;
498+ (hash.len() == 32 && !name.is_empty()).then(|| (hash, name.to_string()))
499+ })
500+ .collect()
501+ }
502+
503+ /// The release notes' list items, without their bullets.
504+ fn parse_notes(text: &str) -> Vec<String> {
505+ text.lines()
506+ .filter_map(|line| line.trim().strip_prefix("- ").or_else(|| line.trim().strip_prefix("* ")))
507+ .map(|line| line.trim().to_string())
508+ .filter(|line| !line.is_empty())
509+ .collect()
510+ }
511+
512+ fn decode_hex(text: &str) -> Option<Vec<u8>> {
513+ if !text.len().is_multiple_of(2) {
514+ return None;
515+ }
516+ (0..text.len()).step_by(2).map(|at| u8::from_str_radix(text.get(at..at + 2)?, 16).ok()).collect()
517+ }
518+
519+ fn sha256(bytes: &[u8]) -> Vec<u8> {
520+ ring::digest::digest(&ring::digest::SHA256, bytes).as_ref().to_vec()
521+ }
522+
523+ // ---- downloading and installing -------------------------------------------------
524+
525+ /// Downloads the release's AppImage beside `target`, checks it, and leaves
526+ /// it staged for [`install`].
527+ fn fetch_and_stage(
528+ release: &Release,
529+ target: &Path,
530+ progress: impl Fn(u64, u64),
531+ ) -> Result<(), String> {
532+ use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
533+
534+ let (part, ready) = staged(target, &release.version);
535+ forget_staged(target);
536+ let response = agent().get(&release.appimage_url).call().map_err(|err| short_error(&err))?;
537+ let total = response.header("Content-Length").and_then(|length| length.parse::<u64>().ok()).unwrap_or(0);
538+ if total > MAX_SIZE {
539+ return Err("the download is larger than an update can be".into());
540+ }
541+ // Never write through whatever already sits at that name.
542+ let _ = fs::remove_file(&part);
543+ let mut file = fs::OpenOptions::new()
544+ .write(true)
545+ .create_new(true)
546+ .mode(0o600)
547+ .open(&part)
548+ .map_err(|err| format!("can't write beside {}: {err}", target.display()))?;
549+ let result = (|| {
550+ let mut reader = response.into_reader().take(MAX_SIZE + 1);
551+ let mut hash = ring::digest::Context::new(&ring::digest::SHA256);
552+ let mut buffer = vec![0; 64 * 1024];
553+ let mut done = 0u64;
554+ let mut head: Vec<u8> = Vec::with_capacity(16);
555+ loop {
556+ let read = reader.read(&mut buffer).map_err(|err| err.to_string())?;
557+ if read == 0 {
558+ break;
559+ }
560+ let chunk = &buffer[..read];
561+ if head.len() < 16 {
562+ head.extend(chunk.iter().take(16 - head.len()));
563+ }
564+ hash.update(chunk);
565+ file.write_all(chunk).map_err(|err| err.to_string())?;
566+ done += read as u64;
567+ if done > MAX_SIZE {
568+ return Err("the download is larger than an update can be".into());
569+ }
570+ progress(done, total.max(done));
571+ }
572+ if total != 0 && done != total {
573+ return Err("the download ended early".into());
574+ }
575+ if hash.finish().as_ref() != release.sha256.as_slice() {
576+ return Err("the download doesn't match the release's signed checksum".into());
577+ }
578+ // An ELF file with the AppImage type 2 mark.
579+ if head.len() < 11 || &head[..4] != b"\x7fELF" || &head[8..11] != b"AI\x02" {
580+ return Err("the download isn't an AppImage".into());
581+ }
582+ file.sync_all().map_err(|err| err.to_string())?;
583+ fs::set_permissions(&part, fs::Permissions::from_mode(0o755)).map_err(|err| err.to_string())?;
584+ fs::rename(&part, &ready).map_err(|err| err.to_string())
585+ })();
586+ if result.is_err() {
587+ let _ = fs::remove_file(&part);
588+ }
589+ result
590+ }
591+
592+ /// Removes updates staged for `target` earlier, as when a newer one came
593+ /// out before the restart.
594+ fn forget_staged(target: &Path) {
595+ let (Some(dir), Some(name)) = (target.parent(), target.file_name()) else { return };
596+ let prefix = format!(".{}.", name.to_string_lossy());
597+ for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
598+ let file = entry.file_name().to_string_lossy().into_owned();
599+ if file.starts_with(&prefix) && (file.ends_with(".update") || file.ends_with(".part")) {
600+ let _ = fs::remove_file(entry.path());
601+ }
602+ }
603+ }
604+
605+ /// Moves a staged update over the AppImage. The running app keeps its own
606+ /// copy open, so this is safe while it runs.
607+ fn install(target: &Path, version: &str) -> Result<(), String> {
608+ let (_, ready) = staged(target, version);
609+ if !ready.is_file() {
610+ return Err("the downloaded update is gone; download it again".into());
611+ }
612+ fs::rename(&ready, target).map_err(|err| format!("can't replace {}: {err}", target.display()))
613+ }
614+
615+ #[cfg(test)]
616+ mod tests {
617+ // Not a glob: GPUI exports its own `test` attribute.
618+ use super::{BASE64, RELEASE_KEYS, is_newer, parse_notes, parse_sums, verify_with};
619+ use base64::Engine as _;
620+
621+ #[test]
622+ fn versions() {
623+ assert!(is_newer("0.2.5", "0.2.4"));
624+ assert!(is_newer("0.10.0", "0.9.9"));
625+ assert!(is_newer("0.2.4", "0.2.4-local"));
626+ assert!(!is_newer("0.2.4", "0.2.4"));
627+ assert!(!is_newer("0.2.3", "0.2.4"));
628+ assert!(!is_newer("0.2.4-local", "0.2.4"));
629+ assert!(!is_newer("latest", "0.2.4"));
630+ assert!(!is_newer("0.2.4.1", "0.2.4"));
631+ }
632+
633+ #[test]
634+ fn sums_and_notes() {
635+ let sums = parse_sums(
636+ "94fcdba3843199f5abc70194e39868edff9a083c61b83e1cc931adcd72047a4e Vectrum_Code-0.2.4-x86_64.AppImage\n\
637+ 3f146a7859fa09896e21f0f8612a54eeb0ab35fdd3d75e90b2a66e68e3245ad5 *vectrum-code-0.2.4-x86_64\n\
638+ nothex CHANGES.md\n",
639+ );
640+ assert_eq!(sums.len(), 2);
641+ assert_eq!(sums[0].1, "Vectrum_Code-0.2.4-x86_64.AppImage");
642+ assert_eq!(sums[1].1, "vectrum-code-0.2.4-x86_64");
643+ assert_eq!(sums[0].0[0], 0x94);
644+ assert_eq!(parse_notes("# 0.2.5\n\n- One\n* Two\nplain\n- \n"), vec!["One", "Two"]);
645+ }
646+
647+ #[test]
648+ fn signatures() {
649+ use ring::signature::{Ed25519KeyPair, KeyPair as _};
650+ let rng = ring::rand::SystemRandom::new();
651+ let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
652+ let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
653+ let key = BASE64.encode(pair.public_key().as_ref());
654+ let sums = b"abc file\n";
655+ let signature = pair.sign(sums);
656+ assert!(verify_with(&[&key], sums, signature.as_ref()).is_ok());
657+ assert!(verify_with(&[&key], sums, BASE64.encode(signature.as_ref()).as_bytes()).is_ok());
658+ assert!(verify_with(&[&key], b"abd file\n", signature.as_ref()).is_err());
659+ assert!(verify_with(RELEASE_KEYS, sums, signature.as_ref()).is_err());
660+ }
661+ }