Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/scripts/appcast.sh
Original file line number Diff line number Diff line change
@@ -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/]]>/]]]]><![CDATA[>/g')
description=" <description><![CDATA[$html]]></description>"
fi

cat > "$dir/appcast.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Stemd</title>
<link>https://github.com/$GH_REPO/releases</link>
<item>
<title>Version $version</title>
<pubDate>$published</pubDate>
<sparkle:version>$version</sparkle:version>
<sparkle:shortVersionString>$version</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:fullReleaseNotesLink>https://github.com/$GH_REPO/releases/tag/$tag</sparkle:fullReleaseNotesLink>
$description
<enclosure url="$url" length="$length" type="application/octet-stream" sparkle:edSignature="$signature"/>
</item>
</channel>
</rss>
XML
cat "$dir/appcast.xml"
29 changes: 29 additions & 0 deletions .github/scripts/sign.sh
Original file line number Diff line number Diff line change
@@ -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'
17 changes: 17 additions & 0 deletions .github/scripts/tag-notes.sh
Original file line number Diff line number Diff line change
@@ -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'
15 changes: 15 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 16 additions & 2 deletions crates/stemd-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions crates/stemd-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ mod switch;
#[cfg(test)]
mod testkit;
mod ui;
mod update;

use anyhow::Result;
use tracing_subscriber::Layer;
Expand Down
2 changes: 1 addition & 1 deletion crates/stemd-server/src/models/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions crates/stemd-server/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
17 changes: 16 additions & 1 deletion crates/stemd-server/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod starting;
mod status;
mod theme;
mod titlebar;
mod update;
mod widgets;

use std::net::SocketAddr;
Expand Down Expand Up @@ -147,6 +148,7 @@ pub fn run(launch: Arc<Launch>, 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,
Expand Down Expand Up @@ -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.
Expand All @@ -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")
}
}
Expand Down
17 changes: 15 additions & 2 deletions crates/stemd-server/src/ui/status.rs
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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!(
Expand All @@ -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()
Expand Down
38 changes: 23 additions & 15 deletions crates/stemd-server/src/ui/titlebar.rs
Original file line number Diff line number Diff line change
@@ -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") {
Expand Down Expand Up @@ -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::{
Expand Down
Loading
Loading