diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66a4a9c5..1bd42e3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" 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, 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) -> 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::().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, 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, 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, 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. diff --git a/assets/locales/de.json b/assets/locales/de.json index d741a6ff..02a02e0d 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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." } diff --git a/assets/locales/en.json b/assets/locales/en.json index 64374f71..75332873 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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." } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index fcb6d4b9..668509b1 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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}。何も変更されていません。「ダウンロード」でパッケージを入手できます。" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 50087f0a..a64401bf 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다." } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 42dc0bf9..625f0426 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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}。未做任何更改;可用“下载”获取安装包。" } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f0da304f..a83683fa 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1a9aa3ce..37c21fb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -215,6 +215,8 @@ pub struct Services { pub graphics_path: Option, /// HTTPS GET for the update check (`update`); None: never check (browser, tests). pub http_get: Option, + /// HTTPS download to a file, with progress, for one-click updates (`update::install`). + pub http_download: Option, } pub struct SolveApp { diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index 121d507f..3f0c171a 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -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, + /// The one-click update in progress (`install`). + job: Option<(Receiver>, Arc)>, + /// The last one-click update's result. + pub installed: Option>, + /// A program for the host to start as SolidCraft exits (restart, installer). + launch: Option<(PathBuf, Vec)>, } 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 { + 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)> { + 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; diff --git a/crates/ui-egui/src/update/install.rs b/crates/ui-egui/src/update/install.rs new file mode 100644 index 00000000..8ec8325c --- /dev/null +++ b/crates/ui-egui/src/update/install.rs @@ -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) -> 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 { + 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), + /// The disk image was opened for the user. + Opened(PathBuf), +} + +/// `SHA256SUMS.txt` lines (` ` or ` *`): the lowercase hex for `name`. +pub fn expected_sha256(sums: &str, name: &str) -> Option { + 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 { + 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 { + 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| { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/crates/ui-egui/src/update/install_tests.rs b/crates/ui-egui/src/update/install_tests.rs new file mode 100644 index 00000000..cd1d8bc3 --- /dev/null +++ b/crates/ui-egui/src/update/install_tests.rs @@ -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)>) -> Net { + let files = Arc::new(files); + let f2 = files.clone(); + let find = move |files: &[(String, Vec)], 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) -> 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); +} diff --git a/docs/book/src/guide/updates.md b/docs/book/src/guide/updates.md index 0babbabd..9dd8aec3 100644 --- a/docs/book/src/guide/updates.md +++ b/docs/book/src/guide/updates.md @@ -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. diff --git a/docs/releasing.md b/docs/releasing.md index 3276d2fa..96be9681 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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`), 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