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 <noreply@anthropic.com>
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

+26
View File
@@ -429,6 +429,32 @@ jobs:
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Sign the checksum list for in-app updates
# Optional until the UPDATE_SIGNING_KEY secret exists (docs/releasing.md, "Update
# signing"). The trusted comment names the release, which the app checks.
env:
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
run: |
# The key the app checks against (crates/ui-egui/src/update/install.rs, PUBLIC_KEY).
public_key=$(sed -n 's/^pub const PUBLIC_KEY: Option<&str> = Some("\([^"]*\)");/\1/p' crates/ui-egui/src/update/install.rs)
if [ -z "$UPDATE_SIGNING_KEY" ]; then
if [ -n "$public_key" ]; then
echo "::error::the app expects signed updates (PUBLIC_KEY is set) but the UPDATE_SIGNING_KEY secret is missing"
exit 1
fi
echo "UPDATE_SIGNING_KEY isn't set: SHA256SUMS.txt stays unsigned"
exit 0
fi
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends minisign
umask 077
printf '%s\n' "$UPDATE_SIGNING_KEY" >"$RUNNER_TEMP/update.key"
minisign -S -s "$RUNNER_TEMP/update.key" -m artifacts/SHA256SUMS.txt \
-x artifacts/SHA256SUMS.txt.minisig -t "SolidCraft v$VERSION" </dev/null
rm -f "$RUNNER_TEMP/update.key"
if [ -n "$public_key" ]; then
minisign -V -P "$public_key" -m artifacts/SHA256SUMS.txt -x artifacts/SHA256SUMS.txt.minisig
fi
- name: Publish nightly pre-release
if: github.event_name == 'schedule'
working-directory: artifacts
Generated
+9
View File
@@ -2019,6 +2019,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3593,14 +3599,17 @@ dependencies = [
"egui-wgpu",
"image",
"log",
"minisign-verify",
"pollster 0.4.0",
"semver",
"serde",
"serde_json",
"sha2",
"solidcraft-engine",
"solidcraft-gpu",
"ttf-parser",
"web-time",
"zip",
]
[[package]]
+66 -1
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 476 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 477 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -192,6 +192,7 @@ matext4cgmath 0.1.0 (Apache-2.0)
memchr 2.8.3 (Unlicense OR MIT)
memmap2 0.9.11 (MIT OR Apache-2.0)
memoffset 0.9.1 (MIT)
minisign-verify 0.3.0 (MIT)
miniz_oxide 0.8.9 (MIT OR Zlib OR Apache-2.0)
miniz_oxide 0.9.1 (MIT OR Zlib OR Apache-2.0)
moxcms 0.8.1 (BSD-3-Clause OR Apache-2.0)
@@ -7913,6 +7914,70 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
minisign-verify 0.3.0
================================================================================
Copyright (c) 2019-2025 Frank Denis
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
--
Code in the src/crypto folder is derived from the rust-crypto project:
https://github.com/DaGenix/rust-crypto
Original ISC license follows:
Copyright (c) 2006-2009 Graydon Hoare
Copyright (c) 2009-2013 Mozilla Foundation
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
miniz_oxide 0.8.9, miniz_oxide 0.9.1
================================================================================
+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.
+11 -1
View File
@@ -725,5 +725,15 @@
"update.prefs_note": "Dies ist SolidCraft {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
"update.prefs_dev": "Dies ist ein Entwicklungs-Build ({version}): er sucht nicht nach Updates.",
"update.show": "Update auf {version} anzeigen",
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst."
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst.",
"update.update_now": "Jetzt aktualisieren",
"update.install_msi": "Installieren (schließt SolidCraft)",
"update.download_open": "Herunterladen und öffnen",
"update.downloading": "Herunterladen und prüfen… {done} von {total} MB",
"update.installed": "SolidCraft {version} ist installiert. Zum Verwenden neu starten.",
"update.restart": "Jetzt neu starten",
"update.restart_later": "Später",
"update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.",
"update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.",
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket."
}
+11 -1
View File
@@ -984,5 +984,15 @@
"update.prefs_note": "This is SolidCraft {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
"update.prefs_dev": "This is a development build ({version}): it doesn't check for updates.",
"update.show": "Show the update to {version}",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. SolidCraft doesn't check."
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. SolidCraft doesn't check.",
"update.update_now": "Update now",
"update.install_msi": "Install (closes SolidCraft)",
"update.download_open": "Download and open",
"update.downloading": "Downloading and verifying… {done} of {total} MB",
"update.installed": "SolidCraft {version} is installed. Restart to use it.",
"update.restart": "Restart now",
"update.restart_later": "Later",
"update.installer_started": "The installer starts when SolidCraft closes.",
"update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.",
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead."
}
+11 -1
View File
@@ -984,5 +984,15 @@
"update.prefs_note": "これは SolidCraft {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
"update.prefs_dev": "これは開発ビルド ({version}) のため、アップデートを確認しません。",
"update.show": "{version} へのアップデートを表示",
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。"
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。",
"update.update_now": "今すぐ更新",
"update.install_msi": "インストール (SolidCraft を終了)",
"update.download_open": "ダウンロードして開く",
"update.downloading": "ダウンロードと検証中… {done} / {total} MB",
"update.installed": "SolidCraft {version} をインストールしました。再起動すると使えます。",
"update.restart": "今すぐ再起動",
"update.restart_later": "後で",
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。"
}
+11 -1
View File
@@ -725,5 +725,15 @@
"update.prefs_note": "SolidCraft {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
"update.prefs_dev": "개발 빌드({version})이므로 업데이트를 확인하지 않습니다.",
"update.show": "{version} 업데이트 보기",
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다."
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다.",
"update.update_now": "지금 업데이트",
"update.install_msi": "설치 (SolidCraft 종료)",
"update.download_open": "다운로드 후 열기",
"update.downloading": "다운로드 및 확인 중… {done} / {total} MB",
"update.installed": "SolidCraft {version}이(가) 설치되었습니다. 다시 시작하면 사용할 수 있습니다.",
"update.restart": "지금 다시 시작",
"update.restart_later": "나중에",
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다."
}
+11 -1
View File
@@ -984,5 +984,15 @@
"update.prefs_note": "当前为 SolidCraft {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
"update.prefs_dev": "这是开发版本({version}),不检查更新。",
"update.show": "显示 {version} 更新",
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。"
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。",
"update.update_now": "立即更新",
"update.install_msi": "安装(将关闭 SolidCraft)",
"update.download_open": "下载并打开",
"update.downloading": "正在下载并校验… {done} / {total} MB",
"update.installed": "SolidCraft {version} 已安装。重新启动后生效。",
"update.restart": "立即重启",
"update.restart_later": "稍后",
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。"
}
+5
View File
@@ -20,6 +20,11 @@ web-time = { workspace = true }
image = { workspace = true, features = ["jpeg"] }
ttf-parser = { workspace = true }
semver = { workspace = true }
# Updates: the downloaded package's SHA-256 (against the release's SHA256SUMS.txt), its optional
# minisign signature (MIT), and the portable .zip's executables.
sha2 = { workspace = true }
minisign-verify = "0.3"
zip = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
# The offscreen viewport render test opens a device without a window.
+2
View File
@@ -215,6 +215,8 @@ pub struct Services {
pub graphics_path: Option<String>,
/// HTTPS GET for the update check (`update`); None: never check (browser, tests).
pub http_get: Option<update::HttpGet>,
/// HTTPS download to a file, with progress, for one-click updates (`update::install`).
pub http_download: Option<update::install::HttpDownload>,
}
pub struct SolveApp {
+172 -2
View File
@@ -488,6 +488,12 @@ pub struct Updater {
last_manual: f64,
/// A release given by `SOLIDCRAFT_UPDATE_FEED` (a file), for tests and screenshots.
feed: Option<PathBuf>,
/// The one-click update in progress (`install`).
job: Option<(Receiver<Result<install::Outcome, String>>, Arc<install::Progress>)>,
/// The last one-click update's result.
pub installed: Option<Result<install::Outcome, String>>,
/// A program for the host to start as SolidCraft exits (restart, installer).
launch: Option<(PathBuf, Vec<String>)>,
}
impl Updater {
@@ -510,10 +516,16 @@ pub fn available(app: &mut SolveApp) -> bool {
/// Per frame: the first frame starts a due automatic check; a finished check is collected.
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
poll_install(app);
// Quitting to restart or install was cancelled (a "save changes?" answered Cancel).
if app.updater.launch.is_some() && app.docs.quitting.is_none() && !app.quit_requested {
app.updater.launch = None;
}
if !app.updater.started {
app.updater.started = true;
#[cfg(not(target_arch = "wasm32"))]
{
install::clean_up_after_update(std::env::current_exe().ok().as_deref());
if std::env::var("SOLIDCRAFT_UPDATE_CHECK").is_ok_and(|v| v == "0") {
return;
}
@@ -605,6 +617,101 @@ fn poll(app: &mut SolveApp) {
}
}
// ---- one-click updates ------------------------------------------------------------------------
/// How this installation updates in place, when the host can download (None: Download only).
pub fn install_method(app: &mut SolveApp) -> Option<install::Method> {
if app.services.http_get.is_none() || app.services.http_download.is_none() {
return None;
}
#[cfg(not(target_arch = "wasm32"))]
{
let exe = std::env::current_exe().ok();
install::method(app.updater.kind(), exe.as_deref())
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
/// Download, verify and apply the release found (Update now).
pub fn start_install(app: &mut SolveApp, ctx: &egui::Context) {
let (Some(release), Some(method), Some(get), Some(download)) =
(app.updater.release.clone(), install_method(app), app.services.http_get.clone(), app.services.http_download.clone())
else {
return;
};
if app.updater.job.is_some() {
return;
}
let kind = app.updater.kind().clone();
let progress = Arc::new(install::Progress::default());
let (tx, rx) = std::sync::mpsc::channel();
app.updater.installed = None;
app.updater.job = Some((rx, progress.clone()));
#[cfg(not(target_arch = "wasm32"))]
{
let ctx = ctx.clone();
let staging = std::env::temp_dir().join("solidcraft-update");
let net = install::Net { get, download };
let spawned = std::thread::Builder::new().name("update-install".into()).spawn(move || {
let result = install::run(&release, &kind, &method, std::env::consts::ARCH, &net, &staging, &progress);
let _ = tx.send(result);
ctx.request_repaint();
});
if let Err(e) = spawned {
app.updater.job = None;
app.updater.installed = Some(Err(e.to_string()));
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = (ctx, tx, release, method, get, download, kind);
app.updater.job = None;
}
}
fn poll_install(app: &mut SolveApp) {
let Some((rx, _)) = &app.updater.job else { return };
let result = match rx.try_recv() {
Ok(r) => r,
Err(TryRecvError::Empty) => return,
Err(TryRecvError::Disconnected) => Err("the update stopped".into()),
};
app.updater.job = None;
match &result {
Ok(install::Outcome::Installer(program, args)) => {
// The installer replaces this program: start it once SolidCraft has closed.
app.updater.launch = Some((program.clone(), args.clone()));
crate::documents::request_quit(app);
}
Ok(install::Outcome::Opened(dmg)) => {
#[cfg(not(target_arch = "wasm32"))]
{
let _ = std::process::Command::new("open").arg(dmg).spawn();
}
let _ = dmg;
}
Ok(install::Outcome::Restart(_)) => {}
Err(e) => log::warn!("update: {e}"),
}
app.updater.installed = Some(result);
}
/// Restart now: close (asking about unsaved designs), then start the new version.
pub fn restart(app: &mut SolveApp) {
if let Some(Ok(install::Outcome::Restart(program))) = &app.updater.installed {
app.updater.launch = Some((program.clone(), Vec::new()));
crate::documents::request_quit(app);
}
}
/// For the host, as SolidCraft exits: the program to start (the new version, an installer).
pub fn take_launch(app: &mut SolveApp) -> Option<(PathBuf, Vec<String>)> {
app.updater.launch.take()
}
// ---- user interface ---------------------------------------------------------------------------
/// The application bar's Update button, right of `right`; returns its rect (None: not shown).
@@ -652,6 +759,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
};
let kind = app.updater.kind().clone();
let install = install_method(app);
let arch = std::env::consts::ARCH;
let t = Tokens::get();
let mut open = true;
@@ -692,12 +800,67 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
});
ui.add_space(4.0);
} else if let Some(name) = &asset {
} else if let Some(name) = asset.as_ref().filter(|_| app.updater.installed.as_ref().is_none_or(|r| r.is_err())) {
ui.label(RichText::new(crate::i18n::format("update.file", &[("file", name)])).size(11.0).color(t.text_dim));
}
if let Some((_, progress)) = &app.updater.job {
let done = progress.done.load(std::sync::atomic::Ordering::Relaxed);
let total = progress.total.load(std::sync::atomic::Ordering::Relaxed).max(1);
let mb = |b: u64| format!("{:.1}", b as f64 / 1_048_576.0);
ui.add(
egui::ProgressBar::new((done as f32 / total as f32).clamp(0.0, 1.0))
.text(crate::i18n::format("update.downloading", &[("done", &mb(done)), ("total", &mb(total))])),
);
if ui.button(crate::i18n::tr("ui.cancel")).clicked() {
progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
return;
}
match &app.updater.installed {
Some(Ok(install::Outcome::Restart(_))) => {
ui.label(RichText::new(crate::i18n::format("update.installed", &[("version", release.version())])).strong());
ui.horizontal(|ui| {
let r = ui.add(egui::Button::new(RichText::new(crate::i18n::tr("update.restart")).strong()).fill(t.accent));
crate::scenario::publish_rect("update:restart", r.rect);
if r.clicked() {
action = Some("restart");
}
if ui.button(crate::i18n::tr("update.restart_later")).clicked() {
action = Some("close");
}
});
return;
}
Some(Ok(install::Outcome::Installer(..))) => {
ui.label(crate::i18n::tr("update.installer_started"));
return;
}
Some(Ok(install::Outcome::Opened(_))) => {
ui.add(egui::Label::new(crate::i18n::tr("update.dmg_opened")).wrap());
return;
}
Some(Err(e)) => {
ui.add(egui::Label::new(RichText::new(crate::i18n::format("update.install_failed", &[("error", e)])).color(t.error)).wrap());
}
None => {}
}
ui.horizontal(|ui| {
let one_click = install.is_some() && !matches!(app.updater.installed, Some(Err(_)));
if let Some(m) = install.as_ref().filter(|_| one_click) {
let label = match m {
install::Method::Msi => crate::i18n::tr("update.install_msi"),
install::Method::Dmg => crate::i18n::tr("update.download_open"),
_ => crate::i18n::tr("update.update_now"),
};
let r = ui.add(egui::Button::new(RichText::new(label).strong()).fill(t.accent));
crate::scenario::publish_rect("update:install", r.rect);
if r.clicked() {
action = Some("install");
}
}
let download = if kind.system_managed() { crate::i18n::tr("update.release_page") } else { crate::i18n::tr("update.download") };
let r = ui.add(egui::Button::new(RichText::new(download).strong()).fill(t.accent));
let r = if one_click { ui.button(download) } else { ui.add(egui::Button::new(RichText::new(download).strong()).fill(t.accent)) };
crate::scenario::publish_rect("update:download", r.rect);
if r.clicked() {
action = Some("download");
@@ -728,6 +891,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.preferences.update.remind_later(now);
app.updater.open = false;
}
Some("install") => start_install(app, ctx),
Some("restart") => restart(app),
Some("close") => app.updater.open = false,
_ if !open => app.updater.open = false,
_ => {}
}
@@ -955,5 +1121,9 @@ fn markdown(ui: &mut egui::Ui, src: &str, t: &Tokens) {
}
}
pub mod install;
#[cfg(test)]
mod install_tests;
#[cfg(test)]
mod tests;
+314
View File
@@ -0,0 +1,314 @@
//! One-click updates (docs/book/src/guide/updates.md): download the package that fits this
//! installation, verify it, and put it in place.
//!
//! Verification is never skipped. The package's SHA-256 must match the release's
//! `SHA256SUMS.txt`, fetched over HTTPS from the same release. When SolidCraft is built with an
//! update signing key ([`PUBLIC_KEY`]), `SHA256SUMS.txt.minisig` must also be a valid minisign
//! signature by that key whose trusted comment names the release, so a compromised download host
//! alone can't push a package. Anything that doesn't verify is deleted and nothing is replaced.
//!
//! What "put in place" means per installation:
//! - AppImage: the new AppImage is written next to the old one and renamed over it (atomic on
//! one file system; the running copy keeps its old inode), then SolidCraft offers a restart.
//! - Windows installer: the `.msi` is started with `msiexec /i … /passive` as SolidCraft exits.
//! - Windows portable `.zip` / standalone `.exe`: the running `solidcraft.exe` is renamed to
//! `solidcraft.exe.old` (Windows allows renaming a running executable), the new one takes its
//! name, and SolidCraft offers a restart; the `.old` file is deleted at the next start.
//! - macOS: the `.dmg` is verified and opened; the user drags the app to Applications. SolidCraft
//! is ad-hoc signed and not notarized, so replacing the bundle in place would install code the
//! system can't vouch for without the user seeing it.
//! - Flatpak, Snap, `.deb`, `.rpm`, `.tar.gz`: never replaced; the window shows the command.
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use sha2::{Digest, Sha256};
use super::{FetchError, InstallKind, Release};
/// The minisign public key releases are signed with (the base64 line of `minisign.pub`), or None
/// while the project has no update signing key: then the SHA-256 check over HTTPS is what
/// guards an update. See docs/releasing.md, "Update signing".
pub const PUBLIC_KEY: Option<&str> = None;
/// The checksum list every release carries, and its signature.
pub const SUMS: &str = "SHA256SUMS.txt";
pub const SUMS_SIG: &str = "SHA256SUMS.txt.minisig";
/// No package is bigger than this (they are about 30 MB).
pub const MAX_PACKAGE: u64 = 1 << 30;
/// Nor a single file inside the portable zip.
const MAX_UNZIPPED: u64 = 1 << 30;
/// The host's HTTPS download to a file: calls `progress(done, total)` as it goes and stops when
/// that returns false.
pub type HttpDownload = Arc<dyn Fn(&str, &Path, &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), FetchError> + Send + Sync>;
/// How an installation is updated in place.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Method {
/// Replace this AppImage file.
AppImage(PathBuf),
/// Run the downloaded `.msi`.
Msi,
/// Swap the running executable (at this path) for the portable zip's one.
PortableZip(PathBuf),
/// Swap the running executable (at this path) for the downloaded `.exe`.
Exe(PathBuf),
/// Open the downloaded disk image.
Dmg,
}
/// The in-place update for an installation, if it has one. `exe` is the running executable.
pub fn method(kind: &InstallKind, exe: Option<&Path>) -> Option<Method> {
match kind {
InstallKind::AppImage(path) => Some(Method::AppImage(path.clone())),
InstallKind::WindowsMsi => Some(Method::Msi),
InstallKind::WindowsPortable => exe.map(|e| Method::PortableZip(e.to_path_buf())),
InstallKind::WindowsExe => exe.map(|e| Method::Exe(e.to_path_buf())),
InstallKind::MacApp(_) => Some(Method::Dmg),
_ => None,
}
}
/// What to do once the package is verified and in place.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome {
/// The new version replaced this one: start `program` after SolidCraft exits.
Restart(PathBuf),
/// Start this installer (program, arguments) after SolidCraft exits.
Installer(PathBuf, Vec<String>),
/// The disk image was opened for the user.
Opened(PathBuf),
}
/// `SHA256SUMS.txt` lines (`<hex> <name>` or `<hex> *<name>`): the lowercase hex for `name`.
pub fn expected_sha256(sums: &str, name: &str) -> Option<String> {
sums.lines().find_map(|line| {
let (hash, file) = line.trim().split_once(char::is_whitespace)?;
let file = file.trim_start().trim_start_matches('*');
(file == name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_ascii_lowercase())
})
}
/// The SHA-256 of a file, in lowercase hex.
pub fn sha256_file(path: &Path) -> Result<String, String> {
let mut f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut h = Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
let n = f.read(&mut buf).map_err(|e| format!("{}: {e}", path.display()))?;
if n == 0 {
break;
}
h.update(buf.get(..n).unwrap_or_default());
}
Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect())
}
/// `sums` is signed by `key` (minisign, prehashed) with a trusted comment naming `tag`.
pub fn verify_signature(sums: &[u8], signature: &str, key: &str, tag: &str) -> Result<(), String> {
let key = minisign_verify::PublicKey::from_base64(key.trim()).map_err(|e| format!("update signing key: {e}"))?;
let sig = minisign_verify::Signature::decode(signature).map_err(|e| format!("{SUMS_SIG}: {e}"))?;
key.verify(sums, &sig, false).map_err(|e| format!("{SUMS} isn't signed by SolidCraft's release key: {e}"))?;
// The comment is covered by the signature: an older release's signed list can't stand in.
let comment = sig.trusted_comment();
if !comment.split_whitespace().any(|w| w == tag) {
return Err(format!("{SUMS_SIG} is for `{comment}`, not {tag}"));
}
Ok(())
}
/// Progress of a running update, shared with the UI.
#[derive(Default)]
pub struct Progress {
pub done: AtomicU64,
pub total: AtomicU64,
pub cancel: AtomicBool,
}
/// What the update needs from the host.
pub struct Net {
pub get: super::HttpGet,
pub download: HttpDownload,
}
/// Download, verify and apply `release` for `method`. Runs on a background thread. `staging` is
/// a folder for downloads that aren't written next to their target (the `.msi`, the `.dmg`).
pub fn run(
release: &Release,
kind: &InstallKind,
method: &Method,
arch: &str,
net: &Net,
staging: &Path,
progress: &Progress,
) -> Result<Outcome, String> {
let asset = release.asset_for(kind, arch).ok_or_else(|| format!("this release has no package for a {} on {arch}", kind.label()))?;
if asset.size > MAX_PACKAGE {
return Err(format!("{} is too large ({} bytes)", asset.name, asset.size));
}
let sums_url = release.assets.iter().find(|a| a.name == SUMS).map(|a| a.url.clone()).ok_or_else(|| format!("this release has no {SUMS}"))?;
let sums = (net.get)(&sums_url).map_err(|e| format!("{SUMS}: {e}"))?;
if let Some(key) = PUBLIC_KEY {
let sig_url = release
.assets
.iter()
.find(|a| a.name == SUMS_SIG)
.map(|a| a.url.clone())
.ok_or_else(|| format!("this release isn't signed (no {SUMS_SIG})"))?;
let sig = (net.get)(&sig_url).map_err(|e| format!("{SUMS_SIG}: {e}"))?;
verify_signature(&sums, &String::from_utf8_lossy(&sig), key, &release.tag)?;
}
let sums = String::from_utf8_lossy(&sums);
let expected = expected_sha256(&sums, &asset.name).ok_or_else(|| format!("{SUMS} doesn't list {}", asset.name))?;
// Where the download goes: beside its target when it replaces a file (one file system, so
// the final rename is atomic), else in the staging folder.
let part = match method {
Method::AppImage(target) => sibling(target, ".update")?,
Method::Exe(exe) => sibling(exe, ".update")?,
Method::Msi | Method::Dmg | Method::PortableZip(_) => {
std::fs::create_dir_all(staging).map_err(|e| format!("{}: {e}", staging.display()))?;
staging.join(&asset.name)
}
};
let cleanup = |e: String| {
let _ = std::fs::remove_file(&part);
e
};
progress.total.store(asset.size, Ordering::Relaxed);
let report = |done: u64, total: Option<u64>| {
progress.done.store(done, Ordering::Relaxed);
if let Some(t) = total {
progress.total.store(t, Ordering::Relaxed);
}
!progress.cancel.load(Ordering::Relaxed) && done <= MAX_PACKAGE
};
(net.download)(&asset.url, &part, &report).map_err(|e| cleanup(format!("{}: {e}", asset.name)))?;
if progress.cancel.load(Ordering::Relaxed) {
return Err(cleanup("cancelled".into()));
}
let actual = sha256_file(&part).map_err(cleanup)?;
if actual != expected {
return Err(cleanup(format!("{} doesn't match {SUMS} (got {actual}, expected {expected}); it was deleted", asset.name)));
}
apply(method, &part).map_err(cleanup)
}
/// Put a verified package in place.
pub fn apply(method: &Method, package: &Path) -> Result<Outcome, String> {
match method {
Method::AppImage(target) => {
make_executable(package)?;
std::fs::rename(package, target).map_err(|e| format!("replacing {}: {e}", target.display()))?;
Ok(Outcome::Restart(target.clone()))
}
Method::Exe(exe) => {
swap(exe, package)?;
Ok(Outcome::Restart(exe.clone()))
}
Method::PortableZip(exe) => {
let dir = exe.parent().ok_or_else(|| format!("{} has no folder", exe.display()))?;
let name = exe.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "solidcraft.exe".into());
let files = [name.as_str(), "solidcraft-cli.exe"];
let mut extracted = Vec::new();
for file in files {
let to = dir.join(format!("{file}.update"));
match unzip_one(package, file, &to) {
Ok(true) => extracted.push((dir.join(file), to)),
Ok(false) if file == name => return Err(format!("the portable zip has no {file}")),
Ok(false) => {}
Err(e) => {
for (_, t) in &extracted {
let _ = std::fs::remove_file(t);
}
return Err(e);
}
}
}
let _ = std::fs::remove_file(package);
for (target, new) in &extracted {
swap(target, new)?;
}
Ok(Outcome::Restart(exe.clone()))
}
Method::Msi => Ok(Outcome::Installer(PathBuf::from("msiexec"), vec!["/i".into(), package.to_string_lossy().to_string(), "/passive".into()])),
Method::Dmg => Ok(Outcome::Opened(package.to_path_buf())),
}
}
/// `path` with `suffix` appended to its file name, in the same folder.
fn sibling(path: &Path, suffix: &str) -> Result<PathBuf, String> {
let name = path.file_name().ok_or_else(|| format!("{} has no file name", path.display()))?;
Ok(path.with_file_name(format!("{}{suffix}", name.to_string_lossy())))
}
/// Replace `target` (possibly the running executable) with `new`: `target` becomes
/// `target.old`, `new` takes its name; on failure the old file is put back.
pub fn swap(target: &Path, new: &Path) -> Result<(), String> {
let old = sibling(target, ".old")?;
let _ = std::fs::remove_file(&old);
let had = target.exists();
if had {
std::fs::rename(target, &old).map_err(|e| format!("moving {} aside: {e}", target.display()))?;
}
if let Err(e) = std::fs::rename(new, target) {
if had {
let _ = std::fs::rename(&old, target);
}
return Err(format!("putting the new {} in place: {e}", target.display()));
}
// Not running: no need to keep the old one (the running exe's `.old` goes at next start).
let _ = std::fs::remove_file(&old);
Ok(())
}
/// Copy the zip entry whose file name is `name` (in any folder) to `to`. False: no such entry.
fn unzip_one(zip: &Path, name: &str, to: &Path) -> Result<bool, String> {
let file = std::fs::File::open(zip).map_err(|e| format!("{}: {e}", zip.display()))?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", zip.display()))?;
let index = (0..archive.len()).find(|&i| {
archive.name_for_index(i).is_some_and(|n| n.rsplit(['/', '\\']).next().is_some_and(|f| f.eq_ignore_ascii_case(name)) && !n.ends_with('/'))
});
let Some(index) = index else { return Ok(false) };
let mut entry = archive.by_index(index).map_err(|e| format!("{}: {e}", zip.display()))?;
if entry.size() > MAX_UNZIPPED {
return Err(format!("{name} in the zip is too large"));
}
// The file is closed at the end of this block (before it may be deleted below).
let copied = {
let mut out = std::fs::File::create(to).map_err(|e| format!("{}: {e}", to.display()))?;
let copied = std::io::copy(&mut (&mut entry).take(MAX_UNZIPPED + 1), &mut out).map_err(|e| format!("{}: {e}", to.display()))?;
out.flush().map_err(|e| format!("{}: {e}", to.display()))?;
copied
};
if copied > MAX_UNZIPPED {
let _ = std::fs::remove_file(to);
return Err(format!("{name} in the zip is too large"));
}
Ok(true)
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(not(unix))]
fn make_executable(_: &Path) -> Result<(), String> {
Ok(())
}
/// At start: delete the executable a previous update moved aside (`solidcraft.exe.old`).
pub fn clean_up_after_update(exe: Option<&Path>) {
for file in [exe.map(Path::to_path_buf), exe.and_then(|e| e.parent()).map(|d| d.join("solidcraft-cli.exe"))].into_iter().flatten() {
if let Ok(old) = sibling(&file, ".old")
&& old.exists()
{
let _ = std::fs::remove_file(old);
}
}
}
+205
View File
@@ -0,0 +1,205 @@
use super::install::*;
use super::{Asset, FetchError, InstallKind, Release};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// A key pair made with `minisign -G -W` for these tests, and its signature of [`SIGNED_SUMS`]
/// with the trusted comment "SolidCraft v9.9.9".
const TEST_KEY: &str = "RWSnDqhIJ/zGKMSbsGUl4/prZLXqi3PwpEeY/D8gvdsEUeZRO9yWCoSM";
const SIGNED_SUMS: &str = "aaaa solidcraft-linux-x86_64.AppImage\n";
const SIGNATURE: &str = "untrusted comment: signature from minisign secret key\nRUSnDqhIJ/zGKEyvbowsT8vEGvdv2OL9Rwv4bo2m7pJC54t2UQWXjU0+V7ayPB0Okk1teY53qZSTugz2Dnko35A3Xc1jdNFZCwU=\ntrusted comment: SolidCraft v9.9.9\npCRA0RNYxyt4SSqLbfI3p00xsCE4Rt8ixJWAQfoyb226cz/XdS7A2PRdG7hFmRmfg4h6zmtIt3iRLSKjk6RsAQ==\n";
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-update-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn hex(bytes: &[u8]) -> String {
use sha2::Digest;
sha2::Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn checksum_lists_are_read() {
let h = "a".repeat(64);
let sums = format!(
"{h} solidcraft-linux-x86_64.AppImage\n{} *solidcraft-windows-x64.msi\nnot a line\n{} short.txt\n",
"B".repeat(64),
"c".repeat(10)
);
assert_eq!(expected_sha256(&sums, "solidcraft-linux-x86_64.AppImage"), Some(h));
assert_eq!(expected_sha256(&sums, "solidcraft-windows-x64.msi"), Some("b".repeat(64)), "binary-mode marker and upper case");
assert_eq!(expected_sha256(&sums, "short.txt"), None, "not a SHA-256");
assert_eq!(expected_sha256(&sums, "solidcraft-linux-x86_64"), None);
}
#[test]
fn signatures_must_match_the_key_the_list_and_the_release() {
assert_eq!(verify_signature(SIGNED_SUMS.as_bytes(), SIGNATURE, TEST_KEY, "v9.9.9"), Ok(()));
assert!(verify_signature(SIGNED_SUMS.as_bytes(), SIGNATURE, TEST_KEY, "v9.9.8").is_err(), "an older release's signed list");
assert!(verify_signature(b"bbbb solidcraft-linux-x86_64.AppImage\n", SIGNATURE, TEST_KEY, "v9.9.9").is_err(), "a changed list");
let other = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
assert!(verify_signature(SIGNED_SUMS.as_bytes(), SIGNATURE, other, "v9.9.9").is_err(), "another key");
assert!(verify_signature(SIGNED_SUMS.as_bytes(), "garbage", TEST_KEY, "v9.9.9").is_err());
}
#[test]
fn methods_per_installation() {
let exe = Path::new("/x/solidcraft.exe");
assert_eq!(method(&InstallKind::AppImage("/a/S.AppImage".into()), Some(exe)), Some(Method::AppImage("/a/S.AppImage".into())));
assert_eq!(method(&InstallKind::WindowsMsi, Some(exe)), Some(Method::Msi));
assert_eq!(method(&InstallKind::WindowsPortable, Some(exe)), Some(Method::PortableZip(exe.into())));
assert_eq!(method(&InstallKind::WindowsExe, Some(exe)), Some(Method::Exe(exe.into())));
assert_eq!(method(&InstallKind::MacApp("/A/S.app".into()), Some(exe)), Some(Method::Dmg));
for never in [
InstallKind::Flatpak("id".into()),
InstallKind::Snap { store: true },
InstallKind::Snap { store: false },
InstallKind::Deb,
InstallKind::Rpm,
InstallKind::LinuxTarball,
InstallKind::Other,
InstallKind::Dev,
] {
assert_eq!(method(&never, Some(exe)), None, "{never:?} is never replaced in place");
}
}
const BASE: &str = "https://github.com/bherbruck/solidcraft/releases/download/v9.9.9/";
fn release_with(assets: &[(&str, usize)]) -> Release {
Release {
tag: "v9.9.9".into(),
name: "SolidCraft v9.9.9".into(),
notes: String::new(),
page: "https://github.com/bherbruck/solidcraft/releases/tag/v9.9.9".into(),
assets: assets.iter().map(|(n, size)| Asset { name: n.to_string(), url: format!("{BASE}{n}"), size: *size as u64 }).collect(),
}
}
/// A network that serves `files` by name.
fn net(files: Vec<(String, Vec<u8>)>) -> Net {
let files = Arc::new(files);
let f2 = files.clone();
let find = move |files: &[(String, Vec<u8>)], url: &str| files.iter().find(|(n, _)| url == format!("{BASE}{n}")).map(|(_, b)| b.clone());
let find2 = find;
Net {
get: Arc::new(move |url: &str| find(&files, url).ok_or_else(|| FetchError::Failed(format!("404 {url}")))),
download: Arc::new(move |url: &str, to: &Path, progress: &dyn Fn(u64, Option<u64>) -> bool| {
let body = find2(&f2, url).ok_or_else(|| FetchError::Failed(format!("404 {url}")))?;
let mut file = std::fs::File::create(to).map_err(|e| FetchError::Failed(e.to_string()))?;
for chunk in body.chunks(4) {
file.write_all(chunk).map_err(|e| FetchError::Failed(e.to_string()))?;
if !progress(chunk.len() as u64, Some(body.len() as u64)) {
return Err(FetchError::Failed("cancelled".into()));
}
}
Ok(())
}),
}
}
#[test]
fn an_appimage_is_replaced_only_when_it_verifies() {
let dir = scratch("appimage");
let target = dir.join("SolidCraft.AppImage");
std::fs::write(&target, b"old version").unwrap();
let new = b"new version".to_vec();
let name = "solidcraft-linux-x86_64.AppImage";
let kind = InstallKind::AppImage(target.clone());
let m = Method::AppImage(target.clone());
let r = release_with(&[(name, new.len()), (SUMS, 100)]);
// A package that doesn't match the list: deleted, the old one untouched.
let wrong = format!("{} {name}\n", hex(b"something else"));
let n = net(vec![(name.into(), new.clone()), (SUMS.into(), wrong.into_bytes())]);
let err = run(&r, &kind, &m, "x86_64", &n, &dir.join("staging"), &Progress::default()).unwrap_err();
assert!(err.contains("doesn't match"), "{err}");
assert_eq!(std::fs::read(&target).unwrap(), b"old version");
assert!(!dir.join("SolidCraft.AppImage.update").exists(), "the bad download is deleted");
// Not listed at all.
let n = net(vec![(name.into(), new.clone()), (SUMS.into(), b"".to_vec())]);
assert!(run(&r, &kind, &m, "x86_64", &n, &dir, &Progress::default()).unwrap_err().contains("doesn't list"));
// No checksum list in the release: refused before downloading.
let bare = release_with(&[(name, new.len())]);
assert!(run(&bare, &kind, &m, "x86_64", &n, &dir, &Progress::default()).unwrap_err().contains(SUMS));
// Cancelled: nothing changes.
let good = format!("{} {name}\n", hex(&new));
let n = net(vec![(name.into(), new.clone()), (SUMS.into(), good.clone().into_bytes())]);
let p = Progress::default();
p.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(run(&r, &kind, &m, "x86_64", &n, &dir, &p).is_err());
assert_eq!(std::fs::read(&target).unwrap(), b"old version");
// Verified: renamed over the old one, executable, restart offered.
let out = run(&r, &kind, &m, "x86_64", &n, &dir, &Progress::default()).unwrap();
assert_eq!(out, Outcome::Restart(target.clone()));
assert_eq!(std::fs::read(&target).unwrap(), new);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o755);
}
assert_eq!(std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).filter(|e| e.file_name().to_string_lossy().ends_with(".update")).count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_portable_zip_swaps_both_executables() {
let dir = scratch("portable");
let exe = dir.join("solidcraft.exe");
std::fs::write(&exe, b"old app").unwrap();
std::fs::write(dir.join("solidcraft-cli.exe"), b"old cli").unwrap();
let mut zip_bytes = Vec::new();
{
let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_bytes));
let o = zip::write::SimpleFileOptions::default();
z.add_directory("solidcraft-9.9.9-windows-x64-portable/", o).unwrap();
for (n, b) in [("solidcraft.exe", &b"new app"[..]), ("solidcraft-cli.exe", b"new cli"), ("README.md", b"readme")] {
z.start_file(format!("solidcraft-9.9.9-windows-x64-portable/{n}"), o).unwrap();
z.write_all(b).unwrap();
}
z.finish().unwrap();
}
let name = "solidcraft-windows-x64-portable.zip";
let r = release_with(&[(name, zip_bytes.len()), (SUMS, 100)]);
let n = net(vec![(name.into(), zip_bytes.clone()), (SUMS.into(), format!("{} {name}\n", hex(&zip_bytes)).into_bytes())]);
let out =
run(&r, &InstallKind::WindowsPortable, &Method::PortableZip(exe.clone()), "x86_64", &n, &dir.join("staging"), &Progress::default()).unwrap();
assert_eq!(out, Outcome::Restart(exe.clone()));
assert_eq!(std::fs::read(&exe).unwrap(), b"new app");
assert_eq!(std::fs::read(dir.join("solidcraft-cli.exe")).unwrap(), b"new cli");
assert!(!dir.join("staging").join(name).exists(), "the zip is removed");
// A zip without the app is refused and changes nothing.
std::fs::write(dir.join("empty.zip"), {
let mut b = Vec::new();
zip::ZipWriter::new(std::io::Cursor::new(&mut b)).finish().unwrap();
b
})
.unwrap();
assert!(apply(&Method::PortableZip(exe.clone()), &dir.join("empty.zip")).is_err());
assert_eq!(std::fs::read(&exe).unwrap(), b"new app");
// The moved-aside executable of an update goes at the next start.
std::fs::write(dir.join("solidcraft.exe.old"), b"x").unwrap();
clean_up_after_update(Some(&exe));
assert!(!dir.join("solidcraft.exe.old").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn installers_and_disk_images_are_handed_over() {
let dir = scratch("msi");
let pkg = dir.join("solidcraft-windows-x64.msi");
std::fs::write(&pkg, b"msi").unwrap();
assert_eq!(
apply(&Method::Msi, &pkg).unwrap(),
Outcome::Installer("msiexec".into(), vec!["/i".into(), pkg.to_string_lossy().to_string(), "/passive".into()])
);
assert_eq!(apply(&Method::Dmg, &pkg).unwrap(), Outcome::Opened(pkg.clone()));
let _ = std::fs::remove_dir_all(&dir);
}
+27 -1
View File
@@ -17,6 +17,30 @@ choices:
**Preferences ▸ General ▸ Updates** has **Check for updates automatically** (on by default) and
**Check now**, which checks right away and shows the window even for a version you skipped.
## Updating in one click
Where SolidCraft can replace itself safely, the window also has **Update now**:
| Installed as | Update now does |
|---|---|
| AppImage | Downloads the new AppImage next to the old one, verifies it, renames it over the old file and offers **Restart now** |
| Windows installer (`.msi`) | **Install (closes SolidCraft)**: downloads and verifies the `.msi`, closes SolidCraft (asking about unsaved designs first) and starts the installer |
| Windows portable `.zip` or `.exe` | Downloads and verifies the package, swaps in the new `solidcraft.exe` (and `solidcraft-cli.exe`) and offers **Restart now**; the old file is removed at the next start |
| macOS | **Download and open**: downloads and verifies the `.dmg` and opens it; drag SolidCraft to Applications to replace the old copy |
| Flatpak, Snap, `.deb`, `.rpm`, `.tar.gz` | Nothing: your package manager updates these (the window shows the command), or you unpack the new archive |
Every download is verified before anything changes: its SHA-256 must match the release's
`SHA256SUMS.txt`, fetched over HTTPS from the same release, and, once the project signs its
releases, that list must carry a valid signature by SolidCraft's release key that names the
release (see [releasing](https://github.com/bherbruck/solidcraft/blob/main/docs/releasing.md#update-signing)).
A download that doesn't verify is deleted and the installed copy is left as it was; **Download**
still gets the package for a manual install. On macOS SolidCraft opens the disk image rather than
replacing the app itself, because the app is ad-hoc signed and not notarized: replacing it in the
background would install code macOS can't vouch for without you seeing it.
Update now needs write access to the installed file: an AppImage in a folder you can't write to
fails with a message, and **Download** is the way to update it.
## What is sent
One HTTPS request, at start and at most once a day while SolidCraft runs:
@@ -28,7 +52,9 @@ Accept: application/vnd.github+json
X-GitHub-Api-Version: 2022-11-28
```
That's all: no version number, no machine or user identifier, no usage data, no cookies. GitHub
**Update now** then downloads `SHA256SUMS.txt` (and its signature) and the one package from the
release's own `github.com/bherbruck/solidcraft/releases/download/…` links, with the same
User-Agent. That's all: no version number, no machine or user identifier, no usage data, no cookies. GitHub
sees the request like any other visit to the API (your IP address, the time); see
[GitHub's privacy statement](https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement).
SolidCraft compares the answer with its own version on your computer.
+41
View File
@@ -206,6 +206,47 @@ Renew the credentials before `--expires` by repeating steps 2 and 3. The snap us
confinement and only plugs that connect automatically, apart from `removable-media`, which users
connect with `sudo snap connect solidcraft:removable-media` (getting-started.md).
## Update signing
The app's **Update now** (docs/book/src/guide/updates.md) only installs a package whose SHA-256
matches the release's `SHA256SUMS.txt`, fetched over HTTPS. That protects against corrupted and
tampered downloads in transit, but not against someone who can upload release assets. Signing
the checksum list closes that gap: once `PUBLIC_KEY` in `crates/ui-egui/src/update/install.rs`
is set, the app also requires `SHA256SUMS.txt.minisig`, a [minisign](https://jedisct1.github.io/minisign/)
signature by that key whose trusted comment names the release (`SolidCraft v<version>`), and
refuses to update in place otherwise. The release workflow signs the list when the
`UPDATE_SIGNING_KEY` secret exists, and fails when the app expects signatures but the secret is
missing.
To turn it on once (owner):
1. On a trusted machine with minisign (`sudo apt install minisign`, `brew install minisign` or
`winget install minisign`), make a key pair without a password (the workflow can't type one):
```sh
minisign -G -W -p solidcraft-update.pub -s solidcraft-update.key
```
2. Store the secret key as the repository secret and keep an offline backup of it (a lost key
means a new key and an app release that carries it; a leaked key means the same, quickly):
```sh
gh secret set UPDATE_SIGNING_KEY -R bherbruck/solidcraft < solidcraft-update.key
```
3. Put the public key (the second line of `solidcraft-update.pub`, starting with `RW`) into
`crates/ui-egui/src/update/install.rs`:
```rust
pub const PUBLIC_KEY: Option<&str> = Some("RW…");
```
and land that through a PR. Builds from then on refuse unsigned updates; releases built
from then on carry `SHA256SUMS.txt.minisig`.
4. Delete `solidcraft-update.key` from the machine once the backup is safe.
Older builds without the key keep updating with the SHA-256 check alone.
## Repository setup
The `release` branch is advanced only for intentional release batches. Restrict its writers to