Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 17 changed files with 1133 additions and 15 deletions.
| @@ -7489,6 +7489,7 @@ dependencies = [ | ||
| 7489 | 7489 | "mermaid-rs-renderer", |
| 7490 | 7490 | "openh264", |
| 7491 | 7491 | "qrcode", |
| 7492 | + "ring", | |
| 7492 | 7493 | "serde", |
| 7493 | 7494 | "serde_json", |
| 7494 | 7495 | "smol", |
| @@ -20,6 +20,7 @@ alacritty_terminal = "0.26" | ||
| 20 | 20 | openh264 = "0.9" |
| 21 | 21 | mermaid-rs-renderer = { version = "0.3.1", default-features = false } |
| 22 | 22 | qrcode = { version = "0.14.1", default-features = false } |
| 23 | + ring = "0.17" | |
| 23 | 24 | |
| 24 | 25 | [profile.dev] |
| 25 | 26 | debug = "line-tables-only" |
| @@ -214,6 +214,15 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 214 | 214 | with settings that differ from yours, a dialog asks whether to give it your settings or |
| 215 | 215 | leave its own as they are. The connection is a WebSocket guarded by the key but not |
| 216 | 216 | 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. | |
| 217 | 226 | - An Android companion app ([`android/`](android/README.md)), in Kotlin and Jetpack Compose: |
| 218 | 227 | a remote client that looks like the desktop, in the same themes. Pair it by scanning the QR |
| 219 | 228 | code under Settings → Connections; the sidebar, threads, composer and its pickers, |
| @@ -343,6 +352,8 @@ The shortcuts follow T3 Code's default keymap. | ||
| 343 | 352 | `~/.local/bin`, `~/.npm-global/bin`, `~/.bun/bin`, `~/.volta/bin` and nvm's Node versions. |
| 344 | 353 | - `VECTRUM_CODE_JCODE_BIN`: path to the `jcode` binary. Otherwise it's looked up on `PATH`, |
| 345 | 354 | 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. | |
| 346 | 357 | |
| 347 | 358 | Favorite models and everything in the settings view are saved in |
| 348 | 359 | `~/.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 | ||
| 359 | 370 | the host toolchain instead, for the host's glibc or newer. `-v 0.1.2` first sets the |
| 360 | 371 | version (in `Cargo.toml` and `Cargo.lock`, committed as "Version 0.1.2"), and `--release` (or |
| 361 | 372 | `-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 | |
| 363 | 378 | `./build.sh -v 0.1.2 -r` makes and publishes 0.1.2. Releasing needs a clean working tree, a |
| 364 | 379 | version not tagged yet, and a read & write API token in `VECTRUM_TOKEN` or |
| 365 | 380 | `~/.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 | ||
| 385 | 400 | - `src/models.rs`: the model catalog across providers, and favorites. |
| 386 | 401 | - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs. |
| 387 | 402 | - `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. | |
| 388 | 405 | - `src/terminal.rs`: a shell in a pseudo-terminal, emulated by `alacritty_terminal`, and the |
| 389 | 406 | bytes keys and pastes send to it. |
| 390 | 407 | - `src/ui/terminal.rs`: one terminal's view: the grid in the mono font, cursor, selection, |
| @@ -20,9 +20,12 @@ | ||
| 20 | 20 | # and Cargo.lock and commits them as "Version X" |
| 21 | 21 | # -r, --release then publish the build as a release on Vectrum: tag the |
| 22 | 22 | # 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. | |
| 26 | 29 | # -a, --android also build the Android app (android/) as a release APK. |
| 27 | 30 | # It has the desktop version as its versionName, and |
| 28 | 31 | # versionCode major*1000000 + minor*1000 + patch (0.1.9 → |
| @@ -38,6 +41,14 @@ | ||
| 38 | 41 | # ~/.config/vectrum/token (or $VECTRUM_TOKEN_FILE) |
| 39 | 42 | # VECTRUM_URL the server, https://vectrum.cc by default |
| 40 | 43 | # 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. | |
| 41 | 52 | set -euo pipefail |
| 42 | 53 | |
| 43 | 54 | log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } |
| @@ -122,6 +133,13 @@ if (( RELEASE )); then | ||
| 122 | 133 | # ssh://git@git.vectrum.cc:2222/vectrum/code.git, https://…/vectrum/code → vectrum/code |
| 123 | 134 | REPO="${VECTRUM_REPO:-$(git -C "$ROOT" remote get-url origin | sed -E 's#\.git$##; s#^.*[:/]([^/:]+/[^/:]+)$#\1#')}" |
| 124 | 135 | [[ "$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" | |
| 125 | 143 | fi |
| 126 | 144 | |
| 127 | 145 | if [[ -n "$NEW_VERSION" && "$NEW_VERSION" != "$VERSION" ]]; then |
| @@ -278,7 +296,21 @@ if (( RELEASE )); then | ||
| 278 | 296 | cp "$APK" "$STAGE/" |
| 279 | 297 | UPLOADS+=("$(basename "$APK")") |
| 280 | 298 | 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 | + | |
| 281 | 308 | (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" | |
| 282 | 314 | |
| 283 | 315 | if [[ -z "$LOCAL_TAG" ]]; then |
| 284 | 316 | git -C "$ROOT" tag -a "$TAG" -m "$PRETTY $VERSION" "$HEAD" |
| @@ -288,8 +320,9 @@ if (( RELEASE )); then | ||
| 288 | 320 | git -C "$ROOT" push -q origin "refs/tags/$TAG" |
| 289 | 321 | fi |
| 290 | 322 | |
| 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 | |
| 293 | 326 | file="$STAGE/$name" |
| 294 | 327 | log "Uploading $name" |
| 295 | 328 | # The token goes in through a file descriptor, so it never shows in ps. |
| @@ -20,6 +20,7 @@ mod store; | ||
| 20 | 20 | mod terminal; |
| 21 | 21 | mod textgen; |
| 22 | 22 | mod ui; |
| 23 | + mod update; | |
| 23 | 24 | mod usage; |
| 24 | 25 | mod worktree; |
| 25 | 26 | |
| @@ -111,6 +112,7 @@ fn main() { | ||
| 111 | 112 | models::Catalog::init(cx); |
| 112 | 113 | ui::browser::BrowserWatch::init(cx); |
| 113 | 114 | ui::device::DeviceWatch::init(cx); |
| 115 | + update::Updater::init(cx); | |
| 114 | 116 | |
| 115 | 117 | cx.bind_keys([ |
| 116 | 118 | // T3 Code's default keymap, for the commands the app has. |
| @@ -225,12 +225,22 @@ struct Link { | ||
| 225 | 225 | route: Route, |
| 226 | 226 | /// Where the host says it listens. |
| 227 | 227 | routes: Vec<Route>, |
| 228 | + /// The host's app version, and whether it can update itself. | |
| 229 | + version: String, | |
| 230 | + updatable: bool, | |
| 228 | 231 | } |
| 229 | 232 | |
| 230 | 233 | /// Tells the workspace about a new connection. |
| 231 | 234 | fn announce(events: &smol::channel::Sender<ClientEvent>, link: &Link) { |
| 232 | 235 | let _ = events.send_blocking(ClientEvent::Route(link.route.clone())); |
| 233 | 236 | 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 | + })); | |
| 234 | 244 | let _ = events.send_blocking(ClientEvent::Status(ConnectionStatus::Connected(link.host.clone()))); |
| 235 | 245 | } |
| 236 | 246 | |
| @@ -302,9 +312,9 @@ fn open(route: &Route, hello: &Hello) -> Result<Link, Failure> { | ||
| 302 | 312 | match socket.read().map_err(|err| other(&err))? { |
| 303 | 313 | Message::Text(text) => { |
| 304 | 314 | return match serde_json::from_str::<ServerMessage>(text.as_str()) { |
| 305 | - Ok(ServerMessage::Welcome { host, routes, .. }) => { | |
| 315 | + Ok(ServerMessage::Welcome { host, routes, version, updatable, .. }) => { | |
| 306 | 316 | 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 }) | |
| 308 | 318 | } |
| 309 | 319 | Ok(ServerMessage::Denied { reason }) => Err(Failure::Denied(reason)), |
| 310 | 320 | _ => Err(other(&"the host answered something unexpected")), |
| @@ -30,6 +30,11 @@ | ||
| 30 | 30 | //! again in [`ServerMessage::Routes`] when they change; the client saves |
| 31 | 31 | //! them with the connection, tries them fastest first, and moves back to a |
| 32 | 32 | //! 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. | |
| 33 | 38 | |
| 34 | 39 | pub mod client; |
| 35 | 40 | pub mod device_proxy; |
| @@ -242,6 +247,10 @@ pub enum ClientMessage { | ||
| 242 | 247 | /// `base` and `head`), for one file or all of them. Older hosts don't |
| 243 | 248 | /// answer. |
| 244 | 249 | 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, | |
| 245 | 254 | } |
| 246 | 255 | |
| 247 | 256 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| @@ -254,6 +263,11 @@ pub enum ServerMessage { | ||
| 254 | 263 | /// Where else the host listens. Older hosts don't say. |
| 255 | 264 | #[serde(default)] |
| 256 | 265 | 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, | |
| 257 | 271 | }, |
| 258 | 272 | Denied { reason: String }, |
| 259 | 273 | State { projects: Vec<ProjectInfo>, threads: Vec<ThreadSummary> }, |
| @@ -290,6 +304,10 @@ pub enum ServerMessage { | ||
| 290 | 304 | /// The answer to a [`ClientMessage::Diff`]: the changed files, or what |
| 291 | 305 | /// went wrong. |
| 292 | 306 | 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> }, | |
| 293 | 311 | } |
| 294 | 312 | |
| 295 | 313 | /// A provider instance on the host and what its last health check found. |
| @@ -995,6 +1013,15 @@ mod tests { | ||
| 995 | 1013 | _ => panic!("not settings"), |
| 996 | 1014 | } |
| 997 | 1015 | |
| 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 | + | |
| 998 | 1025 | let text = serde_json::to_string(&ClientMessage::Catalog).unwrap(); |
| 999 | 1026 | assert_eq!(text, r#"{"type":"catalog"}"#); |
| 1000 | 1027 | assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), ClientMessage::Catalog); |
| @@ -1083,6 +1110,12 @@ mod tests { | ||
| 1083 | 1110 | _ => panic!("didn't say which route"), |
| 1084 | 1111 | } |
| 1085 | 1112 | 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 | + } | |
| 1086 | 1119 | match wait(&client.events) { |
| 1087 | 1120 | |
| 1088 | 1121 | Some(ClientEvent::Status(ConnectionStatus::Connected(host))) => assert_eq!(host, "test-host"), |
| @@ -191,8 +191,13 @@ fn serve( | ||
| 191 | 191 | return; |
| 192 | 192 | } |
| 193 | 193 | }; |
| 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 | + }; | |
| 196 | 201 | if socket.send(Message::text(serde_json::to_string(&welcome).unwrap_or_default())).is_err() { |
| 197 | 202 | return; |
| 198 | 203 | } |
| @@ -530,6 +530,10 @@ pub struct Settings { | ||
| 530 | 530 | /// Ctrl+Q, Alt+F4 and the window's close button ask before closing. |
| 531 | 531 | pub confirm_close: bool, |
| 532 | 532 | |
| 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 | + | |
| 533 | 537 | // ---- notifications and background work ---- |
| 534 | 538 | /// A desktop notification when a thread finishes or needs you while the |
| 535 | 539 | /// window is in the background. |
| @@ -663,6 +667,7 @@ impl Default for Settings { | ||
| 663 | 667 | confirm_delete: true, |
| 664 | 668 | quit_shortcut: QuitShortcut::Hold, |
| 665 | 669 | confirm_close: true, |
| 670 | + app_update_checks: true, | |
| 666 | 671 | notify: false, |
| 667 | 672 | notify_sound: false, |
| 668 | 673 | in_app_notifications: false, |
| @@ -775,6 +780,7 @@ impl Settings { | ||
| 775 | 780 | ("Delete confirmation", self.confirm_delete != d.confirm_delete), |
| 776 | 781 | ("Quit shortcut", self.quit_shortcut != d.quit_shortcut), |
| 777 | 782 | ("Close confirmation", self.confirm_close != d.confirm_close), |
| 783 | + ("Automatic update checks", self.app_update_checks != d.app_update_checks), | |
| 778 | 784 | ("Text generation model", (&self.text_model, &self.text_reasoning) != (&d.text_model, &d.text_reasoning)), |
| 779 | 785 | ( |
| 780 | 786 | "Worktree branch naming", |
| @@ -125,6 +125,8 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 125 | 125 | PictureInPicture2, |
| 126 | 126 | Power, |
| 127 | 127 | GripHorizontal, |
| 128 | + CircleArrowUp, | |
| 129 | + RotateCw, | |
| 128 | 130 | ]); |
| 129 | 131 | |
| 130 | 132 | /// Forge logos Lucide doesn't have, from Simple Icons (CC0). |
| @@ -20,6 +20,7 @@ use crate::settings::{ | ||
| 20 | 20 | }; |
| 21 | 21 | use crate::ui::metal::{self, MetalButton}; |
| 22 | 22 | use crate::ui::{pickers, workspace}; |
| 23 | + use crate::update::{Stage, Updater}; | |
| 23 | 24 | |
| 24 | 25 | const WORKSPACES: &[(WorkspaceMode, &str)] = |
| 25 | 26 | &[(WorkspaceMode::Local, "Current checkout"), (WorkspaceMode::Worktree, "New worktree")]; |
| @@ -582,12 +583,49 @@ impl SettingsView { | ||
| 582 | 583 | .render(cx); |
| 583 | 584 | |
| 584 | 585 | // ---- 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 | + }; | |
| 585 | 600 | let about = Section::new("About") |
| 586 | 601 | .row( |
| 587 | 602 | row("Version") |
| 588 | 603 | .id("version") |
| 589 | 604 | .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 | + })), | |
| 591 | 629 | ) |
| 592 | 630 | .render(cx); |
| 593 | 631 | |
| @@ -178,6 +178,8 @@ impl SettingsView { | ||
| 178 | 178 | }); |
| 179 | 179 | |
| 180 | 180 | let mut subscriptions = vec![ |
| 181 | + // About's update status. | |
| 182 | + cx.observe_global::<crate::update::Updater>(|_, cx| cx.notify()), | |
| 181 | 183 | cx.subscribe_in(&search, window, |this, _, event: &InputEvent, window, cx| match event { |
| 182 | 184 | InputEvent::Change => { |
| 183 | 185 | this.active_result = 0; |
| @@ -105,7 +105,8 @@ pub const ITEMS: &[Item] = &[ | ||
| 105 | 105 | item("delete-confirmation", "Delete confirmation", Page::General, "ask before thread chat history"), |
| 106 | 106 | item("quit-confirmation", "Quit shortcut", Page::General, "confirmation exit direct hold double press twice ctrl q"), |
| 107 | 107 | 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"), | |
| 109 | 110 | item("diagnostics", "Diagnostics", Page::General, "logs processes resource cpu memory kill"), |
| 110 | 111 | item("open-source-licenses", "Open source licenses", Page::General, "notices dependencies fonts geist crates ofl mit apache"), |
| 111 | 112 | folded("legacy-plan-mode", "Plan mode (legacy)", Page::General, "build plan composer old shift tab", "legacy"), |
| @@ -31,6 +31,7 @@ use crate::git::{self, GitInfo}; | ||
| 31 | 31 | use crate::models::{Catalog, ModelEntry, Provider, split_key}; |
| 32 | 32 | use crate::session::{NewSession, Reasoning, Session, from_millis, to_millis}; |
| 33 | 33 | use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode}; |
| 34 | + use crate::update::Updater; | |
| 34 | 35 | use crate::store::{self, AppState, SavedProject}; |
| 35 | 36 | use crate::ui::browser::{BrowserPanel, BrowserPanelEvent}; |
| 36 | 37 | use crate::ui::chat::{ChatEvent, ChatView, display_path}; |
| @@ -367,6 +368,16 @@ impl Workspace { | ||
| 367 | 368 | device::service().stop_all(); |
| 368 | 369 | async {} |
| 369 | 370 | }), |
| 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 | + }), | |
| 370 | 381 | // The System theme mode follows the desktop's light/dark setting. |
| 371 | 382 | cx.observe_window_appearance(window, |_, window, cx| metal::sync_appearance(window, cx)), |
| 372 | 383 | cx.observe_window_activation(window, |this, window, cx| { |
| @@ -8,7 +8,7 @@ use std::{ | ||
| 8 | 8 | path::PathBuf, |
| 9 | 9 | rc::Rc, |
| 10 | 10 | sync::atomic::{AtomicU64, Ordering}, |
| 11 | - time::{Duration, SystemTime}, | |
| 11 | + time::{Duration, Instant, SystemTime}, | |
| 12 | 12 | }; |
| 13 | 13 | |
| 14 | 14 | use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; |
| @@ -35,6 +35,7 @@ use crate::terminal::RemoteRequest; | ||
| 35 | 35 | use crate::ui::folder_browser; |
| 36 | 36 | use crate::ui::metal::MetalButton; |
| 37 | 37 | use crate::ui::terminal::TerminalView; |
| 38 | + use crate::update::{Stage, Updater, current_version, is_newer}; | |
| 38 | 39 | |
| 39 | 40 | /// How often changed threads are sent to subscribed clients. |
| 40 | 41 | pub(super) const FLUSH: Duration = Duration::from_millis(150); |
| @@ -48,9 +49,27 @@ pub(super) struct Environment { | ||
| 48 | 49 | pub(super) client: Rc<RemoteClient>, |
| 49 | 50 | /// Its environment settings, once it shared them. |
| 50 | 51 | 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>, | |
| 51 | 58 | _events: Task<()>, |
| 52 | 59 | } |
| 53 | 60 | |
| 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 | + | |
| 54 | 73 | /// One client's interest in a thread: the hashes of the transcript items it |
| 55 | 74 | /// was last sent, or `None` before the first send. |
| 56 | 75 | type Subscription = Option<Vec<u64>>; |
| @@ -69,6 +88,10 @@ pub(super) struct RemoteState { | ||
| 69 | 88 | /// The environment settings clients were last sent. |
| 70 | 89 | last_settings: String, |
| 71 | 90 | 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>)>, | |
| 72 | 95 | /// Shells clients run here, by connection and the client's id for them. |
| 73 | 96 | shells: HashMap<(u64, u64), HostShell>, |
| 74 | 97 | /// This device's terminals whose shells run on another device, by the |
| @@ -179,6 +202,9 @@ impl Workspace { | ||
| 179 | 202 | route: None, |
| 180 | 203 | client, |
| 181 | 204 | settings: None, |
| 205 | + version: None, | |
| 206 | + updatable: false, | |
| 207 | + updating: None, | |
| 182 | 208 | _events: task, |
| 183 | 209 | }); |
| 184 | 210 | } |
| @@ -450,6 +476,7 @@ impl Workspace { | ||
| 450 | 476 | } |
| 451 | 477 | ServerEvent::Disconnected { conn } => { |
| 452 | 478 | self.forget_device_watcher(conn); |
| 479 | + self.remote.update_requesters.retain(|requester| *requester != conn); | |
| 453 | 480 | self.remote.peers.retain(|peer| peer.conn != conn); |
| 454 | 481 | self.remote.subscriptions.remove(&conn); |
| 455 | 482 | self.remote.shells.retain(|(owner, _), _| *owner != conn); |
| @@ -576,6 +603,18 @@ impl Workspace { | ||
| 576 | 603 | ClientMessage::TerminalClose { terminal } => { |
| 577 | 604 | self.remote.shells.remove(&(conn, terminal)); |
| 578 | 605 | } |
| 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 | + }, | |
| 579 | 618 | ClientMessage::Catalog => { |
| 580 | 619 | let catalog = Catalog::global(cx); |
| 581 | 620 | let instances = catalog.instances.iter().map(|state| RemoteInstance::from_state(state, catalog)).collect(); |
| @@ -750,6 +789,76 @@ impl Workspace { | ||
| 750 | 789 | } |
| 751 | 790 | } |
| 752 | 791 | |
| 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 | + | |
| 753 | 862 | // ---- connecting to other devices ------------------------------------------- |
| 754 | 863 | |
| 755 | 864 | fn on_client_event(&mut self, env: &str, event: ClientEvent, window: &mut Window, cx: &mut Context<Self>) { |
| @@ -845,7 +954,36 @@ impl Workspace { | ||
| 845 | 954 | self.move_devices_link(env, &route.address); |
| 846 | 955 | } |
| 847 | 956 | 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 { .. }) => {} | |
| 849 | 987 | // Only sent in reply to requests this app doesn't make. |
| 850 | 988 | ClientEvent::Message(ServerMessage::Catalog { .. }) => {} |
| 851 | 989 | ClientEvent::Message(ServerMessage::DiffReply { request, files, error }) => { |
| @@ -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 | + } | |