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