From 43683e31bb57142e99859a77a1ff4ca2492a9d95 Mon Sep 17 00:00:00 2001 From: nsaintot Date: Thu, 12 Nov 2026 13:11:03 +0100 Subject: [PATCH] Add an updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Title bar: the version stays centred; a click checks for updates. A slot in the corner away from the window buttons shows Checking, Up to date, Check failed, and "New update available!" once a newer release exists. - macOS: Sparkle 2.10 in the bundle, with "Check for Updates…" in the app menu. A click on the version probes the feed; a click on the notice opens Sparkle's window. CD writes appcast.xml, signed with UPDATE_SIGNING_KEY. - Windows: asks, downloads Setup and checks its SHA-256, waits until nothing is running, queued or downloading, then runs Setup silently and restarts Stemd. Setup keeps the install mode, location and GPU choice of the copy it replaces, and waits for the old executable to exit. The zip, or an all-users copy on a standard account, opens the release page. - Linux: the notice shows in the status bar and opens the release page. - Windows and Linux pick the newest v* release, so model releases do not hide updates. macOS reads releases/latest, so model releases must be published with --latest=false. --- .github/scripts/appcast.sh | 50 ++ .github/scripts/sign.sh | 29 ++ .github/scripts/tag-notes.sh | 17 + .github/workflows/cd.yml | 15 + Cargo.lock | 2 + crates/stemd-server/Cargo.toml | 18 +- crates/stemd-server/src/main.rs | 1 + crates/stemd-server/src/models/download.rs | 2 +- crates/stemd-server/src/models/mod.rs | 3 + crates/stemd-server/src/ui/mod.rs | 17 +- crates/stemd-server/src/ui/status.rs | 17 +- crates/stemd-server/src/ui/titlebar.rs | 38 +- crates/stemd-server/src/ui/update.rs | 540 ++++++++++++++++++++ crates/stemd-server/src/update/github.rs | 148 ++++++ crates/stemd-server/src/update/installer.rs | 278 ++++++++++ crates/stemd-server/src/update/mod.rs | 250 +++++++++ crates/stemd-server/src/update/release.rs | 378 ++++++++++++++ crates/stemd-server/src/update/sparkle.rs | 282 ++++++++++ docs/building.md | 9 + docs/internals.md | 62 ++- packaging/update-ed25519.pub | 1 + scripts/bundle-app.sh | 48 +- scripts/installer-windows.nsi | 81 +++ 23 files changed, 2251 insertions(+), 35 deletions(-) create mode 100755 .github/scripts/appcast.sh create mode 100755 .github/scripts/sign.sh create mode 100755 .github/scripts/tag-notes.sh create mode 100644 crates/stemd-server/src/ui/update.rs create mode 100644 crates/stemd-server/src/update/github.rs create mode 100644 crates/stemd-server/src/update/installer.rs create mode 100644 crates/stemd-server/src/update/mod.rs create mode 100644 crates/stemd-server/src/update/release.rs create mode 100644 crates/stemd-server/src/update/sparkle.rs create mode 100644 packaging/update-ed25519.pub diff --git a/.github/scripts/appcast.sh b/.github/scripts/appcast.sh new file mode 100755 index 0000000..5c83151 --- /dev/null +++ b/.github/scripts/appcast.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# appcast.sh VERSION TAG DIR - write DIR/appcast.xml, the Sparkle feed for the +# macOS .dmg in DIR. The app reads it from the latest published release +# (SUFeedURL in scripts/bundle-app.sh). +# +# Sparkle compares sparkle:version with the bundle's CFBundleVersion, which is +# the version. The release notes are the annotated tag's message. +set -euo pipefail +version="$1" +tag="$2" +dir="$3" +: "${GH_REPO:?}" +script_dir="$(cd "$(dirname "$0")" && pwd)" + +dmg="$dir/stemd-$version-macos-arm64.dmg" +[[ -f "$dmg" ]] || { echo "::error::$dmg is missing" >&2; exit 1; } +signature=$("$script_dir/sign.sh" "$dmg") +length=$(wc -c < "$dmg" | tr -d ' ') +url="https://github.com/$GH_REPO/releases/download/$tag/$(basename "$dmg")" +published=$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000') + +description="" +notes=$("$script_dir/tag-notes.sh" "$tag") +if [[ -n "${notes//[[:space:]]/}" ]]; then + # A CDATA section cannot hold its own terminator. + html=$(gh api markdown -f text="$notes" -f mode=gfm -f context="$GH_REPO" \ + | sed 's/]]>/]]]]>/g') + description=" " +fi + +cat > "$dir/appcast.xml" < + + + Stemd + https://github.com/$GH_REPO/releases + + Version $version + $published + $version + $version + 14.0 + https://github.com/$GH_REPO/releases/tag/$tag +$description + + + + +XML +cat "$dir/appcast.xml" diff --git a/.github/scripts/sign.sh b/.github/scripts/sign.sh new file mode 100755 index 0000000..2d5de46 --- /dev/null +++ b/.github/scripts/sign.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# sign.sh FILE - print FILE's Ed25519 signature, base64, after checking it +# against packaging/update-ed25519.pub. +# +# UPDATE_SIGNING_KEY is the base64 32-byte seed, the format of Sparkle's +# `generate_keys -x`. The signature is over the file's bytes, which is what +# Sparkle checks against SUPublicEDKey. +set -euo pipefail +file="$1" +: "${UPDATE_SIGNING_KEY:?}" +script_dir="$(cd "$(dirname "$0")" && pwd)" +public_key="$script_dir/../../packaging/update-ed25519.pub" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +# PKCS#8 and SubjectPublicKeyInfo wrappers around the raw Ed25519 key bytes. +{ printf '\x30\x2e\x02\x01\x00\x30\x05\x06\x03\x2b\x65\x70\x04\x22\x04\x20' + printf '%s' "$UPDATE_SIGNING_KEY" | base64 -d; } > "$work/key.der" +openssl pkey -inform DER -in "$work/key.der" -out "$work/key.pem" +{ printf '\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00' + tr -d '[:space:]' < "$public_key" | base64 -d; } > "$work/pub.der" +openssl pkey -pubin -inform DER -in "$work/pub.der" -out "$work/pub.pem" + +openssl pkeyutl -sign -inkey "$work/key.pem" -rawin -in "$file" -out "$work/sig.bin" +openssl pkeyutl -verify -pubin -inkey "$work/pub.pem" -rawin -in "$file" -sigfile "$work/sig.bin" >/dev/null || { + echo "::error::UPDATE_SIGNING_KEY does not match packaging/update-ed25519.pub" >&2 + exit 1 +} +base64 < "$work/sig.bin" | tr -d '\n' diff --git a/.github/scripts/tag-notes.sh b/.github/scripts/tag-notes.sh new file mode 100755 index 0000000..d7d33a6 --- /dev/null +++ b/.github/scripts/tag-notes.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# tag-notes.sh TAG - print the message of the annotated tag TAG, with LF line +# endings and without a tag signature. A lightweight tag or an empty message +# prints nothing. +# +# Read through the API because a checkout of a tag can lose the tag object. +set -euo pipefail +tag="$1" +: "${GH_REPO:?}" + +ref=$(gh api "repos/$GH_REPO/git/ref/tags/$tag" --jq '"\(.object.type) \(.object.sha)"') +if [[ "${ref% *}" != tag ]]; then + echo "::warning::$tag is a lightweight tag, so the update feed has no release notes" >&2 + exit 0 +fi +gh api "repos/$GH_REPO/git/tags/${ref#* }" --jq .message \ + | tr -d '\r' | sed '/^-----BEGIN [A-Z]* SIGNATURE-----$/,$d' diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index d3fdde9..a9c4c4e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -20,6 +20,10 @@ # NOTARY_KEY_P8 (base64 of an App Store Connect API key), NOTARY_KEY_ID, and # NOTARY_ISSUER for a team key. Without them the app is signed ad hoc and no .dmg is made; a # release tag fails. +# +# A release also gets appcast.xml, the Sparkle feed of the macOS app. The +# secret UPDATE_SIGNING_KEY signs the .dmg in it; its public half is +# packaging/update-ed25519.pub, which the bundle carries as SUPublicEDKey. name: CD on: @@ -298,11 +302,22 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + VERSION: ${{ needs.plan.outputs.version }} steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: | + .github/scripts + packaging/update-ed25519.pub + sparse-checkout-cone-mode: false - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true + - name: Update feed + env: + UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }} + run: .github/scripts/appcast.sh "$VERSION" "$GITHUB_REF_NAME" dist # A draft, so nothing is public until published by hand. A rerun # replaces the files. - name: Draft release diff --git a/Cargo.lock b/Cargo.lock index dcd5983..f013d6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3475,6 +3475,8 @@ dependencies = [ "eframe", "if-addrs", "libc", + "objc2 0.6.4", + "objc2-foundation 0.3.2", "parking_lot", "serde", "serde_json", diff --git a/crates/stemd-server/Cargo.toml b/crates/stemd-server/Cargo.toml index dccca20..111c439 100644 --- a/crates/stemd-server/Cargo.toml +++ b/crates/stemd-server/Cargo.toml @@ -52,8 +52,22 @@ winit = { version = "0.30.13", default-features = false, features = ["x11"] } # download only there. See `cuda`. [target.'cfg(windows)'.dependencies] zip = { version = "2", default-features = false, features = ["deflate"] } -# Free disk space, see `disk.rs`. -windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } +# Free disk space, see `disk.rs`; ShellExecuteW starts the updater's Setup, +# the registry gives its install mode and the token whether it can elevate. +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Registry", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + +# Sparkle, through the Objective-C runtime. See `update::sparkle`. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSBundle", "NSString"] } [target.'cfg(unix)'.dependencies] # statvfs and sysctl, see `disk.rs`. diff --git a/crates/stemd-server/src/main.rs b/crates/stemd-server/src/main.rs index fa219b0..1a4cdc4 100644 --- a/crates/stemd-server/src/main.rs +++ b/crates/stemd-server/src/main.rs @@ -39,6 +39,7 @@ mod switch; #[cfg(test)] mod testkit; mod ui; +mod update; use anyhow::Result; use tracing_subscriber::Layer; diff --git a/crates/stemd-server/src/models/download.rs b/crates/stemd-server/src/models/download.rs index 3bb221a..92a0a17 100644 --- a/crates/stemd-server/src/models/download.rs +++ b/crates/stemd-server/src/models/download.rs @@ -85,7 +85,7 @@ pub fn fetch_one( } /// Copy `reader` into `path`, returning the digest and length of what landed. -fn stream_to_file( +pub fn stream_to_file( reader: impl Read, path: &Path, on_bytes: impl FnMut(u64), diff --git a/crates/stemd-server/src/models/mod.rs b/crates/stemd-server/src/models/mod.rs index 43376f3..6e2041c 100644 --- a/crates/stemd-server/src/models/mod.rs +++ b/crates/stemd-server/src/models/mod.rs @@ -38,6 +38,9 @@ pub use preset::{DEFAULT_PRESET, ModelSource, Preset, RETIRED, WEIGHTS_EXTENSION // names nothing there can use. #[cfg(windows)] pub use download::fetch_one as fetch_file; +/// Used by the updater to download Setup. +#[cfg(windows)] +pub use download::stream_to_file; #[cfg(windows)] pub use preset::RemoteFile; diff --git a/crates/stemd-server/src/ui/mod.rs b/crates/stemd-server/src/ui/mod.rs index 06d1846..4411a03 100644 --- a/crates/stemd-server/src/ui/mod.rs +++ b/crates/stemd-server/src/ui/mod.rs @@ -26,6 +26,7 @@ mod starting; mod status; mod theme; mod titlebar; +mod update; mod widgets; use std::net::SocketAddr; @@ -147,6 +148,7 @@ pub fn run(launch: Arc, bind: SocketAddr) -> Result<(), eframe::Error> { options, Box::new(move |cc| { theme::install(&cc.egui_ctx); + crate::update::start(&cc.egui_ctx); Ok(Box::new(App { launch, bind, @@ -198,7 +200,19 @@ impl eframe::App for App { } None => starting::show(ui, &self.launch, &mut self.first_run, &self.brushes), } - titlebar::show(ui, &Palette::of(ui.ctx())); + let palette = Palette::of(ui.ctx()); + // Idle: nothing running, queued or downloading, so Setup may close the + // window. Never while starting, which may be fetching the weights. + let idle = self.window.as_ref().is_some_and(|w| { + w.state.queue.running().is_none() + && w.state.queue.depth() == 0 + && matches!( + w.state.switcher.state(), + SwitchState::Idle | SwitchState::Failed { .. } + ) + }); + update::dialog(ui.ctx(), idle, &palette, &self.brushes); + titlebar::show(ui, &palette); } /// eframe saves only the window's position and size. @@ -212,6 +226,7 @@ impl eframe::App for App { /// `LoopExiting` to here, and AppKit calls `exit()` as soon as that returns. It is /// the last point at which the worker can be taken out of the model. fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + crate::update::run_installer(); crate::shutdown::quit(&self.launch, "window closed") } } diff --git a/crates/stemd-server/src/ui/status.rs b/crates/stemd-server/src/ui/status.rs index b62b6c5..9715db1 100644 --- a/crates/stemd-server/src/ui/status.rs +++ b/crates/stemd-server/src/ui/status.rs @@ -1,5 +1,5 @@ //! The bottom bar: the server switch and port, what is stored, and the way to -//! the log. +//! the log. On Linux, also a newer release. //! //! The same state `/v1/health` reports: a second view, never a second source. @@ -207,7 +207,7 @@ pub fn bar( tracing::info!("cache reset: {tracks} tracks, {:.0} MB", freed as f64 / 1e6); } - ui.painter().text( + let _stats = ui.painter().text( pos2(empty_rect.left() - GAP, y), egui::Align2::RIGHT_CENTER, format!( @@ -220,6 +220,19 @@ pub fn bar( palette.muted, ); + // Linux has no title bar of its own to carry the version, so a newer + // release shows here, between the two groups, where it fits. + #[cfg(all(unix, not(target_os = "macos")))] + { + let status = crate::update::status(); + if status.opens() + && let Some(x) = super::update::fits(ui, &status, hit.right(), _stats.left()) + { + let anchor = pos2(x, y); + super::update::slot(ui, anchor, egui::Align2::LEFT_CENTER, palette, 1.0); + } + } + if logs_response .on_hover_cursor(egui::CursorIcon::PointingHand) .clicked() diff --git a/crates/stemd-server/src/ui/titlebar.rs b/crates/stemd-server/src/ui/titlebar.rs index ffb2c7f..6cb6ba0 100644 --- a/crates/stemd-server/src/ui/titlebar.rs +++ b/crates/stemd-server/src/ui/titlebar.rs @@ -1,10 +1,12 @@ -//! The window's own title bar on macOS and Windows: the name and version centred -//! and, on Windows, the window buttons on the right. macOS keeps its traffic +//! The window's own title bar on macOS and Windows: the name and version +//! centred, the update slot in the corner away from the window buttons and, +//! on Windows, the window buttons on the right. macOS keeps its traffic //! lights. Linux keeps the system's title bar. -use eframe::egui::{self, Rect, pos2, vec2}; +use eframe::egui::{self, Align2, Rect, pos2, vec2}; use super::theme::{self, Palette, Weight}; +use super::update; /// Height of the bar. The content starts under it. pub const HEIGHT: f32 = if cfg!(target_os = "macos") { @@ -40,31 +42,37 @@ pub fn show(ui: &mut egui::Ui, palette: &Palette) { theme::font(12.0, Weight::Semibold), palette.text.gamma_multiply(dim), ); - let version = painter.layout_no_wrap( - format!("v{}", env!("CARGO_PKG_VERSION")), - theme::font(12.0, Weight::Regular), - palette.muted.gamma_multiply(dim), - ); + let status = crate::update::status(); let y = bar.top() + TEXT_Y; - let width = name.size().x + 6.0 + version.size().x; + let width = name.size().x + GAP + update::version_width(ui); // Centred, as macOS draws window titles. let x = bar.center().x - width / 2.0; - let version_x = x + name.size().x + 6.0; + let version_x = x + name.size().x + GAP; painter.galley( pos2(x, y - name.size().y / 2.0), name, palette.text.gamma_multiply(dim), ); - painter.galley( - pos2(version_x, y - version.size().y / 2.0), - version, - palette.muted.gamma_multiply(dim), - ); #[cfg(windows)] windows::controls(ui, &ctx, bar, screen, palette, dim); + + // After the controls, so they take clicks over the drag area. + update::version(ui, version_x, y, &status, palette, dim); + let (anchor, align) = if cfg!(windows) { + (pos2(bar.left() + CORNER, y), Align2::LEFT_CENTER) + } else { + (pos2(bar.right() - CORNER, y), Align2::RIGHT_CENTER) + }; + update::slot(ui, anchor, align, palette, dim); } +/// Space between the name and the version button. +const GAP: f32 = 4.0; + +/// Inset of the update slot from the window's side. +const CORNER: f32 = 14.0; + #[cfg(windows)] mod windows { use eframe::egui::{ diff --git a/crates/stemd-server/src/ui/update.rs b/crates/stemd-server/src/ui/update.rs new file mode 100644 index 0000000..20a33d0 --- /dev/null +++ b/crates/stemd-server/src/ui/update.rs @@ -0,0 +1,540 @@ +//! The version button and the update slot in the title bar, and the install +//! question. See [`crate::update`]. +//! +//! The slot sits in the corner away from the window buttons. A status slides +//! in from below and the one it replaces leaves upwards. + +use std::f32::consts::TAU; +use std::mem::discriminant; +use std::sync::Arc; +use std::time::Duration; + +use eframe::egui::{ + self, Align2, Color32, CursorIcon, Galley, Pos2, Rect, Sense, Shape, Stroke, Vec2, pos2, vec2, +}; + +use crate::update::{self, Status}; + +use super::theme::{self, Brushes, Palette, Weight, white}; +use super::widgets::{self, Pill}; + +/// Size of the version's text, and of the slot's. +const VERSION_TEXT: f32 = 12.0; +const SLOT_TEXT: f32 = 12.5; + +/// The version button: height, side padding, and its icon's size and gap. +const BUTTON_HEIGHT: f32 = 20.0; +const PAD: f32 = 6.0; +const ICON: f32 = 10.0; +const ICON_GAP: f32 = 5.0; + +/// Gap between a status's text and its icon. +const SLOT_GAP: f32 = 6.0; + +/// A status change: fade time and slide distance. +const CHANGE_SECS: f64 = 0.28; +const SLIDE: f32 = 7.0; + +/// Colours of the slot's icons, and the stops of "New update available!". +const ARROW: Color32 = Color32::from_rgb(0xBB, 0xCE, 0xF7); +const SHEEN: [Color32; 4] = [ + Color32::from_rgb(0xF3, 0xB9, 0xB3), + Color32::from_rgb(0xC4, 0xE8, 0xCB), + Color32::from_rgb(0xBB, 0xCE, 0xF7), + Color32::from_rgb(0xF3, 0xB9, 0xB3), +]; + +/// Frame interval of the slot's looping animations. +const FRAME: Duration = Duration::from_millis(33); + +/// How long the gradient and arrow move after an update appears: 7 cycles of +/// 3 s and 15 of 1.4 s, so both stop where they started. +const SHEEN_SECS: f64 = 21.0; + +fn version_galley(ui: &egui::Ui) -> Arc { + ui.painter().layout_no_wrap( + format!("v{}", update::VERSION), + theme::font(VERSION_TEXT, Weight::Regular), + Color32::PLACEHOLDER, + ) +} + +/// Width of the version button. +pub fn version_width(ui: &egui::Ui) -> f32 { + let icon = if update::present() { + ICON_GAP + ICON + } else { + 0.0 + }; + PAD + version_galley(ui).size().x + icon + PAD +} + +/// Draw the version button with its left edge at `left`, centred on `y`. A +/// click checks for updates. `dim` fades it with an unfocused window. +pub fn version(ui: &mut egui::Ui, left: f32, y: f32, status: &Status, palette: &Palette, dim: f32) { + let galley = version_galley(ui); + let rect = Rect::from_min_size( + pos2(left, y - BUTTON_HEIGHT / 2.0), + vec2(version_width(ui), BUTTON_HEIGHT), + ); + let can = update::can_check(); + let busy = *status == Status::Checking; + let id = ui.id().with("update-version"); + let response = ui.interact(rect, id, if can { Sense::click() } else { Sense::hover() }); + let hovered = can && response.hovered(); + let hover = widgets::fade(ui, id, hovered); + let lit = if busy { 1.0 } else { hover }; + let painter = ui.painter(); + painter.rect_filled(rect, BUTTON_HEIGHT / 2.0, white(0.09 * hover * dim)); + let ink = theme::mix(palette.muted, Color32::WHITE, lit).gamma_multiply(dim); + let text_pos = pos2(rect.left() + PAD, y - galley.size().y / 2.0); + let text_right = text_pos.x + galley.size().x; + painter.galley(text_pos, galley, ink); + if update::present() { + let time = ui.input(|i| i.time); + let turn = ui + .ctx() + .animate_bool_with_time(id.with("turn"), hovered, 0.35); + let angle = if busy { + ui.ctx().request_repaint_after(FRAME); + (time as f32 / 0.8).fract() * TAU + } else { + ease(turn) * TAU / 2.0 + }; + let centre = pos2(text_right + ICON_GAP + ICON / 2.0, y + 1.0); + let alpha = 0.32 + 0.68 * lit; + refresh_icon(painter, centre, angle, ink.gamma_multiply(alpha)); + } + if can { + let response = response + .on_hover_text("Check for updates") + .on_hover_cursor(CursorIcon::PointingHand); + if response.clicked() { + update::check(); + } + } +} + +/// What the slot shows, and what it showed before the last change. +#[derive(Clone)] +struct Shown { + now: Status, + before: Status, + since: f64, +} + +/// Draw the update slot aligned to `anchor`. A click on an update acts on +/// it. +pub fn slot(ui: &mut egui::Ui, anchor: Pos2, align: Align2, palette: &Palette, dim: f32) { + let status = update::status(); + let id = ui.id().with("update-slot"); + let time = ui.input(|i| i.time); + let mut shown = ui.data(|d| d.get_temp::(id)).unwrap_or(Shown { + now: Status::Idle, + before: Status::Idle, + since: f64::NEG_INFINITY, + }); + if discriminant(&status) != discriminant(&shown.now) { + shown.before = std::mem::replace(&mut shown.now, status); + shown.since = time; + } else { + shown.now = status; + } + ui.data_mut(|d| d.insert_temp(id, shown.clone())); + + let t = ((time - shown.since) / CHANGE_SECS).clamp(0.0, 1.0) as f32; + let e = ease(t); + if t < 1.0 { + ui.ctx().request_repaint(); + let leaving = vec2(0.0, -SLIDE * e); + item( + ui, + &shown.before, + anchor + leaving, + align, + (1.0 - e) * dim, + f64::INFINITY, + palette, + None, + ); + } + let entering = vec2(0.0, SLIDE * (1.0 - e)); + item( + ui, + &shown.now, + anchor + entering, + align, + e * dim, + time - shown.since, + palette, + Some(id), + ); +} + +/// Width of `status` in the slot. +fn item_width(ui: &egui::Ui, status: &Status) -> f32 { + let Some((text, icon)) = parts(status) else { + return 0.0; + }; + let text = ui + .painter() + .layout_no_wrap(text, slot_font(), Color32::PLACEHOLDER) + .size() + .x; + let dots = if *status == Status::Checking { + dots_width(ui) + } else { + 0.0 + }; + text + dots + icon.map_or(0.0, |icon| SLOT_GAP + icon.size()) +} + +/// The icons of the slot. +#[derive(Clone, Copy, PartialEq)] +enum Icon { + Tick, + Cross, + Arrow, +} + +impl Icon { + fn size(self) -> f32 { + match self { + Self::Arrow => 11.0, + Self::Tick | Self::Cross => 12.0, + } + } + + /// Whether the icon comes before the text. + fn leads(self) -> bool { + self != Self::Arrow + } +} + +/// The text of `status` and its icon. `None` for an empty slot. +fn parts(status: &Status) -> Option<(String, Option)> { + Some(match status { + Status::Idle => return None, + Status::Checking => ("Checking".to_owned(), None), + Status::UpToDate => ("Up to date".to_owned(), Some(Icon::Tick)), + Status::Failed { title, .. } => ((*title).to_owned(), Some(Icon::Cross)), + Status::Available(_) => ("New update available!".to_owned(), Some(Icon::Arrow)), + Status::Downloading(f) => ( + format!("Downloading {}", super::status::percent(*f)), + Some(Icon::Arrow), + ), + Status::Ready => ("Update ready".to_owned(), Some(Icon::Arrow)), + }) +} + +fn slot_font() -> egui::FontId { + theme::font(SLOT_TEXT, Weight::Semibold) +} + +/// The tooltip, where there is one. +fn hint(status: &Status) -> Option { + match status { + Status::Failed { error, .. } => Some(error.clone()), + Status::Available(version) => Some(if cfg!(target_os = "macos") { + format!("Show Stemd {version}") + } else if cfg!(windows) { + format!("Download and install Stemd {version}") + } else { + format!("Open the Stemd {version} release page") + }), + Status::Ready => Some("Installs once nothing is running, or when Stemd quits".to_owned()), + _ => None, + } +} + +/// Draw one status at `alpha`, shown for `age` seconds. `click` is the id to +/// sense clicks with; the status leaving the slot gets none. +#[allow(clippy::too_many_arguments)] +fn item( + ui: &mut egui::Ui, + status: &Status, + anchor: Pos2, + align: Align2, + alpha: f32, + age: f64, + palette: &Palette, + click: Option, +) { + let Some((text, icon)) = parts(status) else { + return; + }; + let size = vec2(item_width(ui, status), BUTTON_HEIGHT); + let rect = align.anchor_size(anchor, size); + let time = ui.input(|i| i.time); + let y = rect.center().y; + let sheen = status.sheen(); + let hovered = click.is_some() && ui.rect_contains_pointer(rect.expand(3.0)); + // Move for SHEEN_SECS, then only under the pointer, so an update left for + // days does not keep the window repainting. + let clock = if age < SHEEN_SECS { + Some(age) + } else if hovered { + Some(time) + } else { + None + }; + let moving = sheen && clock.is_some(); + if moving || *status == Status::Checking { + ui.ctx().request_repaint_after(FRAME); + } + + let galley = ui + .painter() + .layout_no_wrap(text, slot_font(), Color32::PLACEHOLDER); + let galley = if sheen { + let phase = clock.map_or(0.0, |t| (t / 3.0).fract()) as f32; + sheened(&galley, phase, alpha) + } else { + galley + }; + let mut x = rect.left(); + if let Some(icon) = icon.filter(|icon| icon.leads()) { + draw_icon( + ui.painter(), + pos2(x, y - icon.size() / 2.0), + icon, + palette, + alpha, + ); + x += icon.size() + SLOT_GAP; + } + let text_pos = pos2(x, y - galley.size().y / 2.0); + x += galley.size().x; + ui.painter() + .galley(text_pos, galley, palette.text.gamma_multiply(alpha)); + + if *status == Status::Checking { + dots( + ui, + pos2(x, text_pos.y), + time, + palette.text.gamma_multiply(alpha), + ); + } + if let Some(icon) = icon.filter(|icon| !icon.leads()) { + let nudge = match clock.filter(|_| sheen) { + // Up 1 px to down 2 px and back, every 1.4 s. + Some(t) => -1.0 + 3.0 * (0.5 - 0.5 * (t as f32 / 1.4 * TAU).cos()), + None => -1.0, + }; + let origin = pos2(x + SLOT_GAP, y - icon.size() / 2.0 + nudge); + draw_icon(ui.painter(), origin, icon, palette, alpha); + } + + if let Some(id) = click { + let opens = status.opens(); + let mut response = ui.interact( + rect.expand(3.0), + id, + if opens { + Sense::click() + } else { + Sense::hover() + }, + ); + if let Some(hint) = hint(status) { + response = response.on_hover_text(hint); + } + if opens { + response = response.on_hover_cursor(CursorIcon::PointingHand); + if response.clicked() { + update::open(); + } + } + } +} + +/// `galley` coloured with the moving gradient of an update: a gradient 2.2 +/// times the text's width, scrolled by `phase` (0 to 1) of its length. +fn sheened(galley: &Galley, phase: f32, alpha: f32) -> Arc { + let mut galley = galley.clone(); + let width = galley.size().x.max(1.0); + for placed in &mut galley.rows { + let left = placed.pos.x; + let row = Arc::make_mut(&mut placed.row); + for vertex in &mut row.visuals.mesh.vertices { + let u = ((left + vertex.pos.x) / width / 2.2 + phase).fract(); + vertex.color = gradient(u).gamma_multiply(alpha); + } + } + Arc::new(galley) +} + +/// The sheen's colour at `u`, 0 to 1. Stops at 0, 1/3, 2/3 and 1. +fn gradient(u: f32) -> Color32 { + let scaled = u.clamp(0.0, 1.0) * 3.0; + let i = (scaled as usize).min(2); + theme::mix(SHEEN[i], SHEEN[i + 1], scaled - i as f32) +} + +fn dots_width(ui: &egui::Ui) -> f32 { + ui.painter() + .layout_no_wrap("...".to_owned(), slot_font(), Color32::PLACEHOLDER) + .size() + .x +} + +/// The three dots after "Checking", each brightening in turn every 1.2 s. +fn dots(ui: &egui::Ui, mut pos: Pos2, time: f64, colour: Color32) { + for n in 0..3 { + let p = ((time - 0.15 * n as f64) / 1.2).rem_euclid(1.0) as f32; + // Keyframes: 0.25 at 0%, 1 at 30%, 0.25 from 60%. + let rise = if p < 0.3 { + ease_in_out(p / 0.3) + } else if p < 0.6 { + 1.0 - ease_in_out((p - 0.3) / 0.3) + } else { + 0.0 + }; + let galley = ui + .painter() + .layout_no_wrap(".".to_owned(), slot_font(), Color32::PLACEHOLDER); + let advance = galley.size().x; + ui.painter() + .galley(pos, galley, colour.gamma_multiply(0.25 + 0.75 * rise)); + pos.x += advance; + } +} + +/// The refresh icon on a 10 by 10 grid, turned by `angle`: a circle open at +/// the top right, with an arrowhead there. +fn refresh_icon(painter: &egui::Painter, centre: Pos2, angle: f32, colour: Color32) { + let (sin, cos) = angle.sin_cos(); + let at = |x: f32, y: f32| { + let (dx, dy) = (x - 5.0, y - 5.0); + centre + vec2(dx * cos - dy * sin, dx * sin + dy * cos) + }; + let stroke = Stroke::new(0.9, colour); + // From 3 o'clock, clockwise, to the arrowhead at about 1:30. + let end = 313.3_f32.to_radians(); + let arc: Vec = (0..=32) + .map(|n| { + let a = end * n as f32 / 32.0; + at(5.0 + 3.5 * a.cos(), 5.0 + 3.5 * a.sin()) + }) + .collect(); + painter.add(Shape::line(arc, stroke)); + painter.add(Shape::line( + vec![at(8.7, 1.3), at(8.7, 3.6), at(6.4, 3.6)], + stroke, + )); +} + +/// Draw `icon` from its 12 by 12 grid, scaled to its size, at `origin`. +fn draw_icon(painter: &egui::Painter, origin: Pos2, icon: Icon, palette: &Palette, alpha: f32) { + let scale = icon.size() / 12.0; + let colour = match icon { + Icon::Tick => palette.good, + Icon::Cross => palette.danger, + Icon::Arrow => ARROW, + }; + let stroke = Stroke::new(1.8 * scale, colour.gamma_multiply(alpha)); + let paths: &[&[(f32, f32)]] = match icon { + Icon::Tick => &[&[(2.5, 6.4), (5.0, 8.8), (9.5, 3.4)]], + Icon::Cross => &[&[(3.0, 3.0), (9.0, 9.0)], &[(9.0, 3.0), (3.0, 9.0)]], + Icon::Arrow => &[ + &[(6.0, 1.8), (6.0, 8.8)], + &[(3.0, 6.2), (6.0, 9.2), (9.0, 6.2)], + ], + }; + for path in paths { + let points: Vec = path + .iter() + .map(|&(x, y)| origin + Vec2::new(x, y) * scale) + .collect(); + // Round caps. + for end in [points[0], points[points.len() - 1]] { + painter.circle_filled(end, stroke.width / 2.0, stroke.color); + } + painter.add(Shape::line(points, stroke)); + } +} + +/// CSS's cubic-bezier(.3, .8, .3, 1), near enough: a fast start that settles. +fn ease(t: f32) -> f32 { + 1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3) +} + +fn ease_in_out(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// The install question, while it is asked, and closing the window once Setup +/// has started. `idle` says nothing is running, queued or downloading. +pub fn dialog(ctx: &egui::Context, idle: bool, palette: &Palette, brushes: &Brushes) { + if update::tick(idle) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + let Some(version) = update::asking() else { + return; + }; + let mut answer = None; + egui::Modal::new(egui::Id::new("stemd-update")) + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + super::blur::frosted(ui, &widgets::dialog_look(palette), |ui| { + answer = question(ui, palette, brushes, &version); + }); + }); + if let Some(install) = answer { + update::answer(install); + } +} + +/// The question's contents. Returns true to install, false for not now. +fn question( + ui: &mut egui::Ui, + palette: &Palette, + brushes: &Brushes, + version: &str, +) -> Option { + ui.set_max_width(320.0); + ui.label( + egui::RichText::new(format!("Stemd {version}")) + .font(theme::font(17.0, Weight::Semibold)) + .color(palette.text), + ); + ui.add_space(6.0); + ui.label( + egui::RichText::new( + "Download the installer and run it? Once the download is done and \ + nothing is running, Stemd quits, installs the update and starts \ + again.", + ) + .font(theme::font(13.0, Weight::Regular)) + .color(palette.muted), + ); + ui.add_space(16.0); + let mut answer = None; + ui.horizontal(|ui| { + for (label, kind, install) in [ + ("Not now", Pill::Ghost, false), + ("Download and install", Pill::Light, true), + ] { + let width = widgets::pill_width(ui, label, 30.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 30.0), Sense::hover()); + let id = ui.id().with(("update-answer", label)); + if widgets::pill(ui, rect, id, label, kind, true, palette, brushes).clicked() { + answer = Some(install); + } + } + }); + answer +} + +/// Gap kept on each side of the slot in the status bar. +#[cfg(all(unix, not(target_os = "macos")))] +const SPACE: f32 = 14.0; + +/// Whether `status` fits between `left` and `right` in the status bar, and +/// where it starts if so. +#[cfg(all(unix, not(target_os = "macos")))] +pub fn fits(ui: &egui::Ui, status: &Status, left: f32, right: f32) -> Option { + (left + SPACE + item_width(ui, status) + SPACE <= right).then_some(left + SPACE) +} diff --git a/crates/stemd-server/src/update/github.rs b/crates/stemd-server/src/update/github.rs new file mode 100644 index 0000000..5a7bb0d --- /dev/null +++ b/crates/stemd-server/src/update/github.rs @@ -0,0 +1,148 @@ +//! The newest published app release, from GitHub's API. +// macOS builds this for its tests only. +#![cfg_attr(target_os = "macos", allow(dead_code))] + +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// The most recent releases. Model releases such as `models-v3` are in this list +/// and can be GitHub's "latest", so [`latest`] picks the newest `v*` tag. +const RELEASES: &str = "https://api.github.com/repos/nsaintot/stemd/releases?per_page=30"; + +/// A release, as the API describes it. +#[derive(Clone, Debug, Deserialize)] +pub struct Release { + pub tag_name: String, + pub html_url: String, + #[serde(default)] + pub draft: bool, + #[serde(default)] + pub prerelease: bool, + #[serde(default)] + pub assets: Vec, +} + +/// One file of a release. +#[derive(Clone, Debug, Deserialize)] +pub struct Asset { + pub name: String, + pub browser_download_url: String, + pub size: u64, + /// `sha256:`, which GitHub computes on upload. + #[serde(default)] + pub digest: Option, +} + +impl Release { + /// The version, without the tag's `v`. + pub fn version(&self) -> &str { + self.tag_name.trim_start_matches('v') + } + + /// The Windows installer, `stemd--setup.exe`. + #[cfg_attr(not(windows), allow(dead_code))] + pub fn installer(&self) -> Option<&Asset> { + let name = format!("stemd-{}-setup.exe", self.version()); + self.assets.iter().find(|a| a.name == name) + } +} + +impl Asset { + /// The SHA-256 GitHub recorded, as lowercase hex. + #[cfg_attr(not(windows), allow(dead_code))] + pub fn sha256(&self) -> Option { + let hex = self.digest.as_deref()?.strip_prefix("sha256:")?; + (hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())) + .then(|| hex.to_ascii_lowercase()) + } +} + +/// Fetch the newest published app release: a `v` tag, neither a +/// draft nor a pre-release. +pub fn latest() -> Result { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(20))) + .build() + .into(); + let body = agent + .get(RELEASES) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", concat!("stemd/", env!("CARGO_PKG_VERSION"))) + .call() + .map_err(|e| anyhow::anyhow!("GET {RELEASES}: {e}"))? + .into_body() + .read_to_string() + .context("reading the releases")?; + let releases: Vec = serde_json::from_str(&body).context("parsing the releases")?; + newest(releases).context("no published app release") +} + +/// The newest of `releases` that is an app release. +fn newest(releases: Vec) -> Option { + releases + .into_iter() + .filter(|r| !r.draft && !r.prerelease && r.tag_name.starts_with('v')) + .filter_map(|r| Some((super::Version::parse(&r.tag_name)?, r))) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, r)| r) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"{ + "tag_name": "v0.2.0", + "html_url": "https://github.com/nsaintot/stemd/releases/tag/v0.2.0", + "draft": false, + "assets": [ + {"name": "stemd-0.2.0-windows-x64.zip", "size": 1, "digest": null, + "browser_download_url": "https://example.invalid/zip"}, + {"name": "stemd-0.2.0-setup.exe", "size": 84000000, + "digest": "sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "browser_download_url": "https://example.invalid/setup"} + ] + }"#; + + #[test] + fn finds_the_installer_and_its_digest() { + let release: Release = serde_json::from_str(SAMPLE).unwrap(); + assert_eq!(release.version(), "0.2.0"); + let setup = release.installer().unwrap(); + assert_eq!(setup.browser_download_url, "https://example.invalid/setup"); + assert_eq!( + setup.sha256().unwrap(), + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + ); + } + + #[test] + fn picks_the_newest_app_release() { + let releases: Vec = serde_json::from_str( + r#"[ + {"tag_name": "models-v4", "html_url": "m"}, + {"tag_name": "v0.3.0", "html_url": "draft", "draft": true}, + {"tag_name": "v0.2.1-rc1", "html_url": "pre", "prerelease": true}, + {"tag_name": "v0.1.9", "html_url": "old"}, + {"tag_name": "v0.2.0", "html_url": "new"} + ]"#, + ) + .unwrap(); + assert_eq!(newest(releases).unwrap().html_url, "new"); + assert!(newest(Vec::new()).is_none()); + } + + #[test] + fn a_missing_or_malformed_digest_is_none() { + let mut asset: Asset = + serde_json::from_str(r#"{"name": "a", "size": 1, "browser_download_url": "u"}"#) + .unwrap(); + assert_eq!(asset.sha256(), None); + asset.digest = Some("sha512:00".into()); + assert_eq!(asset.sha256(), None); + asset.digest = Some("sha256:00".into()); + assert_eq!(asset.sha256(), None); + } +} diff --git a/crates/stemd-server/src/update/installer.rs b/crates/stemd-server/src/update/installer.rs new file mode 100644 index 0000000..ef22bcc --- /dev/null +++ b/crates/stemd-server/src/update/installer.rs @@ -0,0 +1,278 @@ +//! Windows: download Setup, check it against the digest GitHub recorded, and +//! start it so it can replace this copy once the app has quit. +//! +//! Setup runs silently with this copy's install mode, and with `/relaunch` +//! when the app closes for it: it reuses the location and GPU choice of the +//! copy it replaces, and starts Stemd again when it is done. + +use std::io::Read; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use sha2::{Digest, Sha256}; + +use super::github::Release; + +/// Setup, downloaded and verified. +#[derive(Clone, Debug)] +pub struct Setup { + path: PathBuf, + sha256: String, +} + +/// Where downloads go. +fn download_dir() -> PathBuf { + std::env::temp_dir().join("stemd-update") +} + +/// Remove earlier downloads. Setup started by the previous run may still hold +/// its file, which is then left for the next launch. +pub fn remove_downloads() { + let _ = std::fs::remove_dir_all(download_dir()); +} + +/// An install mode, as Setup's command line names it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Mode { + AllUsers, + CurrentUser, +} + +impl Mode { + fn arg(self) -> &'static str { + match self { + Self::AllUsers => "/AllUsers", + Self::CurrentUser => "/CurrentUser", + } + } +} + +/// How Setup installed this copy, from the uninstall key naming its folder; `None` for the zip. +pub fn install_mode() -> Option { + use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + + let here = std::env::current_exe().ok()?.parent()?.to_path_buf(); + [ + (HKEY_CURRENT_USER, Mode::CurrentUser), + (HKEY_LOCAL_MACHINE, Mode::AllUsers), + ] + .into_iter() + .find(|(hive, _)| install_location(*hive).is_some_and(|there| same_dir(&here, &there))) + .map(|(_, mode)| mode) +} + +/// Whether this account can install in `mode`: all users needs an administrator account. +pub fn can_install(mode: Mode) -> bool { + mode == Mode::CurrentUser || can_elevate() +} + +/// Whether this account is an administrator, elevated or not. +fn can_elevate() -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + GetTokenInformation, TOKEN_ELEVATION_TYPE, TOKEN_QUERY, TokenElevationType, + TokenElevationTypeFull, TokenElevationTypeLimited, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + use windows_sys::Win32::UI::Shell::IsUserAnAdmin; + + let mut token: HANDLE = std::ptr::null_mut(); + let mut kind: TOKEN_ELEVATION_TYPE = 0; + let mut len = 0u32; + // SAFETY: the token is this process's, queried into a value of the size + // given, and closed once read. + let split = unsafe { + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return false; + } + let ok = GetTokenInformation( + token, + TokenElevationType, + (&mut kind as *mut TOKEN_ELEVATION_TYPE).cast(), + std::mem::size_of::() as u32, + &mut len, + ); + CloseHandle(token); + ok != 0 && (kind == TokenElevationTypeFull || kind == TokenElevationTypeLimited) + }; + // Without a split token (UAC off, or a standard user), check the group. + // SAFETY: no arguments. + split || unsafe { IsUserAnAdmin() != 0 } +} + +/// Download `release`'s Setup and check its size and SHA-256. `progress` +/// receives the fraction done. +pub fn download(release: &Release, mut progress: impl FnMut(f32)) -> Result { + let asset = release + .installer() + .context("the release has no Setup for Windows")?; + let expected = asset + .sha256() + .context("the release lists no SHA-256 for Setup")?; + let dir = download_dir(); + std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + crate::disk::ensure_room_for(&dir, asset.size)?; + + // No timeout covers a single read: `release` abandons a download with no + // progress for 60 s. + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(30))) + .timeout_recv_response(Some(Duration::from_secs(60))) + .timeout_recv_body(Some(Duration::from_secs(60 * 60))) + .build() + .into(); + let response = agent + .get(&asset.browser_download_url) + .call() + .map_err(|e| anyhow::anyhow!("GET {}: {e}", asset.browser_download_url))?; + let partial = dir.join(format!("{}.part", asset.name)); + let total = asset.size.max(1) as f32; + let streamed = + crate::models::stream_to_file(response.into_body().into_reader(), &partial, |done| { + progress(done as f32 / total) + }); + let (digest, written) = match streamed { + Ok(done) => done, + Err(err) => { + let _ = std::fs::remove_file(&partial); + return Err(err); + } + }; + if written != asset.size || digest != expected { + let _ = std::fs::remove_file(&partial); + bail!( + "{} does not match the release: {written} bytes with SHA-256 {digest}, \ + expected {} bytes with {expected}", + asset.name, + asset.size + ); + } + progress(1.0); + let path = dir.join(&asset.name); + std::fs::rename(&partial, &path).with_context(|| format!("publishing {}", path.display()))?; + tracing::info!("downloaded {}", path.display()); + Ok(Setup { + path, + sha256: expected, + }) +} + +impl Setup { + /// Check the file again and start it silently in `mode`, with `/relaunch` to restart Stemd. + pub fn run(&self, mode: Mode, relaunch: bool) -> Result<()> { + // Read sharing only, so the file cannot change before Setup starts. + const FILE_SHARE_READ: u32 = 1; + let mut file = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&self.path) + .with_context(|| format!("opening {}", self.path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = vec![0; 1 << 20]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let digest = format!("{:x}", hasher.finalize()); + if digest != self.sha256 { + bail!("{} changed after it was verified", self.path.display()); + } + tracing::info!("starting {}", self.path.display()); + let args = format!( + "/S {}{}", + mode.arg(), + if relaunch { " /relaunch" } else { "" } + ); + shell_open(&self.path, Some(&args)).map_err(anyhow::Error::msg)?; + drop(file); + Ok(()) + } +} + +/// Whether two directories are the same: resolved as Windows resolves them +/// (case, short names, links), then compared without a trailing separator. +fn same_dir(a: &Path, b: &Path) -> bool { + let norm = |p: &Path| { + std::fs::canonicalize(p) + .unwrap_or_else(|_| p.to_path_buf()) + .to_string_lossy() + .trim_end_matches(['\\', '/']) + .to_lowercase() + }; + norm(a) == norm(b) +} + +/// `InstallLocation` from stemd's uninstall key in `hive`, in the 64-bit +/// registry view. +fn install_location(hive: windows_sys::Win32::System::Registry::HKEY) -> Option { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::Registry::{ + RRF_RT_REG_SZ, RRF_SUBKEY_WOW6464KEY, RegGetValueW, + }; + + let wide = |s: &str| s.encode_utf16().chain([0]).collect::>(); + let key = wide(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\stemd"); + let value = wide("InstallLocation"); + let read = |buf: *mut u16, bytes: &mut u32| { + // SAFETY: NUL-terminated names, and `bytes` is the size of `buf`, or + // 0 with a null `buf` to ask for the size. + unsafe { + RegGetValueW( + hive, + key.as_ptr(), + value.as_ptr(), + RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, + std::ptr::null_mut(), + buf.cast(), + bytes, + ) + } + }; + let mut bytes = 0u32; + if read(std::ptr::null_mut(), &mut bytes) != 0 { + return None; + } + let mut buf = vec![0u16; (bytes as usize).div_ceil(2)]; + if read(buf.as_mut_ptr(), &mut bytes) != 0 { + return None; + } + // `bytes` includes the terminating NUL. + buf.truncate((bytes as usize / 2).saturating_sub(1)); + Some(PathBuf::from(std::ffi::OsString::from_wide(&buf))) +} + +/// Open a file or URL as Explorer would, with `args` for a program. Setup asks +/// for elevation in its manifest, which only the shell honours: +/// `CreateProcess` refuses it. +pub fn shell_open(target: &Path, args: Option<&str>) -> Result<(), String> { + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let wide = |s: &std::ffi::OsStr| s.encode_wide().chain([0]).collect::>(); + let verb = wide("open".as_ref()); + let file = wide(target.as_os_str()); + let args = args.map(|a| wide(a.as_ref())); + // SAFETY: every string is NUL-terminated and outlives the call. + let code = unsafe { + ShellExecuteW( + std::ptr::null_mut(), + verb.as_ptr(), + file.as_ptr(), + args.as_ref().map_or(std::ptr::null(), |a| a.as_ptr()), + std::ptr::null(), + SW_SHOWNORMAL, + ) + } as isize; + // Values up to 32 are error codes. + if code > 32 { + Ok(()) + } else { + Err(format!("ShellExecute failed with code {code}")) + } +} diff --git a/crates/stemd-server/src/update/mod.rs b/crates/stemd-server/src/update/mod.rs new file mode 100644 index 0000000..7ea4c3d --- /dev/null +++ b/crates/stemd-server/src/update/mod.rs @@ -0,0 +1,250 @@ +//! Finding and installing a newer release. +//! +//! | host | check | install | +//! | ------- | ----------------------------------------- | ------------------------------------ | +//! | macOS | Sparkle, from the release's `appcast.xml` | Sparkle | +//! | Windows | GitHub's newest `v*` release | download Setup, run it once idle | +//! | Linux | GitHub's newest `v*` release | open the release page in the browser | +//! +//! The state is process-wide: the title bar and the status bar read it with +//! [`status`] every frame, and the checks write it from their own threads. + +#[cfg(any(not(target_os = "macos"), test))] +mod github; +#[cfg(windows)] +mod installer; +#[cfg(not(target_os = "macos"))] +mod release; +#[cfg(target_os = "macos")] +mod sparkle; + +#[cfg(not(target_os = "macos"))] +use release as imp; +#[cfg(target_os = "macos")] +use sparkle as imp; + +use std::sync::OnceLock; + +use eframe::egui; + +/// The running version, such as `0.1.4`. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// What the update slot in the title bar shows. The slot is empty while +/// `Idle`. +#[derive(Clone, Debug, PartialEq)] +pub enum Status { + Idle, + /// A check the user asked for is running. + Checking, + /// A check the user asked for found nothing newer. + UpToDate, + /// A check the user asked for, or the download, failed. `title` is what + /// the slot says, `error` the reason. + Failed { + title: &'static str, + error: String, + }, + /// A newer release, by its version. + Available(String), + /// The installer download, 0 to 1. + #[cfg_attr(not(windows), allow(dead_code))] + Downloading(f32), + /// Setup is downloaded and starts once nothing is running, or when the + /// app quits. + #[cfg_attr(not(windows), allow(dead_code))] + Ready, +} + +impl Status { + /// Whether a click on the slot acts on it. + pub fn opens(&self) -> bool { + matches!(self, Self::Available(_)) + } + + /// Whether the slot draws it in the update's gradient. + pub fn sheen(&self) -> bool { + matches!(self, Self::Available(_) | Self::Ready) + } +} + +static CTX: OnceLock = OnceLock::new(); + +/// Start the updater: Sparkle and its menu item on macOS, a background check +/// elsewhere. Call once, on the main thread. +pub fn start(ctx: &egui::Context) { + if CTX.set(ctx.clone()).is_ok() { + imp::start(); + } +} + +/// Repaint the window, from any thread. +fn repaint() { + if let Some(ctx) = CTX.get() { + ctx.request_repaint(); + } +} + +/// What the update slot shows now. +pub fn status() -> Status { + imp::status() +} + +/// Whether this build has an updater: macOS needs Sparkle in the bundle. +pub fn present() -> bool { + imp::present() +} + +/// Whether a click on the version checks for updates. +#[cfg_attr(all(unix, not(target_os = "macos")), allow(dead_code))] +pub fn can_check() -> bool { + imp::can_check() +} + +/// Check for updates and report the result in the slot. +#[cfg_attr(all(unix, not(target_os = "macos")), allow(dead_code))] +pub fn check() { + imp::check() +} + +/// The slot was clicked: act on the update it shows. +pub fn open() { + imp::open() +} + +/// The version the install question is about, while it is on screen. +pub fn asking() -> Option { + #[cfg(windows)] + return release::asking(); + #[cfg(not(windows))] + None +} + +/// Answer the install question: download and install, or not now. +pub fn answer(install: bool) { + #[cfg(windows)] + release::answer(install); + #[cfg(not(windows))] + let _ = install; +} + +/// Start Setup once it is ready and `idle`; true when it has started and the window should close. +pub fn tick(idle: bool) -> bool { + #[cfg(windows)] + return release::tick(idle); + #[cfg(not(windows))] + { + let _ = idle; + false + } +} + +/// Start Setup without `/relaunch` if it is ready but not started. Called as the app quits. +pub fn run_installer() { + #[cfg(windows)] + release::run_installer(); +} + +/// Open `url` in the default browser. +#[cfg(not(target_os = "macos"))] +fn open_in_browser(url: &str) { + #[cfg(windows)] + let result = installer::shell_open(std::path::Path::new(url), None); + #[cfg(not(windows))] + let result = std::process::Command::new("xdg-open") + .arg(url) + // The packaged launcher points this at the bundled CUDA libraries. + .env_remove("LD_LIBRARY_PATH") + .spawn() + .map(|mut child| { + // Wait on the child so it does not stay a zombie. + std::thread::spawn(move || child.wait()); + }) + .map_err(|e| e.to_string()); + if let Err(err) = result { + tracing::warn!("could not open {url}: {err}"); + } +} + +/// A version. Fields in comparison order: a pre-release sorts before its +/// release, so `0.2.0-rc1` < `0.2.0`, and `rc2` < `rc10`. +// Only the tests use this on macOS, where Sparkle compares versions. +#[cfg_attr(target_os = "macos", allow(dead_code))] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +struct Version { + numbers: [u64; 3], + release: bool, + pre: Vec<(String, u64)>, +} + +#[cfg_attr(target_os = "macos", allow(dead_code))] +impl Version { + /// Parse `1.2.3`, `v1.2.3` or `1.2.3-rc1`. + fn parse(text: &str) -> Option { + let text = text.trim().trim_start_matches('v'); + let (core, pre) = text.split_once('-').unwrap_or((text, "")); + let mut numbers = [0; 3]; + let mut parts = core.split('.'); + for n in &mut numbers { + *n = parts.next()?.parse().ok()?; + } + if parts.next().is_some() { + return None; + } + Some(Self { + numbers, + release: pre.is_empty(), + pre: pre.split('.').map(split_number).collect(), + }) + } +} + +/// A pre-release part as its text and trailing number: `rc10` is `("rc", 10)` +/// and `2` is `("", 2)`, so numbers compare as numbers. +fn split_number(part: &str) -> (String, u64) { + let digits = part.len() - part.trim_end_matches(|c: char| c.is_ascii_digit()).len(); + let (text, number) = part.split_at(part.len() - digits); + (text.to_owned(), number.parse().unwrap_or(0)) +} + +/// Whether `latest` is newer than `running`. False when either does not parse. +#[cfg_attr(target_os = "macos", allow(dead_code))] +pub fn newer(latest: &str, running: &str) -> bool { + match (Version::parse(latest), Version::parse(running)) { + (Some(latest), Some(running)) => latest > running, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_compares_numbers_not_text() { + assert!(newer("v0.2.0", "0.1.4")); + assert!(newer("0.1.10", "0.1.9")); + assert!(newer("1.0.0", "0.99.99")); + assert!(!newer("0.1.4", "0.1.4")); + assert!(!newer("v0.1.3", "0.1.4")); + } + + #[test] + fn a_release_is_newer_than_its_pre_release() { + assert!(newer("0.2.0", "0.2.0-rc1")); + assert!(!newer("0.2.0-rc1", "0.2.0")); + assert!(newer("0.2.0-rc2", "0.2.0-rc1")); + assert!(newer("0.2.0-rc10", "0.2.0-rc2")); + assert!(newer("0.2.0-beta.11", "0.2.0-beta.2")); + assert!(newer("0.2.0-rc1", "0.2.0-beta9")); + assert!(newer("0.2.0-rc1", "0.1.9")); + } + + #[test] + fn unparsable_versions_are_never_newer() { + for bad in ["", "v", "0.2", "0.2.0.1", "latest", "0.x.0"] { + assert!(!newer(bad, "0.1.4"), "{bad:?}"); + assert!(!newer("0.1.4", bad), "{bad:?}"); + } + } +} diff --git a/crates/stemd-server/src/update/release.rs b/crates/stemd-server/src/update/release.rs new file mode 100644 index 0000000..fd34568 --- /dev/null +++ b/crates/stemd-server/src/update/release.rs @@ -0,0 +1,378 @@ +//! Windows and Linux: check GitHub's newest release at launch, once a day +//! and on a click on the version. +//! +//! On Linux, and for a Windows copy Setup did not install (the zip), a newer +//! release opens its page in the browser. On an installed Windows copy, it +//! offers to download Setup. Once Setup is ready and nothing is running, the +//! app starts it and closes; Setup installs and starts Stemd again. + +use std::time::{Duration, Instant, SystemTime}; + +use parking_lot::Mutex; + +use super::github::{self, Release}; +use super::{Status, VERSION, repaint}; + +/// Time between background checks, in wall time so a computer that sleeps still checks daily. +const RECHECK: Duration = Duration::from_secs(24 * 60 * 60); + +/// How often the check thread looks at the clock. +const WAKE: Duration = Duration::from_secs(60 * 60); + +/// How long "Up to date" and "Check failed" stay in the slot. +const NOTICE: Duration = Duration::from_millis(2600); + +/// A download that has not moved for this long has stalled. +#[cfg(windows)] +const STALL: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug)] +enum Phase { + Idle, + Checking { + user: bool, + }, + UpToDate { + until: Instant, + }, + /// `after` is what the slot returns to once the notice expires. + Failed { + title: &'static str, + error: String, + until: Instant, + after: Option, + }, + Available(Release), + /// The install question is on screen. + #[cfg(windows)] + Asking(Release), + /// `attempt` numbers the download, so the result of an abandoned one is ignored. + #[cfg(windows)] + Downloading { + release: Release, + fraction: f32, + moved: Instant, + attempt: u64, + }, + /// Setup is downloaded and verified. + #[cfg(windows)] + Ready { + release: Release, + setup: super::installer::Setup, + mode: super::installer::Mode, + launch: Launch, + }, +} + +/// Progress of starting Setup. +#[cfg(windows)] +#[derive(Clone, Copy, Debug, PartialEq)] +enum Launch { + /// Until nothing is running, queued or downloading. + Waiting, + /// Hashing the file and waiting on the consent prompt, off the UI thread. + Starting, + /// Setup runs; the window closes on the next frame. + Started, + /// The window was asked to close. + Closing, +} + +static PHASE: Mutex = Mutex::new(Phase::Idle); + +fn set(phase: Phase) { + *PHASE.lock() = phase; + repaint(); +} + +pub fn start() { + #[cfg(windows)] + super::installer::remove_downloads(); + std::thread::Builder::new() + .name("update-check".into()) + .spawn(|| { + let mut last: Option = None; + loop { + let due = last.is_none_or(|t| t.elapsed().is_ok_and(|e| e >= RECHECK)); + if due { + check_now(false); + last = Some(SystemTime::now()); + } + std::thread::sleep(WAKE); + } + }) + .expect("spawning the update check"); +} + +/// Ask GitHub for the newest release. `user` is a click, which reports what +/// it found; a background check stays silent unless there is an update. +fn check_now(user: bool) { + let before = { + let mut phase = PHASE.lock(); + match &mut *phase { + // A click during a background check reports its result. + Phase::Checking { user: reports } => { + *reports |= user; + return; + } + #[cfg(windows)] + Phase::Asking(_) | Phase::Downloading { .. } | Phase::Ready { .. } => return, + _ => {} + } + std::mem::replace(&mut *phase, Phase::Checking { user }) + }; + repaint(); + // An update already known survives a failed check. + let known = match before { + Phase::Available(release) => Some(release), + Phase::Failed { after, .. } => after, + _ => None, + }; + let latest = github::latest(); + let user = matches!(*PHASE.lock(), Phase::Checking { user: true }); + let until = Instant::now() + NOTICE; + set(match latest { + Ok(release) if super::newer(release.version(), VERSION) => { + tracing::info!("update available: {}", release.version()); + Phase::Available(release) + } + Ok(_) if user => Phase::UpToDate { until }, + Ok(_) => Phase::Idle, + Err(err) => { + tracing::warn!("update check failed: {err:#}"); + match (user, known) { + (true, after) => Phase::Failed { + title: "Check failed", + error: format!("{err:#}"), + until, + after, + }, + (false, Some(release)) => Phase::Available(release), + (false, None) => Phase::Idle, + } + } + }); +} + +pub fn status() -> Status { + let mut phase = PHASE.lock(); + let now = Instant::now(); + match &mut *phase { + Phase::Idle | Phase::Checking { user: false } => Status::Idle, + Phase::Checking { user: true } => Status::Checking, + Phase::UpToDate { until } if now >= *until => { + *phase = Phase::Idle; + Status::Idle + } + Phase::Failed { until, after, .. } if now >= *until => { + let after = after.take(); + let status = after.as_ref().map_or(Status::Idle, |release| { + Status::Available(release.version().to_owned()) + }); + *phase = after.map_or(Phase::Idle, Phase::Available); + status + } + Phase::UpToDate { .. } => Status::UpToDate, + Phase::Failed { title, error, .. } => Status::Failed { + title, + error: error.clone(), + }, + Phase::Available(release) => Status::Available(release.version().to_owned()), + #[cfg(windows)] + Phase::Asking(release) => Status::Available(release.version().to_owned()), + // A blocked read cannot be interrupted, so a stalled download is + // abandoned, and its thread's result is ignored by `attempt`. + #[cfg(windows)] + Phase::Downloading { release, moved, .. } if now.duration_since(*moved) >= STALL => { + tracing::warn!("update download stalled"); + let error = format!("no data for {} s", STALL.as_secs()); + *phase = Phase::Failed { + title: "Download failed", + error: error.clone(), + until: now + NOTICE, + after: Some(release.clone()), + }; + Status::Failed { + title: "Download failed", + error, + } + } + #[cfg(windows)] + Phase::Downloading { fraction, .. } => Status::Downloading(*fraction), + #[cfg(windows)] + Phase::Ready { .. } => Status::Ready, + } +} + +pub fn present() -> bool { + true +} + +pub fn can_check() -> bool { + match &*PHASE.lock() { + Phase::Checking { user } => !user, + #[cfg(windows)] + Phase::Asking(_) | Phase::Downloading { .. } | Phase::Ready { .. } => false, + _ => true, + } +} + +pub fn check() { + std::thread::spawn(|| check_now(true)); +} + +pub fn open() { + let phase = PHASE.lock().clone(); + if let Phase::Available(release) = phase { + #[cfg(windows)] + if installable(&release) { + set(Phase::Asking(release)); + return; + } + super::open_in_browser(&release.html_url); + } +} + +/// Whether the app can install `release`: Setup has a SHA-256 and this account may update it. +#[cfg(windows)] +fn installable(release: &Release) -> bool { + use super::installer; + release.installer().and_then(|a| a.sha256()).is_some() + && installer::install_mode().is_some_and(installer::can_install) +} + +#[cfg(windows)] +pub fn asking() -> Option { + match &*PHASE.lock() { + Phase::Asking(release) => Some(release.version().to_owned()), + _ => None, + } +} + +#[cfg(windows)] +pub fn answer(install: bool) { + use std::sync::atomic::{AtomicU64, Ordering}; + static ATTEMPTS: AtomicU64 = AtomicU64::new(0); + + let Phase::Asking(release) = PHASE.lock().clone() else { + return; + }; + let mode = match super::installer::install_mode() { + Some(mode) if install => mode, + _ => { + set(Phase::Available(release)); + return; + } + }; + let attempt = ATTEMPTS.fetch_add(1, Ordering::Relaxed); + set(Phase::Downloading { + release: release.clone(), + fraction: 0.0, + moved: Instant::now(), + attempt, + }); + std::thread::spawn(move || { + let progress = |fraction: f32| { + if let Phase::Downloading { + fraction: f, + moved, + attempt: a, + .. + } = &mut *PHASE.lock() + && *a == attempt + { + *f = fraction; + *moved = Instant::now(); + } + repaint(); + }; + let result = super::installer::download(&release, progress); + let mut phase = PHASE.lock(); + if !matches!(&*phase, Phase::Downloading { attempt: a, .. } if *a == attempt) { + return; + } + *phase = match result { + Ok(setup) => Phase::Ready { + release, + setup, + mode, + launch: Launch::Waiting, + }, + Err(err) => { + tracing::warn!("update download failed: {err:#}"); + Phase::Failed { + title: "Download failed", + error: format!("{err:#}"), + until: Instant::now() + NOTICE, + after: Some(release), + } + } + }; + drop(phase); + repaint(); + }); +} + +#[cfg(windows)] +pub fn tick(idle: bool) -> bool { + let mut phase = PHASE.lock(); + let Phase::Ready { + release, + setup, + mode, + launch, + } = &mut *phase + else { + return false; + }; + match launch { + Launch::Waiting if idle => { + *launch = Launch::Starting; + let (release, setup, mode) = (release.clone(), setup.clone(), *mode); + std::thread::spawn(move || { + let started = setup.run(mode, true); + let mut phase = PHASE.lock(); + if let Phase::Ready { launch, .. } = &mut *phase { + match started { + Ok(()) => *launch = Launch::Started, + Err(err) => { + tracing::warn!("could not start Setup: {err:#}"); + *phase = Phase::Failed { + title: "Install failed", + error: format!("{err:#}"), + until: Instant::now() + NOTICE, + after: Some(release), + }; + } + } + } + drop(phase); + repaint(); + }); + false + } + Launch::Started => { + *launch = Launch::Closing; + true + } + _ => false, + } +} + +#[cfg(windows)] +pub fn run_installer() { + let ready = match &*PHASE.lock() { + Phase::Ready { + setup, + mode, + launch: Launch::Waiting, + .. + } => Some((setup.clone(), *mode)), + _ => None, + }; + if let Some((setup, mode)) = ready + && let Err(err) = setup.run(mode, false) + { + tracing::warn!("could not start Setup: {err:#}"); + } +} diff --git a/crates/stemd-server/src/update/sparkle.rs b/crates/stemd-server/src/update/sparkle.rs new file mode 100644 index 0000000..3a0faf2 --- /dev/null +++ b/crates/stemd-server/src/update/sparkle.rs @@ -0,0 +1,282 @@ +//! macOS: Sparkle 2, loaded at run time from `Contents/Frameworks`. +//! +//! Sparkle reads the feed named by `SUFeedURL` in `Info.plist`, checks once a +//! day, shows its own window, verifies the `.dmg` against `SUPublicEDKey`, +//! replaces the bundle and relaunches. A click on the version runs a probe, +//! which reports to the slot without a window; a click on "New update +//! available!" opens Sparkle's window. The app menu also gets "Check for +//! Updates…". A build without the framework, such as `cargo run`, has no +//! updater. Every call here runs on the main thread. + +use std::cell::RefCell; +use std::time::{Duration, Instant}; + +use objc2::rc::{Allocated, Retained}; +use objc2::runtime::{AnyClass, AnyObject, NSObject}; +use objc2::{AllocAnyThread, define_class, msg_send, sel}; +use objc2_foundation::{NSBundle, NSString}; +use parking_lot::Mutex; + +use super::{Status, repaint}; + +/// How long "Up to date" and "Check failed" stay in the slot. +const NOTICE: Duration = Duration::from_millis(2600); + +/// Sparkle's error code for a check that found nothing newer. +const NO_UPDATE: isize = 1001; + +/// How long a probe may go unanswered before it counts as failed. +const NO_ANSWER: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug)] +enum Phase { + Idle, + /// A probe the user asked for. + Checking { + since: Instant, + }, + UpToDate { + until: Instant, + }, + Failed { + error: String, + until: Instant, + }, + Available(String), +} + +static PHASE: Mutex = Mutex::new(Phase::Idle); + +fn set(phase: Phase) { + *PHASE.lock() = phase; + repaint(); +} + +thread_local! { + /// The controller, and its delegate, which it holds weakly. + static UPDATER: RefCell, Retained)>> = + const { RefCell::new(None) }; +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements and Delegate has no + // Drop. + #[unsafe(super(NSObject))] + #[name = "StemdUpdaterDelegate"] + struct Delegate; + + // The SPUUpdaterDelegate methods the app answers. Sparkle asks + // `respondsToSelector:` for each. + impl Delegate { + #[unsafe(method(updater:didFindValidUpdate:))] + fn did_find(&self, _updater: &AnyObject, item: &AnyObject) { + // SAFETY: SUAppcastItem's `displayVersionString`, an NSString. + let version: Option> = + unsafe { msg_send![item, displayVersionString] }; + set(Phase::Available( + version.map(|v| v.to_string()).unwrap_or_default(), + )); + } + + #[unsafe(method(updaterDidNotFindUpdate:))] + fn did_not_find(&self, _updater: &AnyObject) { + let checking = matches!(*PHASE.lock(), Phase::Checking { .. }); + set(if checking { + Phase::UpToDate { + until: Instant::now() + NOTICE, + } + } else { + Phase::Idle + }); + } + + // Called after the two above, so a probe still checking here failed. + #[unsafe(method(updater:didFinishUpdateCycleForUpdateCheck:error:))] + fn did_finish(&self, _updater: &AnyObject, _check: isize, error: Option<&AnyObject>) { + if !matches!(*PHASE.lock(), Phase::Checking { .. }) { + return; + } + let error = error.and_then(|error| { + // SAFETY: NSError's `code` and `localizedDescription`. + let code: isize = unsafe { msg_send![error, code] }; + let text: Option> = + unsafe { msg_send![error, localizedDescription] }; + (code != NO_UPDATE).then(|| text.map(|t| t.to_string()).unwrap_or_default()) + }); + set(match error { + Some(error) => { + tracing::warn!("update check failed: {error}"); + Phase::Failed { + error, + until: Instant::now() + NOTICE, + } + } + None => Phase::Idle, + }); + } + } +); + +impl Delegate { + fn new() -> Retained { + let this = Self::alloc().set_ivars(()); + // SAFETY: NSObject's init. + unsafe { msg_send![super(this), init] } + } +} + +pub fn start() { + let Some(framework) = std::env::current_exe() + .ok() + .and_then(|exe| Some(exe.parent()?.parent()?.join("Frameworks/Sparkle.framework"))) + .filter(|path| path.exists()) + else { + tracing::debug!("no Sparkle.framework in the bundle, so no updater"); + return; + }; + let path = NSString::from_str(&framework.to_string_lossy()); + // SAFETY: loading a framework on the main thread. + let loaded = NSBundle::bundleWithPath(&path).is_some_and(|bundle| unsafe { bundle.load() }); + let Some(class) = loaded + .then(|| AnyClass::get(c"SPUStandardUpdaterController")) + .flatten() + else { + tracing::warn!("Sparkle.framework would not load, so no updater"); + return; + }; + let delegate = Delegate::new(); + // SAFETY: SPUStandardUpdaterController's designated initialiser. The + // delegate is kept beside the controller, which holds it weakly. + let controller: Option> = unsafe { + let obj: Allocated = msg_send![class, alloc]; + msg_send![ + obj, + initWithStartingUpdater: false, + updaterDelegate: &*delegate, + userDriverDelegate: Option::<&AnyObject>::None + ] + }; + // Started here so a failure is seen: the initialiser would only show an + // alert, and an updater that did not start ignores every check. + // SAFETY: SPUStandardUpdaterController's `updater`, and SPUUpdater's + // `startUpdater:`, which takes a null error pointer. + let started = controller.as_ref().is_some_and(|controller| unsafe { + let updater: Retained = msg_send![&**controller, updater]; + msg_send![&*updater, startUpdater: std::ptr::null_mut::<*mut AnyObject>()] + }); + let Some(controller) = controller.filter(|_| started) else { + tracing::warn!("Sparkle's updater would not start: check SUFeedURL and SUPublicEDKey"); + return; + }; + add_menu_item(&controller); + UPDATER.with(|u| *u.borrow_mut() = Some((controller, delegate))); +} + +/// Put "Check for Updates…" under "About" in the app menu, which winit builds. +fn add_menu_item(controller: &AnyObject) { + // SAFETY: AppKit calls on the main thread; every object is checked for nil. + unsafe { + let Some(app_class) = AnyClass::get(c"NSApplication") else { + return; + }; + let app: Option> = msg_send![app_class, sharedApplication]; + let Some(app) = app else { return }; + let menu: Option> = msg_send![&*app, mainMenu]; + let Some(menu) = menu else { return }; + let count: isize = msg_send![&*menu, numberOfItems]; + if count == 0 { + return; + } + let first: Option> = msg_send![&*menu, itemAtIndex: 0isize]; + let submenu: Option> = + first.and_then(|item| msg_send![&*item, submenu]); + let Some(submenu) = submenu else { return }; + let Some(item_class) = AnyClass::get(c"NSMenuItem") else { + return; + }; + let item: Allocated = msg_send![item_class, alloc]; + let item: Retained = msg_send![ + item, + initWithTitle: &*NSString::from_str("Check for Updates…"), + action: sel!(checkForUpdates:), + keyEquivalent: &*NSString::from_str("") + ]; + let _: () = msg_send![&*item, setTarget: controller]; + let _: () = msg_send![&*submenu, insertItem: &*item, atIndex: 1isize]; + } +} + +pub fn status() -> Status { + let mut phase = PHASE.lock(); + match &*phase { + Phase::Idle => Status::Idle, + Phase::Checking { since } if since.elapsed() >= NO_ANSWER => { + let error = format!("no answer from Sparkle in {} s", NO_ANSWER.as_secs()); + *phase = Phase::Failed { + error: error.clone(), + until: Instant::now() + NOTICE, + }; + Status::Failed { + title: "Check failed", + error, + } + } + Phase::Checking { .. } => Status::Checking, + Phase::UpToDate { until } | Phase::Failed { until, .. } if Instant::now() >= *until => { + *phase = Phase::Idle; + Status::Idle + } + Phase::UpToDate { .. } => Status::UpToDate, + Phase::Failed { error, .. } => Status::Failed { + title: "Check failed", + error: error.clone(), + }, + Phase::Available(version) => Status::Available(version.clone()), + } +} + +pub fn present() -> bool { + UPDATER.with(|u| u.borrow().is_some()) +} + +pub fn can_check() -> bool { + present() && !matches!(*PHASE.lock(), Phase::Checking { .. }) +} + +/// The controller, cloned out of the borrow: Sparkle may call the delegate +/// before a call into it returns. +fn controller() -> Option> { + UPDATER.with(|u| u.borrow().as_ref().map(|(c, _)| c.clone())) +} + +/// Probe the feed without a window. While Sparkle's window is up, bring it +/// forward instead: Sparkle ignores a probe during a session. +pub fn check() { + let Some(controller) = controller() else { + return; + }; + // SAFETY: SPUStandardUpdaterController's `updater`, and SPUUpdater's + // `sessionInProgress` and `checkForUpdateInformation`, on the main thread. + unsafe { + let updater: Retained = msg_send![&*controller, updater]; + let busy: bool = msg_send![&*updater, sessionInProgress]; + if busy { + let _: () = msg_send![&*controller, checkForUpdates: Option::<&AnyObject>::None]; + return; + } + set(Phase::Checking { + since: Instant::now(), + }); + let _: () = msg_send![&*updater, checkForUpdateInformation]; + } +} + +/// Open Sparkle's window on the update the slot shows. +pub fn open() { + if let Some(controller) = controller() { + // SAFETY: an IBAction that takes a nil sender. + unsafe { + let _: () = msg_send![&*controller, checkForUpdates: Option::<&AnyObject>::None]; + } + } +} diff --git a/docs/building.md b/docs/building.md index fc6423d..89ae366 100644 --- a/docs/building.md +++ b/docs/building.md @@ -146,6 +146,15 @@ macOS signing reads these repository secrets: Without them a pull request builds an ad hoc signed app and no `.dmg`, and a release tag fails. +A release also gets `appcast.xml`, the macOS app's Sparkle feed, written by +`.github/scripts/appcast.sh`. Its release notes are the annotated tag's message. +The app finds the feed under GitHub's "latest" release, so publish model +releases (`models-v*`) with `gh release create --latest=false`. + +| secret | | +| --- | --- | +| `UPDATE_SIGNING_KEY` | base64 of the 32-byte Ed25519 seed that signs the `.dmg` in the feed; the public half is `packaging/update-ed25519.pub` | + Windows installs CUDA 13.3.1 and cuDNN 9.14 from NVIDIA's redistributable archives with `.github/scripts/windows-cuda.ps1`, the versions stemd fetches at run time. diff --git a/docs/internals.md b/docs/internals.md index 33ec98d..fa79368 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -340,15 +340,61 @@ Two things a bundle needs that a `cargo run` does not: - **`-psn_*` in argv is discarded.** Finder can pass a process serial number when launching a bundle, and clap would reject it as an unknown flag. -`bundle-app.sh` copies one executable in. There is nothing to vendor: MLX links -statically and embeds its Metal shaders, so the binary depends on system -frameworks alone: no `Contents/Frameworks`, no `install_name_tool`, no rpath. -The bundle is 29 MB where it used to be 223, and the 194 MB difference was five -libtorch dylibs that had to be signed inside-out before the bundle itself could -be. It is ad-hoc signed, which launches on the machine that built it and nowhere -else; distribution needs a Developer ID, the hardened runtime and notarization. +`bundle-app.sh` copies one executable in, with `mlx.metallib` and +`Sparkle.framework`. MLX links statically, so the binary depends on system +frameworks alone: no `install_name_tool`, no rpath. Sparkle is loaded at run +time and signed before the bundle. Without a Developer ID the bundle is ad-hoc +signed, which launches on the machine that built it and nowhere else; +distribution needs a Developer ID, the hardened runtime and notarization. `Info.plist` carries `NSLocalNetworkUsageDescription` because the app advertises -over mDNS and serves on the LAN. +over mDNS and serves on the LAN, and Sparkle's `SUFeedURL` and `SUPublicEDKey`. + +## Updates + +`update/` checks for a newer release. The title bar has two parts: + +- the version, centred beside the title: a click checks for updates, and its + icon spins while it checks; +- the update slot, in the corner away from the window buttons (right on + macOS, left on Windows): "Checking…", "Up to date" or "Check failed" for + 2.6 s after a check, and "New update available!" once one is found. Its + gradient moves for 21 s, then only under the pointer. + +| host | check | slot click | install | +| --- | --- | --- | --- | +| macOS | Sparkle, daily, from `releases/latest/download/appcast.xml`; a probe on a click on the version; "Check for Updates…" in the app menu | Sparkle's window | Sparkle replaces the bundle and relaunches | +| Windows | the newest `v*` release, at launch, daily and on a click | asks, then downloads Setup ("Downloading N%") | Setup, silently, once nothing is running; it restarts Stemd | +| Linux | the newest `v*` release, at launch and daily | opens the release page | the package manager | + +Linux has no title bar of its own, so "New update available!" shows in the +status bar, when it fits. On Windows: + +- Setup is checked against the SHA-256 GitHub records for the asset, and + again before it starts. A download with no data for 60 s fails. +- The install mode is the uninstall key, per user then all users, whose + `InstallLocation` is the executable's folder. With no match (the zip), or + an all-users copy on a standard account, the slot opens the release page. +- If Setup cannot start (consent declined), the app stays open and offers + the update again. +- While a separation runs, queues or a model downloads, the slot says + "Update ready". Once idle, the app starts Setup with `/S /relaunch` and + the install mode, then quits; Setup waits for the executable to be free. +- Quitting while the slot says "Update ready" starts Setup without + `/relaunch`. + +macOS reads `releases/latest/download/appcast.xml`, so a model release +(`models-v*`) must be published with `--latest=false`; Windows and Linux pick +the newest `v*` tag themselves. + +Setup reuses the choices of the copy it replaces, silently or not: + +| choice | remembered by | +| --- | --- | +| install mode | the hive holding the uninstall key, this account's first | +| location | `InstallLocation` in that key | +| GPU support | `GpuSupport` in that key; for an older copy, `cudart64_13.dll` beside it | + +With the CUDA runtime already usable, the GPU section downloads nothing. ## Tools diff --git a/packaging/update-ed25519.pub b/packaging/update-ed25519.pub new file mode 100644 index 0000000..181bf07 --- /dev/null +++ b/packaging/update-ed25519.pub @@ -0,0 +1 @@ +ITawu8+P5NnkhQHBKtQ3A+ipBW2zV8njDguNrnw+SfQ= diff --git a/scripts/bundle-app.sh b/scripts/bundle-app.sh index 7594205..93445bf 100755 --- a/scripts/bundle-app.sh +++ b/scripts/bundle-app.sh @@ -7,10 +7,9 @@ # resolves relative to the exe. # # Almost nothing to vendor. MLX links statically, so the executable depends on -# system frameworks alone: no Frameworks directory, no rpath surgery, no venv. -# It used to carry 223 MB of libtorch dylibs that had to be signed inside-out -# before the bundle could be. The one thing that does travel is mlx.metallib, -# the compiled Metal kernels, see below. +# system frameworks alone: no rpath surgery, no venv. Two things travel with +# it: mlx.metallib, the compiled Metal kernels, and Sparkle.framework, the +# updater, which the app loads at run time. See below. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -140,6 +139,33 @@ else echo " models: fetched on first run" fi +# Sparkle, the updater. The app loads it from Contents/Frameworks; without it +# the app runs with no updater. Its XPC services are for sandboxed apps and are +# removed. +SPARKLE_VERSION="2.10.0" +SPARKLE_SHA256="c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c" +SPARKLE_DIR="$ROOT/target/sparkle-$SPARKLE_VERSION" +if [ ! -d "$SPARKLE_DIR/Sparkle.framework" ]; then + mkdir -p "$SPARKLE_DIR" + archive="$SPARKLE_DIR/Sparkle.tar.xz" + curl -fsSL -o "$archive" \ + "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" + got="$(shasum -a 256 "$archive" | cut -d' ' -f1)" + if [ "$got" != "$SPARKLE_SHA256" ]; then + echo "Sparkle-$SPARKLE_VERSION.tar.xz has SHA-256 $got, expected $SPARKLE_SHA256" >&2 + rm -rf "$SPARKLE_DIR" + exit 1 + fi + tar -xf "$archive" -C "$SPARKLE_DIR" Sparkle.framework + rm "$archive" +fi +SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" +mkdir -p "$APP/Contents/Frameworks" +ditto "$SPARKLE_DIR/Sparkle.framework" "$SPARKLE" +rm -rf "$SPARKLE/Versions/B/XPCServices" "$SPARKLE/XPCServices" +SPARKLE_PUBLIC_KEY="$(tr -d '[:space:]' < "$ROOT/packaging/update-ed25519.pub")" +echo " updater: Sparkle $SPARKLE_VERSION" + # The icon. `sips` and `iconutil` ship with macOS, so this needs nothing # installed: the arithmetic that is not obvious (Apple's 824-of-1024 inset) # was done once by scripts/make-icons.sh and lives in the committed master. @@ -179,6 +205,10 @@ $ICON_PLIST CFBundleVersion $VERSION LSMinimumSystemVersion $MACOS_MIN NSHighResolutionCapable + + SUFeedURL https://github.com/nsaintot/stemd/releases/latest/download/appcast.xml + SUPublicEDKey $SPARKLE_PUBLIC_KEY + SUEnableAutomaticChecks NSLocalNetworkUsageDescription Stemd advertises itself so players on this network can find it. @@ -186,8 +216,8 @@ $ICON_PLIST PLIST -# Nothing nested to sign inside-out: one executable, no dylibs, and the -# metallib is sealed as a resource. +# Sparkle is signed inside-out, helpers first, before the bundle that seals +# it. The metallib is sealed as a resource. # # A Developer ID where the machine has one, ad-hoc otherwise. The difference is # not cosmetic: an ad-hoc signature is trusted by the machine that made it and @@ -204,9 +234,15 @@ if [ -z "$IDENTITY" ]; then | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -1)" fi if [ -n "$IDENTITY" ]; then + for code in "$SPARKLE/Versions/B/Autoupdate" "$SPARKLE/Versions/B/Updater.app" "$SPARKLE"; do + codesign --force --sign "$IDENTITY" --options runtime --timestamp "$code" + done codesign --force --sign "$IDENTITY" --options runtime --timestamp "$APP" echo " signed: $IDENTITY" else + for code in "$SPARKLE/Versions/B/Autoupdate" "$SPARKLE/Versions/B/Updater.app" "$SPARKLE"; do + codesign --force --sign - "$code" + done codesign --force --sign - "$APP" 2>/dev/null \ && echo " signed: ad-hoc, so this opens on this machine and nowhere else" \ || echo " signed: FAILED (may still run locally)" diff --git a/scripts/installer-windows.nsi b/scripts/installer-windows.nsi index eb28549..da879b7 100644 --- a/scripts/installer-windows.nsi +++ b/scripts/installer-windows.nsi @@ -56,6 +56,10 @@ SetCompressor /SOLID lzma !define MULTIUSER_INSTALLMODE_INSTDIR "${NAME}" !define MULTIUSER_INSTALLMODE_INSTDIR_REGISTRY_KEY "${UNINST_KEY}" !define MULTIUSER_INSTALLMODE_INSTDIR_REGISTRY_VALUENAME "InstallLocation" +; An update keeps the mode of the copy it replaces: the hive holding its +; uninstall key, this account's first. +!define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINST_KEY}" +!define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "InstallLocation" !include "MultiUser.nsh" !include "MUI2.nsh" @@ -88,6 +92,7 @@ VIAddVersionKey "LegalCopyright" "MIT OR Apache-2.0" !define MUI_FINISHPAGE_LINK_LOCATION "${HOMEPAGE}" !insertmacro MULTIUSER_PAGE_INSTALLMODE +!define MUI_PAGE_CUSTOMFUNCTION_PRE ComponentsPre !insertmacro MUI_PAGE_COMPONENTS !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES @@ -128,6 +133,7 @@ FunctionEnd Section "Stemd" SecMain SectionIn RO + Call WaitForStemdToExit SetOutPath "$INSTDIR" ; Overwritten in place rather than uninstalled first: a previous install may ; hold the CUDA runtime, and removing it would mean downloading it again for @@ -162,6 +168,9 @@ Section "Stemd" SecMain '"$INSTDIR\Uninstall.exe" /$MultiUser.InstallMode' WriteRegStr SHCTX "${UNINST_KEY}" "QuietUninstallString" \ '"$INSTDIR\Uninstall.exe" /$MultiUser.InstallMode /S' + ; Set again by the GPU section when it runs, so the next install offers the + ; same choice. + WriteRegDWORD SHCTX "${UNINST_KEY}" "GpuSupport" 0 WriteRegDWORD SHCTX "${UNINST_KEY}" "NoModify" 1 WriteRegDWORD SHCTX "${UNINST_KEY}" "NoRepair" 1 ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 @@ -175,6 +184,7 @@ SectionEnd ; difference is a separation in seconds instead of minutes. A failed download ; leaves a working program on the CPU and the script to try again. Section "GPU support (CUDA runtime, 1.2 GB download)" SecCuda + WriteRegDWORD SHCTX "${UNINST_KEY}" "GpuSupport" 1 DetailPrint "Fetching the CUDA runtime beside Stemd, about 1.2 GB..." nsExec::ExecToLog '"$INSTDIR\stemd-server.exe" --install-cuda' Pop $0 @@ -208,6 +218,77 @@ Function .onInit ; left unticked and greyed out rather than downloading 1.2 GB for nothing. ${IfNot} ${FileExists} "$SYSDIR\nvcuda.dll" SectionSetFlags ${SecCuda} ${SF_RO} + ${Else} + Call RememberGpuChoice + ${EndIf} +FunctionEnd + +; The install mode whose GPU choice RememberGpuChoice last applied. +Var GpuMode + +; Tick GPU support as the copy being replaced had it, in the hive of the +; current install mode. A copy installed before `GpuSupport` existed counts as +; having it when cudart64_13.dll is beside it. With the runtime in place the +; section downloads nothing. +Function RememberGpuChoice + StrCpy $GpuMode $MultiUser.InstallMode + !insertmacro SelectSection ${SecCuda} + ClearErrors + ReadRegDWORD $0 SHCTX "${UNINST_KEY}" "GpuSupport" + ${If} ${Errors} + ${If} ${FileExists} "$INSTDIR\Uninstall.exe" + ${AndIfNot} ${FileExists} "$INSTDIR\cudart64_13.dll" + !insertmacro UnselectSection ${SecCuda} + ${EndIf} + ${ElseIf} $0 == 0 + !insertmacro UnselectSection ${SecCuda} + ${EndIf} +FunctionEnd + +; The mode page may have switched to the other install: read its choice. +Function ComponentsPre + ${If} ${FileExists} "$SYSDIR\nvcuda.dll" + ${AndIf} $GpuMode != $MultiUser.InstallMode + Call RememberGpuChoice + ${EndIf} +FunctionEnd + +; The updater starts Setup just before Stemd quits. Overwriting a running +; executable fails, and silent mode ignores the failure, so wait up to 60 s +; for it to be released. +Function WaitForStemdToExit + StrCpy $1 0 + ${Do} + ${IfNot} ${FileExists} "$INSTDIR\stemd-server.exe" + ${Break} + ${EndIf} + ClearErrors + FileOpen $0 "$INSTDIR\stemd-server.exe" a + ${IfNot} ${Errors} + FileClose $0 + ${Break} + ${EndIf} + ${If} $1 >= 120 + DetailPrint "Stemd is still running; close it and run Setup again." + ${IfNot} ${Silent} + MessageBox MB_ICONSTOP|MB_OK "Stemd is still running. Close it and run Setup again." + ${EndIf} + Abort + ${EndIf} + Sleep 500 + IntOp $1 $1 + 1 + ${Loop} +FunctionEnd + +; `/S /relaunch`, as the app's updater runs it: start Stemd once installed. +Function .onInstSuccess + ${If} ${Silent} + ${GetParameters} $0 + ClearErrors + ${GetOptions} $0 "/relaunch" $1 + ${IfNot} ${Errors} + Call LaunchStemd + ${EndIf} ${EndIf} FunctionEnd