Update now: download, verify and install a release in one click (#1118)

Where SolidCraft can replace itself safely, the Update window gets Update now:
- AppImage: the new AppImage is downloaded next to the old one, verified and renamed over
  it; Restart now starts it as SolidCraft exits.
- Windows MSI: the verified .msi starts (msiexec /i … /passive) once SolidCraft has closed,
  after the usual questions about unsaved designs.
- Windows portable zip / standalone exe: the running solidcraft.exe is renamed to .old, the
  new one takes its name (solidcraft-cli.exe too), Restart now; .old goes at the next start.
- macOS: the verified .dmg is opened (the app is ad-hoc signed and not notarized, so it isn't
  replaced behind the user's back).
- Flatpak, Snap, deb, rpm, tar.gz: never replaced; the window shows the command.

Every package's SHA-256 must match the release's SHA256SUMS.txt (HTTPS, same release); a
mismatch deletes the download and changes nothing. With PUBLIC_KEY set in update/install.rs,
SHA256SUMS.txt.minisig must also verify (minisign-verify, MIT) with a trusted comment naming
the release. release.yml signs the list when the UPDATE_SIGNING_KEY secret exists and fails
when the app expects signatures but the secret is missing; docs/releasing.md has the steps.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-14 11:05:40 -04:00
1 parent a8258eb63a
commit d4a7b3abf3
16 files changed
+966 -12

No files matched your search

+44 -3
View File
@@ -63,6 +63,12 @@ impl eframe::App for App {
fn on_exit(&mut self) {
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
// An update asked to restart into the new version or to run its installer.
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve)
&& let Err(e) = std::process::Command::new(&program).args(&args).spawn()
{
log::error!("update: starting {}: {e}", program.display());
}
}
}
@@ -114,6 +120,7 @@ fn services() -> Services {
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
http_get: Some(std::sync::Arc::new(http_get)),
http_download: Some(std::sync::Arc::new(http_download)),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
@@ -138,6 +145,40 @@ fn services() -> Services {
/// 15 s limit, at most 1 MB, and no headers beyond what GitHub requires (a User-Agent without
/// the version or anything about this machine, and the API's media type).
fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
let mut response = http_call(url, std::time::Duration::from_secs(15))?;
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
}
/// HTTPS download of an update package to `to` (docs/updates.md), reporting progress; stops when
/// `progress` returns false. The caller verifies the file.
fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
use std::io::{Read, Write};
let fail = |e: String| FetchError::Failed(e);
let mut response = http_call(url, std::time::Duration::from_secs(30 * 60))?;
let total = response.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
let mut body = response.body_mut().with_config().limit(solidcraft_ui_egui::update::install::MAX_PACKAGE).reader();
let mut file = std::fs::File::create(to).map_err(|e| fail(format!("{}: {e}", to.display())))?;
let mut buf = vec![0u8; 1 << 16];
let mut done = 0u64;
loop {
let n = body.read(&mut buf).map_err(|e| fail(e.to_string()))?;
if n == 0 {
break;
}
file.write_all(buf.get(..n).unwrap_or_default()).map_err(|e| fail(format!("{}: {e}", to.display())))?;
done += n as u64;
if !progress(done, total) {
return Err(fail("cancelled".into()));
}
}
file.sync_all().map_err(|e| fail(format!("{}: {e}", to.display())))?;
Ok(())
}
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
use std::time::Duration;
let tls = ureq::tls::TlsConfig::builder()
@@ -148,13 +189,13 @@ fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError
.tls_config(tls)
.https_only(true)
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(15)))
.timeout_global(Some(timeout))
.timeout_connect(Some(Duration::from_secs(5)))
.user_agent("SolidCraft-update-check")
.max_redirects(3)
.build()
.into();
let mut response = agent
let response = agent
.get(url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
@@ -170,7 +211,7 @@ fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError
if status != 200 {
return Err(FetchError::Failed(format!("GitHub answered HTTP {status}")));
}
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
Ok(response)
}
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.