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