From fe61243ebfd7f9a7bea4cc326028e859d6aab052 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 09:32:22 -0400 Subject: [PATCH 1/4] feat(update): add craft-update crate for release checking, install, and self-updating (#311) * feat(update): add craft-update crate for release checking, install, and self-updating * fix(update): provide non-windows stub for finish * fix(update): address CodeRabbit review feedback on craft-update * feat(dialog,self_update): enhance prompt styling, support standalone self-updates * fix(update): make startup prompt modal with Escape, skip non-file tar entries --- Cargo.lock | 240 +- Cargo.toml | 1 + crates/update/Cargo.toml | 46 + crates/update/src/app.rs | 75 + crates/update/src/desktop_entry.rs | 186 ++ crates/update/src/detect.rs | 777 +++++ crates/update/src/dialog.rs | 212 ++ crates/update/src/distro.rs | 108 + crates/update/src/http.rs | 1124 +++++++ crates/update/src/install.rs | 2870 +++++++++++++++++ crates/update/src/lib.rs | 72 + crates/update/src/manifest.rs | 61 + crates/update/src/packages.rs | 161 + crates/update/src/paths.rs | 131 + crates/update/src/platform.rs | 423 +++ crates/update/src/portable.rs | 857 +++++ crates/update/src/process.rs | 597 ++++ crates/update/src/protocol.rs | 84 + crates/update/src/proxy.rs | 281 ++ crates/update/src/queue.rs | 512 +++ crates/update/src/release.rs | 438 +++ crates/update/src/self_update.rs | 1350 ++++++++ crates/update/src/source.rs | 198 ++ crates/update/src/status.rs | 64 + crates/update/src/suite.rs | 70 + crates/update/src/time.rs | 26 + crates/update/src/version.rs | 183 ++ .../tests/fixtures/photocraft-release.json | 16 + .../tests/fixtures/soundcraft-release.json | 22 + 29 files changed, 11180 insertions(+), 5 deletions(-) create mode 100644 crates/update/Cargo.toml create mode 100644 crates/update/src/app.rs create mode 100644 crates/update/src/desktop_entry.rs create mode 100644 crates/update/src/detect.rs create mode 100644 crates/update/src/dialog.rs create mode 100644 crates/update/src/distro.rs create mode 100644 crates/update/src/http.rs create mode 100644 crates/update/src/install.rs create mode 100644 crates/update/src/lib.rs create mode 100644 crates/update/src/manifest.rs create mode 100644 crates/update/src/packages.rs create mode 100644 crates/update/src/paths.rs create mode 100644 crates/update/src/platform.rs create mode 100644 crates/update/src/portable.rs create mode 100644 crates/update/src/process.rs create mode 100644 crates/update/src/protocol.rs create mode 100644 crates/update/src/proxy.rs create mode 100644 crates/update/src/queue.rs create mode 100644 crates/update/src/release.rs create mode 100644 crates/update/src/self_update.rs create mode 100644 crates/update/src/source.rs create mode 100644 crates/update/src/status.rs create mode 100644 crates/update/src/suite.rs create mode 100644 crates/update/src/time.rs create mode 100644 crates/update/src/version.rs create mode 100644 crates/update/tests/fixtures/photocraft-release.json create mode 100644 crates/update/tests/fixtures/soundcraft-release.json diff --git a/Cargo.lock b/Cargo.lock index d299330a..422b0425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -974,7 +974,7 @@ name = "craft-settings" version = "0.1.0" dependencies = [ "craft-atomic", - "dirs", + "dirs 7.0.0", "serde", "tempfile", "toml_edit", @@ -997,6 +997,31 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-update" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs 6.0.0", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + [[package]] name = "craft-window" version = "0.1.0" @@ -1120,6 +1145,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "digest" version = "0.10.7" @@ -1130,6 +1161,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "7.0.0" @@ -1821,6 +1861,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -1830,7 +1882,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -3034,6 +3086,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -3615,6 +3673,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -3708,6 +3779,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3809,6 +3886,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -3875,6 +3961,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -4195,12 +4287,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4380,6 +4474,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -5116,6 +5214,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -5382,6 +5510,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" + [[package]] name = "typenum" version = "1.20.1" @@ -5480,6 +5614,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5520,6 +5683,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5611,6 +5780,16 @@ dependencies = [ "vello_common 0.1.0", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5651,6 +5830,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -5852,7 +6040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -6455,12 +6643,28 @@ dependencies = [ "xml", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winsafe" version = "0.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "write-fonts" version = "0.48.1" @@ -6807,6 +7011,20 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6819,6 +7037,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 8000fb5f..b09eee6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } craft-ui = { path = "crates/ui" } +craft-update = { path = "crates/update" } craft-window = { path = "crates/window" } egui = { version = "0.36", default-features = false } rayon = "1" diff --git a/crates/update/Cargo.toml b/crates/update/Cargo.toml new file mode 100644 index 00000000..353e8f33 --- /dev/null +++ b/crates/update/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "craft-update" +description = "Release checking, downloading, installing and self-updating for the Craft App Suite and ArtCraft Launcher" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[features] +default = [] +egui = ["dep:egui"] +local-mirror = [] + +[dependencies] +egui = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +log = "0.4" +dirs = "6" +getrandom = "0.3" +tempfile = "3" +sha2 = "0.10" +ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring", "gzip", "platform-verifier"] } +chrono = { workspace = true } +semver = { version = "1", features = ["serde"] } + +[dev-dependencies] +flate2 = "1" + +[target.'cfg(unix)'.dependencies] +flate2 = "1" +tar = { version = "0.4", default-features = false } + +[target.'cfg(target_os = "macos")'.dependencies] +plist = "1" + +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.55" +dunce = "1" +zip = { version = "8", default-features = false, features = ["deflate"] } +winsafe = { version = "0.0.29", features = ["kernel", "version"] } +verifysign = "1" diff --git a/crates/update/src/app.rs b/crates/update/src/app.rs new file mode 100644 index 00000000..c5e0ce25 --- /dev/null +++ b/crates/update/src/app.rs @@ -0,0 +1,75 @@ +//! Which app an installer or an update works on. + +/// A Craft App, as its releases and installers know it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct App { + /// Machine name: repo, binary, package and asset stem (`photocraft`). + pub id: &'static str, + /// Display name (`PhotoCraft`). + pub name: &'static str, + /// What it's for, in a few words (English). Written into the menu entries installers add. + pub tagline: &'static str, + /// GitHub `owner/repo`. Releases are only ever downloaded from this repository. + pub repo: &'static str, + /// Older names its release assets may carry (PdfCraft shipped as PrintCraft until 0.2.1). + pub former_ids: &'static [&'static str], + /// Older display names its installers registered (PdfCraft's MSI said PrintCraft until 0.2.1). + pub former_names: &'static [&'static str], + /// Its 128 px PNG icon, for the menu entry written beside an AppImage. `None`: no icon. + pub icon_png: Option<&'static [u8]>, +} + +impl App { + /// An app with no former names and no icon. + pub const fn new(id: &'static str, name: &'static str, tagline: &'static str, repo: &'static str) -> Self { + Self { id, name, tagline, repo, former_ids: &[], former_names: &[], icon_png: None } + } + + /// `ai.storyteller.`: macOS bundle id, Flatpak id and Linux desktop-file id. + pub fn bundle_id(&self) -> String { + format!("ai.storyteller.{}", self.id) + } + + pub fn releases_url(&self) -> String { + format!("https://github.com/{}/releases", self.repo) + } + + pub fn repo_url(&self) -> String { + format!("https://github.com/{}", self.repo) + } + + /// The app's page on getartcraft.com. + pub fn website(&self) -> String { + format!("https://getartcraft.com/apps/{}", self.id) + } + + /// Asset stems this app's releases may use, current first. + pub fn stems(&self) -> impl Iterator + '_ { + std::iter::once(self.id).chain(self.former_ids.iter().copied()) + } + + /// Display names this app's installers may have registered, current first. + pub fn names(&self) -> impl Iterator + '_ { + std::iter::once(self.name).chain(self.former_names.iter().copied()) + } + + /// Looks up an app in the Craft App Suite catalog by its id. + pub fn by_id(id: &str) -> Option<&'static App> { + crate::suite::by_id(id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn former_names_follow_the_current_one() { + let pdf = + App { former_ids: &["printcraft"], former_names: &["PrintCraft"], ..App::new("pdfcraft", "PdfCraft", "PDFs", "storytold/pdfcraft") }; + assert_eq!(pdf.stems().collect::>(), ["pdfcraft", "printcraft"]); + assert_eq!(pdf.names().collect::>(), ["PdfCraft", "PrintCraft"]); + assert_eq!(pdf.bundle_id(), "ai.storyteller.pdfcraft"); + assert_eq!(pdf.releases_url(), "https://github.com/storytold/pdfcraft/releases"); + } +} diff --git a/crates/update/src/desktop_entry.rs b/crates/update/src/desktop_entry.rs new file mode 100644 index 00000000..d6639bef --- /dev/null +++ b/crates/update/src/desktop_entry.rs @@ -0,0 +1,186 @@ +//! Menu entries (freedesktop `.desktop` files) for AppImages the launcher installs. +//! +//! An AppImage installs nothing by itself, so without an entry the app is missing from the +//! desktop's menu and, on Wayland, the dock can't match the window's app id to an icon. The +//! launcher writes `/applications/ai.storyteller..desktop` (the id each app's window +//! reports) and marks it as its own, so it only ever replaces or removes entries it wrote. +//! +//! The text is built by pure functions, tested on every platform; only Linux writes the files. + +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::path::Path; + +use crate::App; + +/// The key that marks an entry as written by the launcher. +pub const MARKER: &str = "X-ArtCraft-Launcher=true"; + +/// The menu categories and the window class each app's own `.desktop` file declares. +pub fn meta(app: &App) -> (&'static str, Option<&'static str>) { + match app.id { + "photocraft" => ("Graphics;2DGraphics;RasterGraphics;Photography;", Some("photocraft")), + "pdfcraft" => ("Office;Viewer;", Some("pdfcraft")), + "lightcraft" => ("Graphics;2DGraphics;RasterGraphics;Photography;", None), + "filmcraft" => ("AudioVideo;Video;AudioVideoEditing;", None), + "designcraft" => ("Graphics;Publishing;", None), + "vectorcraft" => ("Graphics;2DGraphics;VectorGraphics;", None), + "effectcraft" => ("AudioVideo;Video;Graphics;2DGraphics;", None), + "wordcraft" => ("Office;WordProcessor;", None), + "deckcraft" => ("Office;Presentation;", None), + "gridcraft" => ("Office;Spreadsheet;", None), + "cadcraft" => ("Graphics;Engineering;VectorGraphics;", None), + "soundcraft" => ("AudioVideo;Audio;Midi;Mixer;Sequencer;Recorder;", None), + "figcraft" => ("Graphics;2DGraphics;VectorGraphics;X-Design;", Some("figcraft")), + _ => ("Graphics;", None), + } +} + +/// `path` as one quoted argument of an `Exec=` key, per the Desktop Entry spec: inside double +/// quotes `"`, `` ` ``, `$` and `\` take a backslash, then the string-value rule doubles every +/// backslash, and `%` (field codes) is written `%%`. `None` for paths that can't be written +/// safely (not UTF-8, holding a control character such as a line break, or holding `=`, which the +/// spec forbids in the executable name or path even when quoted). +pub fn exec_quote(path: &Path) -> Option { + let s = path.to_str()?; + if s.chars().any(|c| c.is_control() || c == '=') { + return None; + } + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' | '`' | '$' => { + out.push_str("\\\\"); + out.push(c); + } + '\\' => out.push_str("\\\\\\\\"), + '%' => out.push_str("%%"), + c => out.push(c), + } + } + out.push('"'); + Some(out) +} + +/// A localestring/string value: backslashes and line breaks escaped. +fn value(s: &str) -> String { + s.replace('\\', "\\\\").replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t") +} + +/// The entry for `app` installed as the AppImage `appimage`; `icon` when its icon is installed +/// under the app's id. `None` when the path can't be put in an `Exec=` line. +pub fn render(app: &App, appimage: &Path, icon: bool) -> Option { + let exec = exec_quote(appimage)?; + let (categories, wm_class) = meta(app); + let mut text = String::from("[Desktop Entry]\nType=Application\n"); + text.push_str(&format!("Name={}\n", value(app.name))); + text.push_str(&format!("Comment={}\n", value(app.tagline))); + text.push_str(&format!("Exec={exec} %F\n")); + if icon { + text.push_str(&format!("Icon={}\n", app.bundle_id())); + } + text.push_str("Terminal=false\n"); + text.push_str(&format!("Categories={categories}\n")); + text.push_str(&format!("StartupWMClass={}\n", wm_class.map_or_else(|| app.bundle_id(), str::to_string))); + text.push_str(MARKER); + text.push('\n'); + Some(text) +} + +/// Whether the launcher wrote this entry. +pub fn is_ours(text: &str) -> bool { + text.lines().any(|l| l.trim() == MARKER) +} + +/// The program an entry's `Exec=` runs: its first argument, unquoted. Reads the launcher's own +/// quoting and the plain `Exec="" %F` the apps' AppImages write for themselves. +pub fn exec_program(text: &str) -> Option { + let line = text.lines().find_map(|l| l.strip_prefix("Exec="))?; + // String-value escapes first, then the quoting rule. + let mut unescaped = String::with_capacity(line.len()); + let mut chars = line.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('s') => unescaped.push(' '), + Some('n') => unescaped.push('\n'), + Some('t') => unescaped.push('\t'), + Some('r') => unescaped.push('\r'), + Some(other) => unescaped.push(other), + None => unescaped.push('\\'), + } + } else { + unescaped.push(c); + } + } + let mut out = String::new(); + let mut chars = unescaped.trim_start().chars(); + if unescaped.trim_start().starts_with('"') { + chars.next(); + loop { + match chars.next()? { + '"' => break, + '\\' => out.push(chars.next()?), + c => out.push(c), + } + } + } else { + out.extend(chars.take_while(|c| !c.is_whitespace())); + } + Some(out.replace("%%", "%")) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn app(id: &str) -> &'static App { + App::by_id(id).unwrap() + } + + #[test] + fn writes_a_complete_entry() { + let text = render(app("photocraft"), Path::new("/home/ana/Applications/photocraft-0.6.0-linux-x86_64.AppImage"), true).unwrap(); + let expected = format!( + "[Desktop Entry]\nType=Application\nName=PhotoCraft\nComment={}\n\ + Exec=\"/home/ana/Applications/photocraft-0.6.0-linux-x86_64.AppImage\" %F\n\ + Icon=ai.storyteller.photocraft\nTerminal=false\nCategories=Graphics;2DGraphics;RasterGraphics;Photography;\n\ + StartupWMClass=photocraft\nX-ArtCraft-Launcher=true\n", + app("photocraft").tagline + ); + assert_eq!(text, expected); + assert!(is_ours(&text)); + let no_icon = render(app("wordcraft"), Path::new("/x/w.AppImage"), false).unwrap(); + assert!(!no_icon.contains("Icon=")); + assert!(no_icon.contains("StartupWMClass=ai.storyteller.wordcraft\n")); + assert!(no_icon.contains("Categories=Office;WordProcessor;\n")); + } + + #[test] + fn quotes_exec_paths_per_the_spec() { + assert_eq!(exec_quote(Path::new("/home/ana/My Apps/p.AppImage")).unwrap(), "\"/home/ana/My Apps/p.AppImage\""); + assert_eq!(exec_quote(Path::new("/a/\"q\" $HOME `x`.AppImage")).unwrap(), r#""/a/\\"q\\" \\$HOME \\`x\\`.AppImage""#); + assert_eq!(exec_quote(Path::new(r"/a\b/100%.AppImage")).unwrap(), r#""/a\\\\b/100%%.AppImage""#); + assert_eq!(exec_quote(Path::new("/a/line\nbreak.AppImage")), None); + // The spec forbids `=` in the executable name or path, even quoted. + assert_eq!(exec_quote(Path::new("/opt/artcraft=beta/app")), None); + assert!(render(app("photocraft"), Path::new("/a/line\nbreak.AppImage"), true).is_none()); + } + + #[test] + fn reads_back_the_program_it_wrote_and_the_apps_own_entries() { + for p in ["/home/ana/My Apps/p.AppImage", "/a/\"q\" $HOME `x`.AppImage", r"/a\b/100%.AppImage", "/plain.AppImage"] { + let text = render(app("gridcraft"), Path::new(p), false).unwrap(); + assert_eq!(exec_program(&text).map(PathBuf::from), Some(PathBuf::from(p)), "{p}"); + } + // PhotoCraft's AppRun writes its own entry, unmarked. + let theirs = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/ana/Applications/photocraft-0.5.0-linux-x86_64.AppImage\" %F\n"; + assert_eq!(exec_program(theirs).as_deref(), Some("/home/ana/Applications/photocraft-0.5.0-linux-x86_64.AppImage")); + assert!(!is_ours(theirs)); + assert_eq!(exec_program("[Desktop Entry]\nExec=photocraft %F\n").as_deref(), Some("photocraft")); + assert_eq!(exec_program("[Desktop Entry]\nName=x\n"), None); + } +} diff --git a/crates/update/src/detect.rs b/crates/update/src/detect.rs new file mode 100644 index 00000000..840245a1 --- /dev/null +++ b/crates/update/src/detect.rs @@ -0,0 +1,777 @@ +//! Finding Craft Apps that are already installed, however they got there. +//! +//! - macOS: `.app` in `/Applications` or `~/Applications`, read from its `Info.plist`. +//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), and portable +//! copies the launcher unpacked. +//! - Linux: AppImages in `~/Applications` (where the launcher and AppImageLauncher put them), +//! `~/AppImages` (Gear Lever) and `~/.local/bin`, Debian/RPM packages, Flatpaks, and tarball +//! installs. +//! - FreeBSD: tarball installs and `/usr/local/bin/`. + +use std::path::{Path, PathBuf}; + +use crate::{App, InstallSource, Installed, version}; + +use crate::manifest::Manifest; + +/// One look at the system, shared by every app's detection (package lists are read once). +#[derive(Default)] +pub struct Snapshot { + #[allow(dead_code)] + packages: Vec, +} + +impl Snapshot { + pub fn take() -> Self { + #[cfg(target_os = "linux")] + { + Self { packages: linux::packages() } + } + #[cfg(not(target_os = "linux"))] + { + Self::default() + } + } +} + +/// What's installed of `app`, best source first. +pub fn detect(app: &App, manifest: &Manifest, snapshot: &Snapshot) -> Option { + let _ = snapshot; + if let Some(found) = from_manifest(app, manifest) { + return Some(found); + } + #[cfg(target_os = "macos")] + return macos::detect(app); + #[cfg(windows)] + return windows::detect(app); + #[cfg(target_os = "linux")] + return linux::detect(app, snapshot); + #[cfg(target_os = "freebsd")] + return freebsd::detect(app); + #[allow(unreachable_code)] + None +} + +fn from_manifest(app: &App, manifest: &Manifest) -> Option { + let rec = manifest.apps.get(app.id)?; + if !rec.path.exists() { + return None; + } + // On macOS the bundle itself is the truth (the app may have updated itself). + #[cfg(target_os = "macos")] + if rec.kind == crate::PackageKind::Dmg { + return macos::read_bundle(app, &rec.path); + } + Some(Installed { + version: version::parse(&rec.version), + source: InstallSource::Launcher, + location: rec.path.display().to_string(), + package: None, + }) +} + +/// Whether a file name is an AppImage of the app with asset stems `stems`, and the version its +/// name gives. Matches only the names a release AppImage ends up with: the release's own +/// (`photocraft-0.6.0-linux-x86_64.AppImage`) and the bare app name Gear Lever renames it to +/// (`photocraft.appimage`, version unknown). The extension's case doesn't matter. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) fn appimage_version(name: &str, stems: &[&str]) -> Option> { + let split = name.len().checked_sub(".appimage".len())?; + let (base, ext) = (name.get(..split)?, name.get(split..)?); + if !ext.eq_ignore_ascii_case(".appimage") { + return None; + } + if stems.iter().any(|s| base.eq_ignore_ascii_case(s)) { + return Some(None); + } + let parsed = crate::platform::parse_asset_name(&format!("{base}.AppImage"), stems)?; + (parsed.kind == crate::PackageKind::AppImage && parsed.token.starts_with("linux-")).then_some(Some(parsed.version)) +} + +/// Who our Windows Installer packages name as their publisher. +#[cfg_attr(not(windows), allow(dead_code))] +const PUBLISHER: &str = "Learning Machines LLC"; + +/// Whether an uninstall entry's `DisplayName` and `Publisher` are `app`'s, under any name its +/// installers have registered. +#[cfg_attr(not(windows), allow(dead_code))] +fn is_installer_entry(app: &App, display_name: &str, publisher: &str) -> bool { + publisher == PUBLISHER && app.names().any(|n| n == display_name) +} + +/// Where a Windows install of `app` may keep its exe, in the order to try: the entry's +/// `InstallLocation`, then `` under each Program Files root for each name the app has had, +/// and in each folder `.exe` for each stem, current first. Doesn't touch the disk. +#[cfg_attr(not(windows), allow(dead_code))] +fn exe_candidates(app: &App, location: Option<&Path>, program_files: &[PathBuf]) -> Vec { + let in_program_files = program_files.iter().flat_map(|root| app.names().map(move |n| root.join(n))); + let dirs = location.map(Path::to_path_buf).into_iter().chain(in_program_files); + dirs.flat_map(|d| app.stems().map(move |s| d.join(format!("{s}.exe")))).collect() +} + +/// Version from ` --version` output. Binaries print the machine id (`photocraft 0.7.0`), +/// the display name (`SoundCraft 0.5.0`), or a former id (`printcraft 0.2.1` for PdfCraft). +#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))] +fn parse_cli_version(line: &str, app: &App) -> Option { + let line = line.trim(); + for prefix in app.stems().chain(std::iter::once(app.name)) { + if let Some(rest) = strip_cli_prefix(line, prefix) + && let Some(tok) = rest.split_whitespace().next() + && let Some(v) = version::parse(tok) + { + return Some(v); + } + } + version::parse_package(line) +} + +fn strip_cli_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + let (head, rest) = line.split_at_checked(prefix.len())?; + if !head.eq_ignore_ascii_case(prefix) { + return None; + } + match rest.chars().next() { + None => Some(rest), + Some(c) if c.is_ascii_whitespace() => Some(rest), + _ => None, + } +} + +#[cfg(target_os = "macos")] +pub mod macos { + use std::path::{Path, PathBuf}; + + use super::{App, InstallSource, Installed, version}; + + /// Where a `.app` might be: the system and the user's Applications folders. + pub fn candidates(app: &App) -> Vec { + let bundle = format!("{}.app", app.name); + let mut out = vec![Path::new("/Applications").join(&bundle)]; + if let Some(home) = dirs::home_dir() { + out.push(home.join("Applications").join(&bundle)); + } + out + } + + pub fn detect(app: &App) -> Option { + candidates(app).iter().find_map(|p| read_bundle(app, p)) + } + + /// Reads a bundle's `Info.plist`; `None` unless it's really this app. + pub fn read_bundle(app: &App, path: &Path) -> Option { + let plist = plist::Value::from_file(path.join("Contents/Info.plist")).ok()?; + let dict = plist.as_dictionary()?; + if dict.get("CFBundleIdentifier")?.as_string()? != app.bundle_id() { + return None; + } + // `Version` carries the full version including a pre-release suffix; + // CFBundleShortVersionString only has the numbers. + let full_key = format!("{}Version", app.name); + let text = + [full_key.as_str(), "CFBundleShortVersionString", "CFBundleVersion"].iter().find_map(|k| dict.get(k).and_then(plist::Value::as_string)); + Some(Installed { version: text.and_then(version::parse), source: InstallSource::MacApp, location: path.display().to_string(), package: None }) + } +} + +#[cfg(windows)] +pub mod windows { + use std::path::PathBuf; + + use winreg::RegKey; + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY}; + + use super::{App, InstallSource, Installed, exe_candidates, is_installer_entry, version}; + + const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + + /// Reads the ProductVersion string from a program's VERSIONINFO, in the first language that + /// has one, without running the program or any other process. + pub fn executable_version(path: &std::path::Path) -> Option { + let path = dunce::canonicalize(path).ok()?; + let info = winsafe::HVERSIONINFO::GetFileVersionInfo(path.to_str()?).ok()?; + let listed = info.langs_and_cps().map(<[_]>::to_vec).unwrap_or_default(); + let product = string_tables(&listed).into_iter().find_map(|table| info.str_val(table, "ProductVersion").ok())?; + version::parse(product.trim()) + } + + /// The string tables to look in: the languages the file lists, then U.S. English in UTF-16, + /// Windows-1252 and no code page, as .NET's `FileVersionInfo` also tries when a file lists none + /// or its listed one has no strings. + pub(crate) fn string_tables(listed: &[(winsafe::LANGID, winsafe::co::CP)]) -> Vec<(winsafe::LANGID, winsafe::co::CP)> { + use winsafe::{LANGID, co}; + let us = LANGID::new(co::LANG::ENGLISH, co::SUBLANG::ENGLISH_US); + listed.iter().copied().chain([(us, co::CP::UTF16), (us, co::CP::WINDOWS_1252), (us, co::CP::ACP)]).collect() + } + + /// An MSI install found in the registry. + pub struct MsiInstall { + pub product_code: String, + pub version: Option, + pub exe: Option, + } + + /// Every Windows Installer entry for `app`, newest first. + pub fn msi_installs(app: &App) -> Vec { + let mut out = Vec::new(); + for (hive, flags) in [(HKEY_LOCAL_MACHINE, KEY_WOW64_64KEY), (HKEY_LOCAL_MACHINE, KEY_WOW64_32KEY), (HKEY_CURRENT_USER, 0)] { + let Ok(root) = RegKey::predef(hive).open_subkey_with_flags(UNINSTALL, KEY_READ | flags) else { continue }; + for code in root.enum_keys().flatten() { + let Ok(key) = root.open_subkey_with_flags(&code, KEY_READ | flags) else { continue }; + let name: String = key.get_value("DisplayName").unwrap_or_default(); + let publisher: String = key.get_value("Publisher").unwrap_or_default(); + if !is_installer_entry(app, &name, &publisher) || out.iter().any(|m: &MsiInstall| m.product_code == code) { + continue; + } + let version = key.get_value::("DisplayVersion").ok().and_then(|v| version::parse(&v)); + let location: String = key.get_value("InstallLocation").unwrap_or_default(); + let exe = exe_path(app, (!location.is_empty()).then(|| PathBuf::from(location)).or_else(|| install_dir(app))); + out.push(MsiInstall { product_code: code, version, exe }); + } + } + out.sort_by(|a, b| match (&b.version, &a.version) { + (Some(x), Some(y)) => version::compare(x, y), + (Some(_), None) => std::cmp::Ordering::Greater, + (None, Some(_)) => std::cmp::Ordering::Less, + (None, None) => std::cmp::Ordering::Equal, + }); + out + } + + /// The folder the app's MSI remembers for its next upgrade, which is where it went when the + /// launcher chose the folder. + fn install_dir(app: &App) -> Option { + [KEY_WOW64_64KEY, KEY_WOW64_32KEY].into_iter().find_map(|flags| { + let key = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(format!(r"SOFTWARE\{}", app.name), KEY_READ | flags).ok()?; + key.get_value::("InstallDir").ok().filter(|d| !d.is_empty()).map(PathBuf::from) + }) + } + + /// The app's exe: from `App Paths`, the install folder, or Program Files. + fn exe_path(app: &App, location: Option) -> Option { + for stem in app.stems() { + let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{stem}.exe"); + for flags in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(&app_paths, KEY_READ | flags) + && let Ok(path) = key.get_value::("") + { + let p = PathBuf::from(path.trim_matches('"')); + if p.exists() { + return Some(p); + } + } + } + } + let mut roots = Vec::new(); + for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] { + if let Some(pf) = std::env::var_os(var) { + roots.push(PathBuf::from(pf)); + } + } + exe_candidates(app, location.as_deref(), &roots).into_iter().find(|p| p.exists()) + } + + pub fn detect(app: &App) -> Option { + let m = msi_installs(app).into_iter().next()?; + Some(Installed { + version: m.version, + source: InstallSource::Msi, + location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()), + package: Some(m.product_code), + }) + } +} + +#[cfg(target_os = "linux")] +pub mod linux { + use std::path::{Path, PathBuf}; + use std::time::Duration; + + use super::{App, InstallSource, Installed, Snapshot, appimage_version, parse_cli_version, version}; + use crate::packages::{self, Package}; + use crate::process::capture; + + /// Installed Craft App packages from every package manager on this system (dpkg, rpm, + /// pacman, Flatpak). Read once per detection pass. + pub fn packages() -> Vec { + let ours = |name: &str| { + crate::suite::SUITE.iter().any(|a| { + [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak] + .iter() + .any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name)) + }) + }; + let t = Duration::from_secs(10); + let mut out = Vec::new(); + let mut add = |manager, list: Vec<(String, String)>| { + out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { manager, name, version })); + }; + if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) { + add(InstallSource::Deb, packages::parse_dpkg(&list)); + } + if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) { + add(InstallSource::Rpm, packages::parse_rpm(&list)); + } + // Only run `pacman` where its database exists: elsewhere `pacman` on PATH may be an unrelated program + // (the Pac-Man game in /usr/games on Debian-family systems). + if has_pacman_db() + && let Some(list) = capture("pacman", &["-Q"], t) + { + add(InstallSource::Pacman, packages::parse_pacman(&list)); + } + let arch = format!("--arch={}", std::env::consts::ARCH); + if let Some(list) = crate::process::capture_flatpak(&["list", "--app", &arch, "--columns=application,version"], t) { + let mut flatpaks = packages::parse_flatpak_list(&list); + // Bundles built from a release tarball often list no version: ask Flatpak directly. + for (id, version) in flatpaks.iter_mut().filter(|(id, v)| v.is_empty() && id.starts_with("ai.storyteller.")) { + if let Some(v) = crate::process::capture_flatpak(&["info", &arch, id], t).as_deref().and_then(packages::parse_flatpak_info) { + *version = v; + } + } + add(InstallSource::Flatpak, flatpaks); + } + out + } + + /// The local package database of Arch's pacman. + pub(crate) const PACMAN_DB: &str = "/var/lib/pacman/local"; + + /// True when `db` is a directory, i.e. this system really uses pacman. + pub(crate) fn pacman_db_present(db: &Path) -> bool { + db.is_dir() + } + + /// True when the system's default pacman database directory exists. + pub(crate) fn has_pacman_db() -> bool { + pacman_db_present(Path::new(PACMAN_DB)) + } + + /// Where a package put the app's binary: on `PATH`, else `/usr/bin/`. + fn binary_on_path(id: &str) -> String { + std::env::var_os("PATH") + .and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file())) + .unwrap_or_else(|| PathBuf::from("/usr/bin").join(id)) + .display() + .to_string() + } + + /// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention). + pub fn appimage_dir() -> Option { + dirs::home_dir().map(|h| h.join("Applications")) + } + + /// Every folder searched for AppImages, the launcher's own first: `~/Applications`, + /// `~/AppImages` (Gear Lever's default) and `~/.local/bin`. + pub fn appimage_dirs() -> Vec { + let Some(home) = dirs::home_dir() else { return Vec::new() }; + vec![home.join("Applications"), home.join("AppImages"), home.join(".local").join("bin")] + } + + /// AppImages of `app` in `dirs`, newest first (unknown versions last; earlier folders win + /// ties). + pub fn appimages_in(app: &App, dirs: &[PathBuf]) -> Vec<(PathBuf, Option)> { + let stems: Vec<&str> = app.stems().collect(); + let mut out: Vec<(PathBuf, Option)> = Vec::new(); + for dir in dirs { + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + let mut found: Vec<(PathBuf, Option)> = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let version = appimage_version(&name, &stems)?; + let path = e.path(); + path.is_file().then(|| (path, version.as_deref().and_then(version::parse))) + }) + .collect(); + found.sort_by(|a, b| a.0.cmp(&b.0)); + out.extend(found); + } + // Stable sort: equal versions keep the folder order above. + out.sort_by(|a, b| match (&b.1, &a.1) { + (Some(x), Some(y)) => version::compare(x, y), + (Some(_), None) => std::cmp::Ordering::Greater, + (None, Some(_)) => std::cmp::Ordering::Less, + (None, None) => std::cmp::Ordering::Equal, + }); + out + } + + /// AppImages of `app` in any of the usual folders, newest first. + pub fn appimages(app: &App) -> Vec<(PathBuf, Option)> { + appimages_in(app, &appimage_dirs()) + } + + pub fn detect(app: &App, snapshot: &Snapshot) -> Option { + if let Some((path, version)) = appimages(app).into_iter().next() { + return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None }); + } + let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]; + if let Some(p) = packages::find(app, &snapshot.packages, &order) { + let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) }; + return Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) }); + } + detect_system_install(app) + } + + /// When the package managers don't report the app (a query timed out or was locked, an + /// unlisted package name, or a sandbox that can't see them) but its binary is there. It is + /// reported as a system binary: the launcher can open it but leaves updating and removing it + /// to whatever installed it, since it can't tell which package manager that was. + fn detect_system_install(app: &App) -> Option { + let bin = find_binary(app.id)?; + Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None }) + } + + fn find_binary(id: &str) -> Option { + let on_path = std::env::var_os("PATH").and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file())); + let usual = ["/usr/bin", "/usr/local/bin"].into_iter().map(PathBuf::from).chain(dirs::home_dir().map(|h| h.join(".local/bin"))); + on_path.or_else(|| usual.map(|d| d.join(id)).find(|p| p.is_file())) + } + + pub(crate) fn binary_version(bin: &Path, app: &App) -> Option { + let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?; + parse_cli_version(out.trim(), app) + } +} + +#[cfg(target_os = "freebsd")] +pub mod freebsd { + use std::path::Path; + use std::time::Duration; + + use super::{App, InstallSource, Installed, parse_cli_version, version}; + + pub fn detect(app: &App) -> Option { + let bin = Path::new("/usr/local/bin").join(app.id); + if !bin.exists() { + return None; + } + Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None }) + } + + /// ` --version` prints ` 0.5.0 (sha, date)`. + fn binary_version(bin: &Path, app: &App) -> Option { + let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?; + parse_cli_version(out.trim(), app) + } +} + +#[cfg(all(test, target_os = "linux"))] +mod linux_tests { + use super::*; + + #[test] + fn ours_filter_matches_rpm_packages() { + let ours = |name: &str| { + crate::suite::SUITE.iter().any(|a| { + [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak] + .iter() + .any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name)) + }) + }; + assert!(ours("photocraft")); + assert!(ours("ai.storyteller.photocraft")); + assert!(!ours("unrelated-package")); + } + + #[test] + fn pacman_is_only_queried_where_its_database_exists() { + let root = tempfile::tempdir().unwrap(); + assert!(!linux::pacman_db_present(&root.path().join("local"))); + std::fs::create_dir(root.path().join("local")).unwrap(); + assert!(linux::pacman_db_present(&root.path().join("local"))); + } + + /// `linux::binary_version` on a script the test just wrote. Another test thread that forks + /// while the script is still open for writing holds that descriptor until it execs, and running + /// the script in that window fails with ETXTBSY ("Text file busy"), so a miss is retried. + fn binary_version_of_new_script(bin: &Path, app: &App) -> Option { + for _ in 0..20 { + if let Some(v) = linux::binary_version(bin, app) { + return Some(v); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + None + } + + #[test] + fn reads_binary_version_from_cli() { + let photo = App::by_id("photocraft").unwrap(); + let temp_dir = tempfile::tempdir().unwrap(); + let bin = temp_dir.path().join("photocraft"); + std::fs::write(&bin, "#!/bin/sh\necho 'photocraft 0.6.0 (0c72d9542, 2026-10-10)'\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + let ver = binary_version_of_new_script(&bin, photo); + assert_eq!(ver.unwrap().to_string(), "0.6.0"); + } + + #[test] + fn reads_binary_version_when_the_cli_uses_the_display_name_or_a_former_id() { + let temp_dir = tempfile::tempdir().unwrap(); + use std::os::unix::fs::PermissionsExt; + let write = |name: &str, script: &str| { + let bin = temp_dir.path().join(name); + std::fs::write(&bin, script).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin + }; + let sound = write("soundcraft", "#!/bin/sh\necho 'SoundCraft 0.5.0'\n"); + assert_eq!(binary_version_of_new_script(&sound, App::by_id("soundcraft").unwrap()).unwrap().to_string(), "0.5.0"); + let pdf = write("pdfcraft", "#!/bin/sh\necho 'printcraft 0.2.1'\n"); + assert_eq!(binary_version_of_new_script(&pdf, App::by_id("pdfcraft").unwrap()).unwrap().to_string(), "0.2.1"); + } +} + +#[cfg(test)] +mod tests { + use crate::SUITE; + + use super::*; + + /// A file without VERSIONINFO, or one that isn't there, has no version. + #[cfg(windows)] + #[test] + fn a_file_without_version_info_has_no_version() { + let dir = tempfile::tempdir().unwrap(); + let text = dir.path().join("photocraft.exe"); + std::fs::write(&text, "not a program").unwrap(); + assert_eq!(windows::executable_version(&text), None); + assert_eq!(windows::executable_version(&dir.path().join("missing.exe")), None); + } + + /// Without a listed language, the version is still looked for in the string tables .NET's + /// `FileVersionInfo` falls back to (040904B0, 040904E4, 04090000), after any listed one. + #[cfg(windows)] + #[test] + fn the_version_is_looked_for_in_the_usual_string_tables_too() { + use winsafe::{LANGID, co}; + let key = |(lang, cp): (LANGID, co::CP)| format!("{:04x}{:04x}", u16::from(lang), u16::from(cp)); + let keys = |listed: &[(LANGID, co::CP)]| windows::string_tables(listed).into_iter().map(key).collect::>(); + assert_eq!(keys(&[]), ["040904b0", "040904e4", "04090000"]); + let german = (LANGID::new(co::LANG::GERMAN, co::SUBLANG::GERMAN), co::CP::UTF16); + assert_eq!(keys(&[german]), ["040704b0", "040904b0", "040904e4", "04090000"]); + } + + /// A program whose VERSIONINFO has a ProductVersion in the U.S. English UTF-16 table but no + /// `\VarFileInfo\Translation` entry still has a version. + #[cfg(windows)] + #[test] + fn a_version_without_a_translation_entry_is_found() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("photocraft.exe"); + std::fs::write(&exe, resource_only_program("1.2.3")).unwrap(); + assert_eq!(windows::executable_version(&exe), version::parse("1.2.3")); + } + + /// A VERSIONINFO block node: length, value length, type, the key, then the value and the + /// children, each aligned to 4 bytes. + #[cfg(windows)] + fn version_node(key: &str, value: &[u8], value_length: u16, text: bool, children: &[Vec]) -> Vec { + let align = |bytes: &mut Vec| bytes.resize(bytes.len().next_multiple_of(4), 0); + let mut node = vec![0, 0]; + node.extend(value_length.to_le_bytes()); + node.extend(u16::from(text).to_le_bytes()); + node.extend(key.encode_utf16().chain([0]).flat_map(u16::to_le_bytes)); + align(&mut node); + node.extend(value); + for child in children { + align(&mut node); + node.extend(child); + } + let length = u16::try_from(node.len()).unwrap(); + node[..2].copy_from_slice(&length.to_le_bytes()); + node + } + + /// A 64-bit program file holding only a version resource (`ProductVersion` = `product`, in + /// the 040904B0 string table) and no `\VarFileInfo\Translation` entry. + #[cfg(windows)] + fn resource_only_program(product: &str) -> Vec { + let put = |bytes: &mut Vec, at: usize, value: &[u8]| bytes[at..at + value.len()].copy_from_slice(value); + let text: Vec = product.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect(); + let string = version_node("ProductVersion", &text, u16::try_from(product.len() + 1).unwrap(), true, &[]); + let table = version_node("040904b0", &[], 0, true, &[string]); + let strings = version_node("StringFileInfo", &[], 0, true, &[table]); + let mut fixed = vec![0u8; 52]; + put(&mut fixed, 0, &0xFEEF_04BDu32.to_le_bytes()); + put(&mut fixed, 4, &0x0001_0000u32.to_le_bytes()); + let info = version_node("VS_VERSION_INFO", &fixed, 52, false, &[strings]); + + // The resource section: type 16 (RT_VERSION) > id 1 > language 0x0409 > the data. + const RVA: u32 = 0x1000; + let mut rsrc = vec![0u8; 0x58]; + for (dir, id, next) in [(0x00, 16u32, 0x8000_0018u32), (0x18, 1, 0x8000_0030), (0x30, 0x0409, 0x48)] { + put(&mut rsrc, dir + 14, &1u16.to_le_bytes()); + put(&mut rsrc, dir + 16, &id.to_le_bytes()); + put(&mut rsrc, dir + 20, &next.to_le_bytes()); + } + put(&mut rsrc, 0x48, &(RVA + 0x58).to_le_bytes()); + put(&mut rsrc, 0x4C, &u32::try_from(info.len()).unwrap().to_le_bytes()); + rsrc.extend(&info); + let raw = u32::try_from(rsrc.len().next_multiple_of(0x200)).unwrap(); + let rsrc_len = u32::try_from(rsrc.len()).unwrap(); + + let mut file = vec![0u8; 0x200]; + put(&mut file, 0, b"MZ"); + put(&mut file, 0x3C, &0x40u32.to_le_bytes()); + put(&mut file, 0x40, b"PE\0\0"); + // COFF header: x64, one section, a 240-byte optional header, an executable DLL image. + put(&mut file, 0x44, &0x8664u16.to_le_bytes()); + put(&mut file, 0x46, &1u16.to_le_bytes()); + put(&mut file, 0x54, &240u16.to_le_bytes()); + put(&mut file, 0x56, &0x2022u16.to_le_bytes()); + // PE32+ optional header. + let opt = 0x58; + put(&mut file, opt, &0x20Bu16.to_le_bytes()); + put(&mut file, opt + 8, &raw.to_le_bytes()); + put(&mut file, opt + 24, &0x1_4000_0000u64.to_le_bytes()); + put(&mut file, opt + 32, &0x1000u32.to_le_bytes()); + put(&mut file, opt + 36, &0x200u32.to_le_bytes()); + put(&mut file, opt + 40, &6u16.to_le_bytes()); + put(&mut file, opt + 48, &6u16.to_le_bytes()); + put(&mut file, opt + 56, &(RVA + rsrc_len.next_multiple_of(0x1000)).to_le_bytes()); + put(&mut file, opt + 60, &0x200u32.to_le_bytes()); + put(&mut file, opt + 68, &2u16.to_le_bytes()); + put(&mut file, opt + 72, &0x10_0000u64.to_le_bytes()); + put(&mut file, opt + 80, &0x1000u64.to_le_bytes()); + put(&mut file, opt + 88, &0x10_0000u64.to_le_bytes()); + put(&mut file, opt + 96, &0x1000u64.to_le_bytes()); + put(&mut file, opt + 108, &16u32.to_le_bytes()); + put(&mut file, opt + 112 + 2 * 8, &RVA.to_le_bytes()); + put(&mut file, opt + 112 + 2 * 8 + 4, &rsrc_len.to_le_bytes()); + // The .rsrc section header. + let section = opt + 240; + put(&mut file, section, b".rsrc\0\0\0"); + put(&mut file, section + 8, &rsrc_len.to_le_bytes()); + put(&mut file, section + 12, &RVA.to_le_bytes()); + put(&mut file, section + 16, &raw.to_le_bytes()); + put(&mut file, section + 20, &0x200u32.to_le_bytes()); + put(&mut file, section + 36, &0x4000_0040u32.to_le_bytes()); + rsrc.resize(usize::try_from(raw).unwrap(), 0); + file.extend(rsrc); + file + } + + #[test] + fn recognises_appimage_names_conservatively() { + let pdf = ["pdfcraft", "printcraft"]; + let v = |n: &str| appimage_version(n, &pdf); + assert_eq!(v("pdfcraft-0.3.0-linux-x86_64.AppImage"), Some(Some("0.3.0".into()))); + assert_eq!(v("printcraft-0.2.1-linux-aarch64.appimage"), Some(Some("0.2.1".into()))); + assert_eq!(v("pdfcraft-0.3.0-rc.1-linux-x86_64.AppImage"), Some(Some("0.3.0-rc.1".into()))); + // Gear Lever's rename, and a plain rename by hand. + assert_eq!(v("pdfcraft.appimage"), Some(None)); + assert_eq!(v("PdfCraft.AppImage"), Some(None)); + for other in [ + "pdfcraft-0.3.0-linux-x86_64.AppImage.zsync", + "pdfcraft-0.3.0-linux-x86_64.deb", + "pdfcraft-0.3.0-windows-x64.AppImage", + "pdfcraft-cli-0.3.0-linux-x86_64.AppImage", + "photocraft-0.6.0-linux-x86_64.AppImage", + "mypdfcraft.AppImage", + "pdfcraft-latest.AppImage", + ".appimage", + "x", + ] { + assert_eq!(v(other), None, "{other}"); + } + } + + #[test] + fn parse_cli_version_accepts_id_display_name_and_former_id() { + let photo = App::by_id("photocraft").unwrap(); + let sound = App::by_id("soundcraft").unwrap(); + let pdf = App::by_id("pdfcraft").unwrap(); + assert_eq!(parse_cli_version("photocraft 0.7.0 (dev build)", photo).unwrap().to_string(), "0.7.0"); + assert_eq!(parse_cli_version("SoundCraft 0.5.0", sound).unwrap().to_string(), "0.5.0"); + assert_eq!(parse_cli_version("printcraft 0.2.1", pdf).unwrap().to_string(), "0.2.1"); + assert_eq!(parse_cli_version("PdfCraft 0.6.0", pdf).unwrap().to_string(), "0.6.0"); + assert!(strip_cli_prefix("photocraft-cli 1.0.0", "photocraft").is_none()); + } + + #[cfg(target_os = "linux")] + #[test] + fn finds_appimages_in_every_folder_newest_first() { + let app = App::by_id("photocraft").unwrap(); + let root = tempfile::tempdir().unwrap(); + let (apps, gear, bin) = (root.path().join("Applications"), root.path().join("AppImages"), root.path().join("bin")); + for d in [&apps, &gear, &bin] { + std::fs::create_dir_all(d).unwrap(); + } + std::fs::write(apps.join("photocraft-0.5.0-linux-x86_64.AppImage"), b"").unwrap(); + std::fs::write(apps.join("wordcraft-0.3.0-linux-x86_64.AppImage"), b"").unwrap(); + std::fs::write(gear.join("photocraft.appimage"), b"").unwrap(); + std::fs::write(bin.join("photocraft-0.6.0-linux-x86_64.AppImage"), b"").unwrap(); + std::fs::create_dir(bin.join("photocraft.AppImage")).unwrap(); + let found = linux::appimages_in(app, &[apps.clone(), gear.clone(), bin.clone(), root.path().join("missing")]); + let names: Vec<_> = found.iter().map(|(p, v)| (p.clone(), v.as_ref().map(ToString::to_string))).collect(); + assert_eq!( + names, + [ + (bin.join("photocraft-0.6.0-linux-x86_64.AppImage"), Some("0.6.0".to_string())), + (apps.join("photocraft-0.5.0-linux-x86_64.AppImage"), Some("0.5.0".to_string())), + (gear.join("photocraft.appimage"), None), + ] + ); + } + + const LEARNING_MACHINES: &str = "Learning Machines LLC"; + + fn roots() -> Vec { + vec![PathBuf::from(r"C:\Program Files"), PathBuf::from(r"C:\Program Files (x86)")] + } + + #[test] + fn pdfcraft_matches_its_former_printcraft_installer_name() { + let pdf = App::by_id("pdfcraft").unwrap(); + assert!(is_installer_entry(pdf, "PrintCraft", LEARNING_MACHINES), "a PrintCraft entry from {LEARNING_MACHINES} should be PdfCraft's"); + } + + #[test] + fn pdfcraft_still_matches_its_own_installer_name() { + let pdf = App::by_id("pdfcraft").unwrap(); + assert!(is_installer_entry(pdf, "PdfCraft", LEARNING_MACHINES)); + } + + #[test] + fn printcraft_entries_match_nothing_else() { + let pdf = App::by_id("pdfcraft").unwrap(); + assert!(!is_installer_entry(pdf, "PrintCraft", "Some Other Publisher")); + assert!(!is_installer_entry(pdf, "PhotoCraft", LEARNING_MACHINES)); + for a in SUITE.iter().filter(|a| a.id != "pdfcraft") { + assert!(!is_installer_entry(a, "PrintCraft", LEARNING_MACHINES), "PrintCraft matched {}", a.name); + } + } + + #[test] + fn pdfcraft_exe_is_sought_under_every_name_it_has_shipped_as() { + let pdf = App::by_id("pdfcraft").unwrap(); + let location = PathBuf::from(r"D:\Apps\PrintCraft"); + let roots = roots(); + let mut want = vec![location.join("pdfcraft.exe"), location.join("printcraft.exe")]; + for root in &roots { + for dir in ["PdfCraft", "PrintCraft"] { + want.push(root.join(dir).join("pdfcraft.exe")); + want.push(root.join(dir).join("printcraft.exe")); + } + } + assert_eq!(exe_candidates(pdf, Some(&location), &roots), want); + } + + #[test] + fn apps_without_former_names_match_and_find_their_exe_as_before() { + let roots = roots(); + let location = PathBuf::from(r"D:\Apps\Somewhere"); + for a in SUITE.iter().filter(|a| a.former_ids.is_empty() && a.former_names.is_empty()) { + assert!(is_installer_entry(a, a.name, LEARNING_MACHINES), "{}", a.name); + assert!(!is_installer_entry(a, a.name, "Some Other Publisher"), "{}", a.name); + assert!(!is_installer_entry(a, &a.name.to_lowercase(), LEARNING_MACHINES), "{}", a.name); + let exe = format!("{}.exe", a.id); + let in_program_files: Vec = roots.iter().map(|r| r.join(a.name).join(&exe)).collect(); + assert_eq!(exe_candidates(a, None, &roots), in_program_files); + let with_location: Vec = std::iter::once(location.join(&exe)).chain(in_program_files).collect(); + assert_eq!(exe_candidates(a, Some(&location), &roots), with_location); + } + } +} diff --git a/crates/update/src/dialog.rs b/crates/update/src/dialog.rs new file mode 100644 index 00000000..811e58bd --- /dev/null +++ b/crates/update/src/dialog.rs @@ -0,0 +1,212 @@ +//! Startup update prompt dialog for egui frontends. + +use egui::{Align, Align2, Color32, Layout, RichText, Vec2}; + +use crate::app::App; +use crate::release::Release; + +/// What the user chose in the startup update prompt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StartupPromptResponse { + /// Dialog is still open and waiting for user input. + Pending, + /// User chose to update and restart now. + UpdateAndRestart, + /// User chose to download manually via browser. + Download, + /// User dismissed the dialog for now. + Later, +} + +/// State for the startup update prompt dialog. +#[derive(Clone, Debug)] +pub struct StartupPrompt { + pub app: &'static App, + pub release: Release, + pub running_version: String, + pub is_self_updating: bool, + pub open: bool, + pub status_message: Option, + pub is_installing: bool, +} + +impl StartupPrompt { + /// Creates a new prompt dialog state for the given app and newly discovered release. + pub fn new(app: &'static App, release: Release, running_version: impl Into) -> Self { + let is_self_updating = crate::self_update::is_self_updating(app); + Self { app, release, running_version: running_version.into(), is_self_updating, open: true, status_message: None, is_installing: false } + } + + /// Renders the modal window on the given egui context. + pub fn show(&mut self, ctx: &egui::Context) -> StartupPromptResponse { + if !self.open { + return StartupPromptResponse::Later; + } + + let mut response = StartupPromptResponse::Pending; + + // Modal dimmed background overlay; it senses clicks so they don't reach widgets underneath. + let screen_rect = ctx.content_rect(); + egui::Area::new(egui::Id::new("craft_update_backdrop")).order(egui::Order::Middle).fixed_pos(screen_rect.min).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(screen_rect.size(), egui::Sense::click_and_drag()); + ui.painter().rect_filled(rect, 0.0, Color32::from_black_alpha(150)); + }); + + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + response = StartupPromptResponse::Later; + } + + let is_dark = ctx.theme() == egui::Theme::Dark; + let card_bg = if is_dark { Color32::from_rgb(26, 26, 28) } else { Color32::from_rgb(255, 255, 255) }; + let border_color = if is_dark { Color32::from_rgb(52, 52, 56) } else { Color32::from_rgb(215, 215, 220) }; + let text_primary = if is_dark { Color32::from_rgb(245, 245, 248) } else { Color32::from_rgb(20, 20, 25) }; + let text_muted = if is_dark { Color32::from_rgb(150, 150, 160) } else { Color32::from_rgb(105, 105, 115) }; + let accent_color = Color32::from_rgb(52, 120, 246); + + egui::Area::new(egui::Id::new("craft_update.startup_prompt")).anchor(Align2::CENTER_CENTER, Vec2::ZERO).order(egui::Order::Foreground).show( + ctx, + |ui| { + egui::Frame::new() + .fill(card_bg) + .stroke(egui::Stroke::new(1.0, border_color)) + .corner_radius(egui::CornerRadius::ZERO) + .inner_margin(egui::Margin::symmetric(20, 18)) + .show(ui, |ui| { + ui.set_width(400.0); + ui.spacing_mut().item_spacing = Vec2::new(8.0, 6.0); + + // Header row: App badge + title + close + ui.horizontal(|ui| { + let (badge_rect, _) = ui.allocate_exact_size(Vec2::new(34.0, 34.0), egui::Sense::hover()); + ui.painter().rect_filled(badge_rect, egui::CornerRadius::ZERO, accent_color); + let initial = self.app.name.chars().next().map(|c| c.to_string()).unwrap_or_else(|| "A".to_string()); + ui.painter().text(badge_rect.center(), Align2::CENTER_CENTER, initial, egui::FontId::monospace(18.0), Color32::WHITE); + + ui.add_space(4.0); + + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + ui.horizontal(|ui| { + ui.label(RichText::new(self.app.name).size(15.0).strong().color(text_primary)); + ui.label(RichText::new("UPDATE").size(10.0).monospace().color(accent_color)); + }); + let version_pill = format!("v{} → {}", self.running_version, self.release.version_label()); + ui.label(RichText::new(version_pill).size(11.0).monospace().color(text_muted)); + }); + + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + let x_btn = egui::Button::new(RichText::new("✕").size(12.0).color(text_muted)).frame(false); + if ui.add(x_btn).clicked() { + response = StartupPromptResponse::Later; + } + }); + }); + + ui.add_space(8.0); + + if let Some(msg) = &self.status_message { + let alert_bg = if is_dark { Color32::from_rgb(50, 30, 20) } else { Color32::from_rgb(255, 242, 235) }; + let alert_color = Color32::from_rgb(225, 95, 30); + egui::Frame::new() + .fill(alert_bg) + .stroke(egui::Stroke::new(1.0, alert_color)) + .corner_radius(egui::CornerRadius::ZERO) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(msg).size(12.0).color(alert_color)); + }); + ui.add_space(4.0); + } + + if self.is_installing { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("Downloading and installing update…").size(12.0).color(text_primary)); + }); + } else { + let desc = + format!("{} {} is available with the latest features and bug fixes.", self.app.name, self.release.version_label()); + ui.label(RichText::new(desc).size(12.5).color(text_muted)); + } + + ui.add_space(10.0); + let (hairline_rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 1.0), egui::Sense::hover()); + ui.painter().hline(hairline_rect.x_range(), hairline_rect.top(), egui::Stroke::new(1.0, border_color)); + ui.add_space(8.0); + + // Footer actions + ui.horizontal(|ui| { + let what_new = egui::Hyperlink::from_label_and_url( + RichText::new("What's new").size(11.5).color(accent_color), + &self.release.html_url, + ); + ui.add(what_new); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if self.is_installing { + let dismiss_btn = egui::Button::new(RichText::new("DISMISS").size(10.5).monospace().color(text_muted)) + .stroke(egui::Stroke::new(1.0, border_color)) + .corner_radius(egui::CornerRadius::ZERO) + .min_size(Vec2::new(72.0, 26.0)); + if ui.add(dismiss_btn).clicked() { + response = StartupPromptResponse::Later; + } + } else { + if self.is_self_updating { + let update_btn = egui::Button::new( + RichText::new("UPDATE AND RESTART").size(10.5).strong().monospace().color(Color32::WHITE), + ) + .fill(accent_color) + .corner_radius(egui::CornerRadius::ZERO) + .min_size(Vec2::new(140.0, 26.0)); + if ui.add(update_btn).clicked() { + response = StartupPromptResponse::UpdateAndRestart; + } + } else { + let dl_btn = + egui::Button::new(RichText::new("DOWNLOAD").size(10.5).strong().monospace().color(Color32::WHITE)) + .fill(accent_color) + .corner_radius(egui::CornerRadius::ZERO) + .min_size(Vec2::new(90.0, 26.0)); + if ui.add(dl_btn).clicked() { + response = StartupPromptResponse::Download; + } + } + + let later_btn = egui::Button::new(RichText::new("LATER").size(10.5).monospace().color(text_primary)) + .stroke(egui::Stroke::new(1.0, border_color)) + .corner_radius(egui::CornerRadius::ZERO) + .min_size(Vec2::new(65.0, 26.0)); + if ui.add(later_btn).clicked() { + response = StartupPromptResponse::Later; + } + } + }); + }); + }); + }, + ); + + if response == StartupPromptResponse::Later { + self.open = false; + } + + response + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::suite::PHOTOCRAFT; + + #[test] + fn creates_startup_prompt_dialog() { + let release = Release { version: crate::version::parse("0.2.0"), tag: "v0.2.0".to_string(), ..Release::default() }; + let prompt = StartupPrompt::new(&PHOTOCRAFT, release, "0.1.0"); + assert_eq!(prompt.app.name, "PhotoCraft"); + assert_eq!(prompt.running_version, "0.1.0"); + assert!(prompt.open); + } +} diff --git a/crates/update/src/distro.rs b/crates/update/src/distro.rs new file mode 100644 index 00000000..89080522 --- /dev/null +++ b/crates/update/src/distro.rs @@ -0,0 +1,108 @@ +//! Linux distributions: reading `os-release` and choosing the package formats that suit them. +//! +//! The distro decides the system package format, not which tools happen to be on `PATH`: Fedora +//! can have `apt-get` and `dpkg` installed (for building Debian images), and an `.rpm` system +//! must never be handed to apt. + +use crate::platform::PackageKind; + +/// The parts of `/etc/os-release` that say which distro family this is. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OsRelease { + /// `ID`, lower-cased (`fedora`, `ubuntu`, `opensuse-tumbleweed`, …). Empty when missing. + pub id: String, + /// `ID_LIKE`, lower-cased and split on spaces (`["rhel", "centos", "fedora"]`). + pub id_like: Vec, +} + +/// Reads `os-release` text (`KEY=value` lines, values optionally quoted). +pub fn parse_os_release(text: &str) -> OsRelease { + let mut out = OsRelease::default(); + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { continue }; + let value = unquote(value.trim()).to_ascii_lowercase(); + match key.trim() { + "ID" => out.id = value, + "ID_LIKE" => out.id_like = value.split_whitespace().map(str::to_string).collect(), + _ => {} + } + } + out +} + +fn unquote(v: &str) -> &str { + for q in ['"', '\''] { + if let Some(inner) = v.strip_prefix(q).and_then(|v| v.strip_suffix(q)) { + return inner; + } + } + v +} + +/// The distro's own package format: `.deb` for the Debian family, `.rpm` for the Fedora, Red Hat +/// and SUSE families, `.pkg.tar.zst` for the Arch Linux family, `None` for anything else +/// (unknown distros), which gets AppImages. +pub fn native_package(os: &OsRelease) -> Option { + std::iter::once(&os.id).chain(&os.id_like).find_map(|id| family(id)) +} + +fn family(id: &str) -> Option { + match id { + "debian" | "ubuntu" => Some(PackageKind::Deb), + "fedora" | "rhel" | "centos" | "suse" | "sles" => Some(PackageKind::Rpm), + _ if id.starts_with("opensuse") => Some(PackageKind::Rpm), + "arch" | "manjaro" | "endeavouros" | "garuda" | "cachyos" | "artix" => Some(PackageKind::ArchPkg), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FEDORA: &str = r#"NAME="Fedora Linux" +VERSION="40 (Workstation Edition)" +ID=fedora +VERSION_ID=40 +PRETTY_NAME="Fedora Linux 40 (Workstation Edition)" +"#; + const UBUNTU: &str = r#"PRETTY_NAME="Ubuntu 24.04.1 LTS" +NAME="Ubuntu" +VERSION_ID="24.04" +ID=ubuntu +ID_LIKE=debian +"#; + const MINT: &str = "NAME=\"Linux Mint\"\nID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"; + const ARCH: &str = "NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n"; + const TUMBLEWEED: &str = "NAME=\"openSUSE Tumbleweed\"\n# a comment\nID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"; + const ROCKY: &str = "NAME=\"Rocky Linux\"\nID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"; + const UNKNOWN: &str = "NAME=\"Some OS\"\nID=someos\n"; + + fn native(text: &str) -> Option { + native_package(&parse_os_release(text)) + } + + #[test] + fn os_release_is_parsed() { + assert_eq!(parse_os_release(FEDORA), OsRelease { id: "fedora".into(), id_like: vec![] }); + assert_eq!(parse_os_release(MINT), OsRelease { id: "linuxmint".into(), id_like: vec!["ubuntu".into(), "debian".into()] }); + assert_eq!(parse_os_release("ID='opensuse-leap'\n").id, "opensuse-leap"); + assert_eq!(parse_os_release(""), OsRelease::default()); + } + + #[test] + fn the_distro_family_picks_the_package_format() { + assert_eq!(native(FEDORA), Some(PackageKind::Rpm)); + assert_eq!(native(ROCKY), Some(PackageKind::Rpm)); + assert_eq!(native(TUMBLEWEED), Some(PackageKind::Rpm)); + assert_eq!(native(UBUNTU), Some(PackageKind::Deb)); + assert_eq!(native(MINT), Some(PackageKind::Deb)); + assert_eq!(native(ARCH), Some(PackageKind::ArchPkg)); + assert_eq!(native(UNKNOWN), None); + assert_eq!(native(""), None); + } +} diff --git a/crates/update/src/http.rs b/crates/update/src/http.rs new file mode 100644 index 00000000..963eed1e --- /dev/null +++ b/crates/update/src/http.rs @@ -0,0 +1,1124 @@ +//! HTTPS requests: small JSON/text answers, redirect probes, and verified downloads. + +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicU8, Ordering}; + +use crate::protocol::{CANCELLED, PAUSED}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; + +/// Largest JSON or text answer we read. +const MAX_SMALL: u64 = 4 << 20; + +/// What a GET of a small resource produced. +#[derive(Debug, PartialEq, Eq)] +pub enum Fetched { + Body { + text: String, + etag: Option, + }, + /// `If-None-Match` matched: the saved copy is current. + NotModified, + NotFound, + /// GitHub's API refused: out of quota until `reset` (Unix seconds), if it said. + RateLimited { + reset: Option, + }, +} + +#[derive(Clone)] +pub struct Http { + small: ureq::Agent, + /// ArtCraft's release feed: a short timeout, so a slow server never holds up a check. + feed: ureq::Agent, + no_redirect: ureq::Agent, + download: ureq::Agent, + direct: ureq::Agent, + direct_download: ureq::Agent, + token: Option, +} + +/// Why ArtCraft's release feed couldn't be read. +#[derive(Debug)] +pub struct FeedError { + pub why: String, + /// The server answered HTTP 429 Too Many Requests. + pub throttled: bool, + /// The server's `Retry-After`, in seconds. + pub retry_after: Option, +} + +/// `http://:` and an optional path: the host must be exactly 127.0.0.1, +/// localhost or [::1] (no `user@` in front of another host), with a numeric port. +pub fn is_loopback(url: &str) -> bool { + let Some(rest) = url.strip_prefix("http://") else { return false }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + let Some((host, port)) = authority.rsplit_once(':') else { return false }; + matches!(host, "127.0.0.1" | "localhost" | "[::1]") && !port.is_empty() && port.parse::().is_ok() +} + +impl Http { + /// `user_agent` names the product and its version (`PhotoCraft/0.9.0`) and nothing about the + /// person using it. + pub fn new(user_agent: &str) -> Result { + // The OS checks each certificate chain itself: Windows fetches roots it hasn't stored yet + // and trusts what antivirus HTTPS scanning installs. Reading the store ourselves missed + // both ("invalid peer certificate: UnknownIssuer"). + let tls = || ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build(); + // Chosen once: the proxy environment variables, else the OS proxy setting. + let proxy = crate::proxy::pick(); + let small = ureq::Agent::config_builder() + .tls_config(tls()) + .proxy(proxy.clone()) + .timeout_global(Some(Duration::from_secs(20))) + .http_status_as_error(false) + .user_agent(user_agent) + .build() + .new_agent(); + let feed = ureq::Agent::config_builder() + .tls_config(tls()) + .proxy(proxy.clone()) + .timeout_connect(Some(Duration::from_secs(5))) + .timeout_global(Some(Duration::from_secs(10))) + .http_status_as_error(false) + .user_agent(user_agent) + .build() + .new_agent(); + let no_redirect = ureq::Agent::config_builder() + .tls_config(tls()) + .proxy(proxy.clone()) + .timeout_global(Some(Duration::from_secs(20))) + .http_status_as_error(false) + .max_redirects(0) + .max_redirects_will_error(false) + .user_agent(user_agent) + .build() + .new_agent(); + let download = ureq::Agent::config_builder() + .tls_config(tls()) + .proxy(proxy.clone()) + .timeout_connect(Some(Duration::from_secs(20))) + .timeout_recv_response(Some(Duration::from_secs(60))) + // ureq has no idle timeout: cap each attempt instead, and resume where it stopped. + .timeout_recv_body(Some(ATTEMPT_BODY_TIME)) + .http_status_as_error(false) + .user_agent(user_agent) + .build() + .new_agent(); + let direct = ureq::Agent::config_builder() + .proxy(None) + .tls_config(tls()) + .timeout_connect(Some(Duration::from_secs(5))) + .timeout_global(Some(Duration::from_secs(20))) + .http_status_as_error(false) + .user_agent(user_agent) + .build() + .new_agent(); + let direct_download = ureq::Agent::config_builder() + .proxy(None) + .tls_config(tls()) + .timeout_connect(Some(Duration::from_secs(20))) + .timeout_recv_response(Some(Duration::from_secs(60))) + .timeout_recv_body(Some(ATTEMPT_BODY_TIME)) + .http_status_as_error(false) + .user_agent(user_agent) + .build() + .new_agent(); + Ok(Self { small, feed, no_redirect, download, direct, direct_download, token: None }) + } + + /// An optional token raises GitHub's limit from 60 to 5000 requests an hour. Only ever sent + /// to api.github.com. Blank means none. + #[must_use] + pub fn with_github_token(mut self, token: Option) -> Self { + self.token = token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + self + } + + /// GET a GitHub API resource, revalidating with `etag` when given. + pub fn get_api(&self, url: &str, etag: Option<&str>) -> Result { + let mut req = self.small.get(url).header("Accept", "application/vnd.github+json").header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(tag) = etag { + req = req.header("If-None-Match", tag); + } + if let Some(token) = &self.token + && url.starts_with("https://api.github.com/") + { + req = req.header("Authorization", &format!("Bearer {token}")); + } + let mut resp = req.call().map_err(|e| format!("couldn't reach GitHub ({e})"))?; + let header = |name: &str| resp.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string); + let status = resp.status().as_u16(); + match status { + 200 => { + let etag = header("etag"); + let text = read_text_capped(&mut resp, MAX_SMALL).map_err(|e| format!("unreadable answer from GitHub ({e})"))?; + Ok(Fetched::Body { text, etag }) + } + 304 => Ok(Fetched::NotModified), + 404 => Ok(Fetched::NotFound), + 403 | 429 => { + let remaining = header("x-ratelimit-remaining"); + let reset = header("x-ratelimit-reset").and_then(|r| r.trim().parse::().ok()); + let retry_after = header("retry-after").and_then(|r| r.trim().parse::().ok()); + match refused_until(status, remaining.as_deref(), reset, retry_after, crate::now()) { + Some(reset) => Ok(Fetched::RateLimited { reset }), + None => Err(format!("GitHub refused the request (HTTP {status})")), + } + } + s => Err(format!("GitHub answered HTTP {s}")), + } + } + + /// ArtCraft's release feed, or why it couldn't be read. Never takes more than ~10 seconds. + pub fn get_feed(&self, url: &str) -> Result { + let failed = |why: String| FeedError { why, throttled: false, retry_after: None }; + let agent = if is_loopback(url) { &self.direct } else { &self.feed }; + let mut resp = + agent.get(url).header("Accept", "application/json").call().map_err(|e| failed(format!("couldn't reach {} ({e})", host(url))))?; + // Seconds only; an HTTP date falls back to our own backoff. + let retry_after = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).and_then(|r| r.trim().parse::().ok()); + match resp.status().as_u16() { + 200 => read_text_capped(&mut resp, MAX_SMALL).map_err(|e| failed(format!("unreadable answer ({e})"))), + s => Err(FeedError { why: format!("{} answered HTTP {s}", host(url)), throttled: s == 429, retry_after }), + } + } + + /// GET a small text file (following redirects). + pub fn get_text(&self, url: &str) -> Result, String> { + let agent = if is_loopback(url) { &self.direct } else { &self.small }; + let mut resp = agent.get(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?; + match resp.status().as_u16() { + 200 => read_text_capped(&mut resp, MAX_SMALL).map(Some).map_err(|e| format!("unreadable answer ({e})")), + 404 => Ok(None), + s => Err(format!("{} answered HTTP {s}", host(url))), + } + } + + /// GET a small binary file (following redirects). `Ok(None)` for a 404. A file of more than + /// `max` bytes is refused: by its announced length before a byte is read, and by a hard limit + /// on the body when the length wasn't announced or was wrong. + pub fn get_bytes(&self, url: &str, max: usize) -> Result>, String> { + let agent = if is_loopback(url) { &self.direct } else { &self.small }; + let mut resp = agent.get(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?; + match resp.status().as_u16() { + 200 => { + let announced = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::().ok()); + let too_big = || format!("{} sent more than {max} bytes", host(url)); + if announced.is_some_and(|n| n > max as u64) { + return Err(too_big()); + } + let body = read_bytes_capped(&mut resp, max) + .map_err(|e| if e.contains("larger than") { too_big() } else { format!("unreadable answer ({e})") })?; + Ok(Some(body)) + } + 404 => Ok(None), + s => Err(format!("{} answered HTTP {s}", host(url))), + } + } + + /// Where `url` redirects to, without following it. + pub fn redirect_target(&self, url: &str) -> Result, String> { + let resp = self.no_redirect.head(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?; + match resp.status().as_u16() { + 300..=399 => Ok(resp.headers().get("location").and_then(|v| v.to_str().ok()).map(str::to_string)), + 404 => Ok(None), + s => Err(format!("{} answered HTTP {s}", host(url))), + } + } + + /// Downloads `url` to `dest`, checking it against the supplied SHA-256 checksum (and always + /// against `size`, when known). The caller must provide a published checksum. + /// + /// Tolerant by design: an interrupted or stalled transfer resumes where it stopped (HTTP range + /// requests) with backoff; a paused download, or one left by an earlier run, continues from + /// its partial file; a file that fails its checksum is fetched once more from scratch before + /// giving up. When the connection stays down, the download ends paused (`PAUSED`), never + /// losing what it has. `stop` pauses or cancels it between reads. + pub fn download(&self, url: &str, dest: &Path, size: u64, sha256: &str, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), String> { + if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("the release publishes no valid checksum for this file, so it wasn't downloaded".into()); + } + if let Some(dir) = dest.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?; + } + // A finished copy from an earlier attempt that never got installed. Marking it as just + // used, before checking it, keeps `clean_downloads` away from it while it's installed. + if dest.is_file() && touch(dest) && matches_published(dest, sha256) { + progress(Transfer::Bytes { done: size, total: size }); + return Ok(()); + } + // The partial file is named after the release file and its size; if it still turns out + // to belong to something else, the checksum catches it and the download starts over. + let part = part_path(dest, size); + let mut fresh_start_used = false; + loop { + if let Err(e) = self.fetch_resuming(url, &part, size, stop, progress) { + if e == CANCELLED { + let _ = std::fs::remove_file(&part); + } else if e != PAUSED { + log::warn!("download of {url} failed: {e}"); + } + return Err(e); + } + if matches_published(&part, sha256) { + if let Err(e) = rename_retrying(&part, dest, stop) { + if e == CANCELLED { + let _ = std::fs::remove_file(&part); + } + return Err(e); + } + return Ok(()); + } + let _ = std::fs::remove_file(&part); + if fresh_start_used { + return Err("the download doesn't match its published checksum, so it wasn't installed".into()); + } + // A resumed file can be spliced wrongly by a misbehaving proxy: try once from zero. + log::warn!("checksum mismatch for {url}; downloading again from the start"); + fresh_start_used = true; + } + } + + /// Fetches `url` into `part`, resuming from whatever `part` already holds. Retries while the + /// transfer keeps making progress; after several attempts in a row get nowhere, it stops and + /// reports `PAUSED` so the user can resume later. + fn fetch_resuming(&self, url: &str, part: &Path, size: u64, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), String> { + let mut fruitless = 0u32; + loop { + stop.check()?; + let have = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0); + if fruitless >= MAX_FRUITLESS_ATTEMPTS { + log::warn!("{url}: no progress after {fruitless} attempts; pausing"); + return Err(PAUSED.into()); + } + if fruitless > 0 { + progress(Transfer::Reconnecting { done: have, total: size }); + // 1 s, 2 s, 4 s … 30 s, staying responsive to Pause and Cancel. + let wait = Duration::from_secs((1u64 << (fruitless - 1).min(5)).min(30)); + let until = std::time::Instant::now() + wait; + while std::time::Instant::now() < until { + stop.check()?; + std::thread::sleep(Duration::from_millis(100)); + } + } + if size > 0 && have == size { + return Ok(()); + } + if size > 0 && have > size { + std::fs::remove_file(part).map_err(|e| format!("couldn't save the download ({e})"))?; + continue; + } + match self.attempt(url, part, have, size, stop, progress) { + Ok(()) => return Ok(()), + Err(AttemptError::Stopped(e)) => return Err(e), + Err(AttemptError::Fatal(e)) => return Err(e), + Err(AttemptError::Retry { error, progressed }) => { + log::info!("{url}: {error}; retrying"); + fruitless = if progressed { 1 } else { fruitless + 1 }; + } + } + } + } + + /// One request: appends to `part` from byte `have`. + fn attempt(&self, url: &str, part: &Path, have: u64, size: u64, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), AttemptError> { + let retry = |error: String, progressed: bool| AttemptError::Retry { error, progressed }; + // Binaries are already compressed; ask for the bytes exactly as published. + let agent = if is_loopback(url) { &self.direct_download } else { &self.download }; + let mut req = agent.get(url).header("Accept-Encoding", "identity"); + if have > 0 { + req = req.header("Range", &format!("bytes={have}-")); + } + let mut resp = match req.call() { + Ok(r) => r, + Err(e) => return Err(retry(format!("couldn't connect: {e}"), false)), + }; + let status = resp.status().as_u16(); + let header = |name: &str| resp.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string); + let (start, total) = match status { + 200 => (0, header("content-length").and_then(|v| v.parse::().ok()).filter(|n| *n > 0).unwrap_or(size)), + 206 => match header("content-range").as_deref().and_then(parse_content_range) { + Some((start, Some(total))) => (start, total), + Some((start, None)) => (start, size), + None => return Err(retry("the server sent an unreadable partial response".into(), false)), + }, + // The range is past the end: the part file is already complete (or wrong; the + // checksum decides). + 416 if have > 0 => return Ok(()), + 404 | 410 => return Err(AttemptError::Fatal("the file is no longer on the release page".into())), + 401 | 403 => return Err(retry(format!("the server refused the download (HTTP {status})"), false)), + s => return Err(retry(format!("HTTP {s}"), false)), + }; + if size > 0 && total != size { + return Err(AttemptError::Fatal(format!("the server offered {total} bytes, but the release lists {size}"))); + } + if let Some(enc) = header("content-encoding") + && !enc.eq_ignore_ascii_case("identity") + { + return Err(AttemptError::Fatal(format!("unexpected Content-Encoding '{enc}' on binary artifact download"))); + } + let limit = if total > 0 { total } else { MAX_DOWNLOAD }; + let appends = status == 206 && start == have; + if appends && have > limit { + return Err(AttemptError::Fatal(format!("the download exceeds the permitted size of {limit} bytes"))); + } + // Open the part file at the right place: a 200 restarts it, a 206 continues it. + let mut file = match (status, start) { + (206, s) if s == have => std::fs::OpenOptions::new().append(true).open(part), + _ => std::fs::File::create(part), + } + .map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?; + let mut done = if status == 206 && start == have { have } else { 0 }; + // We check the length and checksum ourselves; the reader's limit is only a backstop + // against an endless body. (ureq errors once the limit is *reached*, so leave headroom.) + let remaining = limit.saturating_sub(done); + let mut reader = resp.body_mut().with_config().limit(remaining.saturating_add(1 << 20)).reader(); + let mut buf = vec![0u8; 256 * 1024]; + let at_start = done; + progress(Transfer::Bytes { done, total }); + loop { + stop.check().map_err(AttemptError::Stopped)?; + let n = match reader.read(&mut buf) { + Ok(n) => n, + Err(e) => { + let _ = file.sync_all(); + return Err(retry(format!("the connection dropped: {e}"), done > at_start)); + } + }; + if n == 0 { + break; + } + let next_done = done.saturating_add(n as u64); + if next_done > limit { + return Err(AttemptError::Fatal(format!("the server sent more than the permitted size of {limit} bytes"))); + } + let chunk = buf.get(..n).ok_or_else(|| AttemptError::Fatal("read past the buffer".into()))?; + file.write_all(chunk).map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e}). Is the disk full?")))?; + done = next_done; + progress(Transfer::Bytes { done, total }); + } + file.sync_all().map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?; + if total > 0 && done < total { + return Err(retry(format!("the connection closed early ({done} of {total} bytes)"), done > at_start)); + } + Ok(()) + } +} + +/// Attempts in a row without any new bytes before pausing (about two minutes of backoff). +const MAX_FRUITLESS_ATTEMPTS: u32 = 8; +/// The longest one request may spend receiving; the next request resumes. +const ATTEMPT_BODY_TIME: Duration = Duration::from_secs(5 * 60); +/// Backstop when the server doesn't say how big the file is. +const MAX_DOWNLOAD: u64 = 16 << 30; + +/// What a download reports as it goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Transfer { + Bytes { + done: u64, + total: u64, + }, + /// The connection dropped; waiting before trying again. + Reconnecting { + done: u64, + total: u64, + }, +} + +/// Asks a running download to pause (keeping its partial file) or cancel (deleting it). +#[derive(Debug, Default)] +pub struct Stop(AtomicU8); + +impl Stop { + const RUN: u8 = 0; + const PAUSE: u8 = 1; + const CANCEL: u8 = 2; + + pub fn pause(&self) { + let _ = self.0.compare_exchange(Self::RUN, Self::PAUSE, Ordering::SeqCst, Ordering::SeqCst); + } + + pub fn cancel(&self) { + self.0.store(Self::CANCEL, Ordering::SeqCst); + } + + /// `Err(PAUSED | CANCELLED)` once asked to stop. + pub fn check(&self) -> Result<(), String> { + match self.0.load(Ordering::SeqCst) { + Self::PAUSE => Err(PAUSED.into()), + Self::CANCEL => Err(CANCELLED.into()), + _ => Ok(()), + } + } +} + +/// Whether a file on disk is the published one: its SHA-256 matches. +fn matches_published(path: &Path, sha256: &str) -> bool { + sha256_file(path).is_ok_and(|h| h.eq_ignore_ascii_case(sha256)) +} + +/// How much of a download is already on disk (a paused or interrupted one). +pub fn partial_bytes(dest: &Path, size: u64) -> Option { + std::fs::metadata(part_path(dest, size)).ok().map(|m| m.len()).filter(|n| *n > 0) +} + +/// Renames `from` to `to`, retrying for a few seconds while Windows reports the file busy: an +/// antivirus scanner often holds a just-written installer open, and the rename then fails with +/// "Access denied" (os error 5) or a sharing violation (os error 32). `stop` pauses or cancels +/// it between tries. +fn rename_retrying(from: &Path, to: &Path, stop: &Stop) -> Result<(), String> { + let mut wait = Duration::from_millis(100); + loop { + stop.check()?; + match std::fs::rename(from, to) { + Err(e) if cfg!(windows) && matches!(e.raw_os_error(), Some(5 | 32)) && wait <= Duration::from_secs(4) => { + log::info!("{} is busy ({e}); retrying the rename", to.display()); + std::thread::sleep(wait); + wait = wait.saturating_mul(2); + } + result => return result.map_err(|e| format!("couldn't save the download ({e})")), + } + } +} + +/// Sets a file's modification time to now. +fn touch(path: &Path) -> bool { + std::fs::File::options().write(true).open(path).and_then(|f| f.set_modified(std::time::SystemTime::now())).is_ok() +} + +/// Whether a finished download is waiting at `dest`. Judged by size alone: `download` checks it +/// against the published checksum before anything uses it. +pub fn finished(dest: &Path, size: u64) -> bool { + std::fs::metadata(dest).is_ok_and(|m| m.is_file() && m.len() == size) +} + +/// Removes leftovers from the download folder: partial files in an old naming scheme, paused +/// downloads untouched for `partial_max_age`, and finished ones (kept after a failed install) +/// untouched for `finished_max_age` (they're for releases long since replaced, or given up on). +/// Files written within the last minute are always kept, so a running download is never touched. +pub fn clean_downloads(dir: &Path, partial_max_age: Duration, finished_max_age: Duration) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let now = std::time::SystemTime::now(); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let age = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).unwrap_or_default(); + if age < Duration::from_secs(60) { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + // Current partial files are `..part`. + let current_part = name + .strip_suffix(".part") + .and_then(|stem| stem.rsplit_once('.')) + .is_some_and(|(_, size)| !size.is_empty() && size.bytes().all(|b| b.is_ascii_digit())); + let legacy_part = name.ends_with(".part") && !current_part; + let max_age = if name.ends_with(".part") { partial_max_age } else { finished_max_age }; + if legacy_part || age > max_age { + log::info!("removing stale download {}", path.display()); + let _ = std::fs::remove_file(&path); + } + } +} + +/// Throws away a paused download, and a finished one kept for another try at installing. +pub fn discard(dest: &Path, size: u64) { + let _ = std::fs::remove_file(part_path(dest, size)); + let _ = std::fs::remove_file(dest); +} + +enum AttemptError { + /// Paused or cancelled. + Stopped(String), + /// Won't get better by trying again. + Fatal(String), + Retry { + error: String, + progressed: bool, + }, +} + +/// `..part`: where an unfinished download of `dest` is kept. +fn part_path(dest: &Path, size: u64) -> std::path::PathBuf { + let tag = size.to_string(); + let mut name = dest.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default(); + name.push(format!(".{tag}.part")); + dest.with_file_name(name) +} + +/// `bytes 100-199/1000` → (100, Some(1000)); `bytes 100-199/*` → (100, None). +fn parse_content_range(v: &str) -> Option<(u64, Option)> { + let rest = v.trim().strip_prefix("bytes ")?; + let (range, total) = rest.split_once('/')?; + let start = range.split_once('-')?.0.trim().parse().ok()?; + let total = if total.trim() == "*" { None } else { Some(total.trim().parse().ok()?) }; + Some((start, total)) +} + +pub fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// SHA-256 of a file on disk. +pub fn sha256_file(path: &Path) -> Result { + let mut file = std::fs::File::open(path).map_err(|e| format!("couldn't read {} ({e})", path.display()))?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).map_err(|e| format!("couldn't read {} ({e})", path.display()))?; + Ok(hex(&hasher.finalize())) +} + +/// Whether a 403 or 429 from GitHub's API means "slow down", and until when (Unix seconds, when +/// it says). `None`: refused for some other reason. +/// +/// GitHub sends `x-ratelimit-reset` with every answer: it's when the hourly quota refills, which +/// only matters once `x-ratelimit-remaining` is 0. A secondary limit says how long to wait in +/// `Retry-After` (seconds) and leaves the hourly quota alone; without either, GitHub's advice is +/// to wait a minute. +fn refused_until(status: u16, remaining: Option<&str>, reset: Option, retry_after: Option, now: i64) -> Option> { + let retry = retry_after.map(|secs| now.saturating_add(secs.max(0))); + if remaining.map(str::trim) == Some("0") { + // `None` sorts first: whichever of the two is known, and the later if both are. + return Some(reset.max(retry)); + } + if retry.is_some() { + return Some(retry); + } + (status == 429).then_some(Some(now.saturating_add(60))) +} + +/// The host of `url` for error messages, or all of it when it has no scheme. +fn host(url: &str) -> &str { + url.split_once("://").and_then(|(_, rest)| rest.split('/').next()).unwrap_or(url) +} + +/// Reads a text body, rejecting one whose *decoded* size exceeds `max`. +/// +/// ureq's own `limit` guards the *compressed* stream: it sits below the transparent gzip decoder, +/// so a few hundred wire bytes can still expand far past the cap. So we cap the fully decoded +/// reader with `take` instead, one byte past the cap, and refuse an oversized answer outright — +/// before it is parsed — instead of truncating it into something that merely fails to parse +/// (CWE-409). Invalid UTF-8 stays an error, as before. +fn read_text_capped(resp: &mut ureq::http::Response, max: u64) -> Result { + let mut reader = resp.body_mut().with_config().reader().take(max.saturating_add(1)); + let mut text = String::new(); + reader.read_to_string(&mut text).map_err(|e| e.to_string())?; + if text.len() as u64 > max { + return Err(format!("the answer is larger than {max} bytes")); + } + Ok(text) +} + +/// Reads a binary body, rejecting one whose decoded size exceeds `max` (CWE-409). +fn read_bytes_capped(resp: &mut ureq::http::Response, max: usize) -> Result, String> { + let mut reader = resp.body_mut().with_config().reader().take((max as u64).saturating_add(1)); + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).map_err(|e| e.to_string())?; + if bytes.len() > max { + return Err(format!("the answer is larger than {max} bytes")); + } + Ok(bytes) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + const DAYS_30: Duration = Duration::from_secs(30 * 86_400); + const DAYS_7: Duration = Duration::from_secs(7 * 86_400); + + #[test] + fn host_is_what_follows_the_scheme() { + assert_eq!(host("https://api.github.com/repos/storytold/wordcraft"), "api.github.com"); + assert_eq!(host("http://127.0.0.1:8080"), "127.0.0.1:8080"); + assert_eq!(host("not a url"), "not a url"); + } + + #[test] + fn cleaning_keeps_current_partial_files_and_removes_leftovers() { + let dir = tempfile::tempdir().unwrap(); + let old = std::time::SystemTime::now() - Duration::from_secs(3600); + let make = |name: &str| { + let p = dir.path().join(name); + std::fs::write(&p, b"x").unwrap(); + std::fs::File::options().write(true).open(&p).unwrap().set_modified(old).unwrap(); + p + }; + let keep = make("photocraft-0.5.0-macos-universal.dmg.150000.part"); + let legacy = make("photocraft-0.5.0-macos-universal.part"); + let finished = make("photocraft-0.5.0-macos-universal.dmg"); + let fresh = dir.path().join("x.part"); + std::fs::write(&fresh, b"x").unwrap(); + clean_downloads(dir.path(), DAYS_30, DAYS_7); + assert!(keep.exists() && fresh.exists() && finished.exists()); + assert!(!legacy.exists()); + // A finished file goes sooner than a paused one. + clean_downloads(dir.path(), DAYS_30, Duration::from_secs(60)); + assert!(keep.exists() && !finished.exists()); + clean_downloads(dir.path(), Duration::from_secs(60), DAYS_7); + assert!(!keep.exists()); + } + + #[test] + fn a_kept_file_that_still_matches_is_not_fetched_again() { + let (body, sha) = body(); + let len = body.len() as u64; + let (url, requests) = serve(body.clone(), vec![Reply::Full, Reply::Full]); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("file.bin"); + let http = Http::new(crate::TEST_AGENT).unwrap(); + http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap(); + assert!(finished(&dest, len) && !finished(&dest, 0)); + // Old enough to be cleaned up, were it not about to be used. + let old = std::time::SystemTime::now() - Duration::from_secs(40 * 86_400); + std::fs::File::options().write(true).open(&dest).unwrap().set_modified(old).unwrap(); + let mut last = (0, 0); + let again = http.download(&url, &dest, len, &sha, &Stop::default(), &mut |t| { + if let Transfer::Bytes { done, total } = t { + last = (done, total); + } + }); + assert_eq!(again, Ok(())); + assert_eq!(last, (len, len)); + assert_eq!(requests.lock().unwrap().len(), 1); + // A cleanup while the installer runs leaves the reused file alone. + clean_downloads(dir.path(), DAYS_30, DAYS_7); + assert_eq!(std::fs::read(&dest).unwrap(), body); + discard(&dest, len); + assert!(!finished(&dest, len)); + } + + #[test] + fn a_kept_file_that_no_longer_matches_is_fetched_again() { + let (body, sha) = body(); + let len = body.len() as u64; + let (url, requests) = serve(body.clone(), vec![Reply::Full, Reply::Full]); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("file.bin"); + let http = Http::new(crate::TEST_AGENT).unwrap(); + http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap(); + // The same size, so only the checksum can tell. + std::fs::write(&dest, vec![b'x'; body.len()]).unwrap(); + http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), body); + assert_eq!(requests.lock().unwrap().len(), 2); + } + + #[test] + fn reads_content_ranges() { + assert_eq!(parse_content_range("bytes 100-199/1000"), Some((100, Some(1000)))); + assert_eq!(parse_content_range("bytes 5-9/*"), Some((5, None))); + assert_eq!(parse_content_range("items 1-2/3"), None); + assert_eq!(parse_content_range("bytes x-9/10"), None); + } + + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + + /// How the fake server answers one request. + #[derive(Clone, Copy, Debug)] + pub(crate) enum Reply { + /// The whole file. + Full, + /// Promise the whole file, send `n` bytes, hang up. + DropAfter(usize), + /// Honour `Range` with a 206. + Ranged, + /// The right length, wrong bytes. + Corrupt, + /// 404. + Missing, + /// The whole body, already a gzip stream, announced as `Content-Encoding: gzip`. + Gzipped, + } + + /// Serves `body` over plain HTTP following `script`, one reply per connection; records the + /// `Range` header each request carried. + pub(crate) fn serve(body: Vec, script: Vec) -> (String, Arc>>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/file.bin", listener.local_addr().unwrap()); + let ranges = Arc::new(Mutex::new(Vec::new())); + let seen = ranges.clone(); + std::thread::spawn(move || { + for reply in script { + let Ok((mut stream, _)) = listener.accept() else { return }; + let mut range = None; + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") { + range = v.trim().trim_end_matches('-').parse::().ok(); + } + } + seen.lock().unwrap().push(range.map(|r| r.to_string())); + let len = body.len(); + let _ = match reply { + Reply::Full => { + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n").and_then(|()| stream.write_all(&body)) + } + Reply::DropAfter(n) => write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n") + .and_then(|()| stream.write_all(&body[..n])), + Reply::Corrupt => write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n") + .and_then(|()| stream.write_all(&vec![b'x'; len])), + Reply::Missing => write!(stream, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"), + Reply::Gzipped => { + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nContent-Encoding: gzip\r\nConnection: close\r\n\r\n") + .and_then(|()| stream.write_all(&body)) + } + Reply::Ranged => { + let from = range.unwrap_or(0); + write!( + stream, + "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {from}-{}/{len}\r\nConnection: close\r\n\r\n", + len - from, + len - 1 + ) + .and_then(|()| stream.write_all(&body[from..])) + } + }; + } + }); + (url, ranges) + } + + pub(crate) fn body() -> (Vec, String) { + let body: Vec = (0..700_000u32).map(|i| (i % 251) as u8).collect(); + let sha = hex(&Sha256::digest(&body)); + (body, sha) + } + + fn run(script: Vec) -> (Result, String>, Vec>) { + let (body, sha) = body(); + let (url, ranges) = serve(body.clone(), script); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("file.bin"); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let mut last = (0, 0); + let result = http.download(&url, &dest, body.len() as u64, &sha, &Stop::default(), &mut |t| { + if let Transfer::Bytes { done, total } = t { + last = (done, total); + } + }); + let result = result.map(|()| std::fs::read(&dest).unwrap()); + if result.is_ok() { + assert_eq!(last, (body.len() as u64, body.len() as u64)); + } + let seen = ranges.lock().unwrap().clone(); + (result, seen) + } + + #[test] + fn small_binary_files_are_read_whole_and_a_404_is_none() { + let (body, _) = body(); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let (url, _) = serve(body.clone(), vec![Reply::Full]); + assert_eq!(http.get_bytes(&url, body.len()).unwrap(), Some(body.clone()), "a body of exactly the cap is fine"); + let (url, _) = serve(body, vec![Reply::Missing]); + assert_eq!(http.get_bytes(&url, 1 << 20).unwrap(), None); + } + + #[test] + fn a_file_over_the_cap_is_refused() { + let (body, _) = body(); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let (url, _) = serve(body.clone(), vec![Reply::Full]); + let err = http.get_bytes(&url, body.len() - 1).unwrap_err(); + assert!(err.contains("more than"), "{err}"); + } + + #[test] + fn a_complete_body_of_exactly_the_published_size_succeeds() { + // The bug this guards: a reader limit equal to the size failed on the final read. + let (result, _) = run(vec![Reply::Full]); + assert_eq!(result.unwrap(), body().0); + } + + #[test] + fn a_dropped_connection_resumes_where_it_stopped() { + let (result, ranges) = run(vec![Reply::DropAfter(300_000), Reply::Ranged]); + assert_eq!(result.unwrap(), body().0); + assert_eq!(ranges, [None, Some("300000".to_string())]); + } + + #[test] + fn a_server_that_ignores_ranges_starts_over() { + let (result, _) = run(vec![Reply::DropAfter(123_456), Reply::Full]); + assert_eq!(result.unwrap(), body().0); + } + + #[test] + fn corrupt_data_is_fetched_again_and_never_kept() { + let (result, _) = run(vec![Reply::Corrupt, Reply::Full]); + assert_eq!(result.unwrap(), body().0); + let (result, _) = run(vec![Reply::Corrupt, Reply::Corrupt]); + assert!(result.unwrap_err().contains("checksum")); + } + + #[test] + fn pausing_keeps_the_partial_file_and_resuming_continues_it() { + let (body, sha) = body(); + let len = body.len() as u64; + let (url, ranges) = serve(body.clone(), vec![Reply::Full, Reply::Ranged]); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("file.bin"); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let stop = Stop::default(); + // Pause as soon as the first bytes arrive. + let err = http + .download(&url, &dest, len, &sha, &stop, &mut |t| { + if matches!(t, Transfer::Bytes { done, .. } if done > 0) { + stop.pause(); + } + }) + .unwrap_err(); + assert_eq!(err, PAUSED); + let kept = partial_bytes(&dest, len).unwrap(); + assert!(kept > 0 && kept < len, "{kept}"); + http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), body); + assert_eq!(ranges.lock().unwrap()[1], Some(kept.to_string())); + } + + #[test] + fn cancelling_throws_the_partial_file_away() { + let (body, sha) = body(); + let len = body.len() as u64; + let (url, _) = serve(body, vec![Reply::Full]); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("file.bin"); + let stop = Stop::default(); + let err = Http::new(crate::TEST_AGENT) + .unwrap() + .download(&url, &dest, len, &sha, &stop, &mut |t| { + if matches!(t, Transfer::Bytes { done, .. } if done > 0) { + stop.cancel(); + } + }) + .unwrap_err(); + assert_eq!(err, CANCELLED); + assert_eq!(partial_bytes(&dest, len), None); + } + + #[test] + fn a_missing_file_fails_at_once() { + let (result, ranges) = run(vec![Reply::Missing, Reply::Full]); + assert!(result.unwrap_err().contains("no longer")); + assert_eq!(ranges.len(), 1); + } + + #[test] + fn a_partial_file_from_an_earlier_run_is_continued() { + let (body, sha) = body(); + let (url, ranges) = serve(body.clone(), vec![Reply::Ranged]); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("file.bin"); + std::fs::write(part_path(&dest, body.len() as u64), &body[..500_000]).unwrap(); + assert_eq!(partial_bytes(&dest, body.len() as u64), Some(500_000)); + Http::new(crate::TEST_AGENT).unwrap().download(&url, &dest, body.len() as u64, &sha, &Stop::default(), &mut |_| {}).unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), body); + assert_eq!(*ranges.lock().unwrap(), [Some("500000".to_string())]); + assert!(!part_path(&dest, body.len() as u64).exists()); + } + + #[test] + fn an_oversized_partial_file_is_restarted_from_zero() { + let (body, sha) = body(); + let (url, ranges) = serve(body.clone(), vec![Reply::Full]); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("file.bin"); + let size = body.len() as u64; + std::fs::write(part_path(&dest, size), vec![b'x'; body.len() + 1]).unwrap(); + Http::new(crate::TEST_AGENT).unwrap().download(&url, &dest, size, &sha, &Stop::default(), &mut |_| {}).unwrap(); + assert_eq!(std::fs::read(dest).unwrap(), body); + assert_eq!(*ranges.lock().unwrap(), [None]); + } + + #[test] + fn an_oversized_partial_file_that_cannot_be_removed_reports_an_error() { + let dir = tempfile::tempdir().unwrap(); + let part = part_path(&dir.path().join("file.bin"), 1); + // A directory cannot be removed as a file on Unix. On Windows, hold a file open + // without delete sharing: metadata remains readable, but removing it is refused. + #[cfg(unix)] + std::fs::create_dir(&part).unwrap(); + #[cfg(windows)] + let _locked_partial = { + use std::os::windows::fs::OpenOptionsExt; + std::fs::write(&part, b"too large").unwrap(); + // FILE_SHARE_READ | FILE_SHARE_WRITE, without FILE_SHARE_DELETE. + std::fs::File::options().read(true).share_mode(0x00000003).open(&part).unwrap() + }; + assert!(std::fs::metadata(&part).unwrap().len() > 1); + assert!(std::fs::remove_file(&part).is_err()); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let stop = Stop::default(); + // Bound the regression: the old retry loop spins until Cancel, without a request. + let (finished, done) = std::sync::mpsc::channel(); + let error = std::thread::scope(|scope| { + let watchdog_stop = &stop; + scope.spawn(move || { + if done.recv_timeout(Duration::from_secs(10)).is_err() { + watchdog_stop.cancel(); + } + }); + let result = http.fetch_resuming("http://127.0.0.1:9/file.bin", &part, 1, &stop, &mut |_| {}); + let _ = finished.send(()); + result.unwrap_err() + }); + assert!(error.contains("couldn't save the download"), "{error}"); + assert!(part.exists(), "a blocked partial file must be left alone"); + } + + #[test] + fn an_oversized_feed_answer_is_refused_before_parsing() { + // More than MAX_SMALL (4 MiB) of decoded text: rejected outright, not truncated and parsed. + let big = vec![b' '; (MAX_SMALL as usize) + 1024]; + let (url, _) = serve(big, vec![Reply::Full]); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let err = http.get_feed(&url).unwrap_err(); + assert!(err.why.contains("larger than"), "{}", err.why); + // A small answer still comes back intact. + let (url, _) = serve(b"{\"ok\":true}".to_vec(), vec![Reply::Full]); + assert_eq!(http.get_feed(&url).unwrap(), "{\"ok\":true}"); + } + + #[test] + fn a_gzip_bomb_answer_is_refused_before_parsing() { + // A few hundred bytes on the wire that decode to more than MAX_SMALL. ureq's own limit + // sits below the transparent gzip decoder, so only a cap on the decoded stream (CWE-409) + // refuses this; a wire-byte limit would hand the parser truncated JSON. + let big = vec![b'x'; (MAX_SMALL as usize) + 1024]; + let gz = gzip(&big); + assert!(gz.len() * 4 < MAX_SMALL as usize, "no longer a bomb: {} compressed bytes", gz.len()); + let (url, _) = serve(gz, vec![Reply::Gzipped]); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let err = http.get_text(&url).unwrap_err(); + assert!(err.contains("larger than"), "{err}"); + // A small gzip answer still decodes intact. + let (url, _) = serve(gzip(b"{\"ok\":true}"), vec![Reply::Gzipped]); + assert_eq!(http.get_text(&url).unwrap().unwrap(), "{\"ok\":true}"); + } + + /// `data` as a real gzip stream (what `Reply::Gzipped` serves). + fn gzip(data: &[u8]) -> Vec { + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(data).unwrap(); + enc.finish().unwrap() + } + + /// Live: a real Craft App release asset (`cargo test -p artcraft-launcher-native -- --ignored`). + #[test] + #[ignore = "needs network access"] + fn downloads_a_real_release_asset() { + let http = Http::new(crate::TEST_AGENT).unwrap(); + let Fetched::Body { text, .. } = http.get_api("https://api.github.com/repos/storytold/wordcraft/releases/latest", None).unwrap() else { + panic!("no release") + }; + let release = crate::release::parse_github_release(&text, "storytold/wordcraft").unwrap(); + let sums_url = &release.sums_asset().unwrap().url; + let sums = crate::release::parse_sha256sums(&http.get_text(sums_url).unwrap().unwrap()); + let asset = release.assets.iter().find(|a| a.name.ends_with("freebsd-x86_64.tar.gz")).unwrap(); + let sha = asset.sha256.clone().or_else(|| sums.iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h.clone())).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join(&asset.name); + http.download(&asset.url, &dest, asset.size, &sha, &Stop::default(), &mut |_| {}).unwrap(); + assert_eq!(std::fs::metadata(&dest).unwrap().len(), asset.size); + } + + /// Answers one request with `head` (status line and headers) and an empty body. + fn answer_once(head: String) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/repos/storytold/photocraft/releases/latest", listener.local_addr().unwrap()); + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { return }; + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + } + let _ = write!(stream, "{head}Content-Length: 0\r\nConnection: close\r\n\r\n"); + }); + url + } + + #[test] + fn a_secondary_limit_waits_for_retry_after_not_the_hourly_reset() { + // GitHub's secondary limit: quota left, an hourly reset 50 minutes away, and "try again + // in a minute". That's a minute, not 50 minutes of "the hourly limit was reached". + let now = crate::now(); + let url = answer_once(format!( + "HTTP/1.1 403 Forbidden\r\nx-ratelimit-remaining: 41\r\nx-ratelimit-reset: {}\r\nretry-after: 60\r\n", + now + 50 * 60 + )); + let Fetched::RateLimited { reset: Some(until) } = Http::new(crate::TEST_AGENT).unwrap().get_api(&url, None).unwrap() else { + panic!("not rate limited") + }; + assert!((now + 60..now + 120).contains(&until), "limited for {} s", until - now); + } + + #[test] + fn an_empty_hourly_quota_waits_for_the_reset() { + let now = crate::now(); + let reset = now + 31 * 60; + let url = answer_once(format!("HTTP/1.1 403 Forbidden\r\nx-ratelimit-remaining: 0\r\nx-ratelimit-reset: {reset}\r\n")); + assert_eq!(Http::new(crate::TEST_AGENT).unwrap().get_api(&url, None).unwrap(), Fetched::RateLimited { reset: Some(reset) }); + } + + #[test] + fn github_refusals_are_read_the_way_github_documents_them() { + const NOW: i64 = 1_791_500_000; + // Out of quota: until the reset, or Retry-After if that's later. + assert_eq!(refused_until(403, Some("0"), Some(NOW + 1800), None, NOW), Some(Some(NOW + 1800))); + assert_eq!(refused_until(403, Some("0"), Some(NOW + 30), Some(90), NOW), Some(Some(NOW + 90))); + assert_eq!(refused_until(403, Some("0"), None, None, NOW), Some(None)); + // A secondary limit: Retry-After, never the hourly reset. + assert_eq!(refused_until(429, Some("12"), Some(NOW + 1800), Some(30), NOW), Some(Some(NOW + 30))); + // A 429 that doesn't say: a minute. + assert_eq!(refused_until(429, Some("12"), Some(NOW + 1800), None, NOW), Some(Some(NOW + 60))); + // A plain 403 (blocked, private, …) isn't a rate limit. + assert_eq!(refused_until(403, Some("12"), Some(NOW + 1800), None, NOW), None); + assert_eq!(refused_until(403, None, None, None, NOW), None); + } + + #[test] + fn partial_files_are_named_after_the_file_and_size() { + assert_eq!(part_path(Path::new("/d/photocraft.dmg"), 9), Path::new("/d/photocraft.dmg.9.part")); + } + + #[test] + fn get_bytes_rejects_oversized_payload() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { return }; + let mut line = String::new(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + while reader.read_line(&mut line).unwrap_or(0) > 0 && line != "\r\n" { + line.clear(); + } + let body = vec![b'A'; 200]; + let _ = write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: 200\r\nConnection: close\r\n\r\n"); + let _ = stream.write_all(&body); + }); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let result = http.get_bytes(&format!("http://127.0.0.1:{port}/test.bin"), 100); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("more than 100 bytes")); + } +} diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs new file mode 100644 index 00000000..a92379f6 --- /dev/null +++ b/crates/update/src/install.rs @@ -0,0 +1,2870 @@ +//! Installing, updating, removing and starting Craft Apps. +//! +//! Apps go where their platform keeps applications, installed the platform's own way: +//! +//! - macOS: the signed `.app` from the release DMG into `/Applications`. +//! - Windows: the release MSI (Windows Installer) into `C:\Program Files\`. +//! - Linux: the distro's own package (`.deb` through apt on the Debian family, `.rpm` through +//! dnf/zypper/rpm on the Fedora, Red Hat and SUSE families, read from `os-release`) with the +//! system's polkit password prompt; otherwise a Flatpak, otherwise an AppImage in +//! `~/Applications` (the AppImage convention), with a menu entry and icon in +//! `~/.local/share` so it shows up like any other app. A Flatpak or AppImage launcher prefers +//! its own format. +//! - FreeBSD: the release's `/usr/local` tree, with a polkit prompt (or `~/.local` without one). +//! - Linux, inside the Flatpak launcher with no usable host Flatpak (none installed, or +//! `flatpak-spawn` not allowed): the release tarball, unpacked into the launcher's own folder +//! (`/apps/`) and run inside the same sandbox with `HOME` pointed at a `home/` +//! folder beside it, so the app's settings stay in the sandbox too. +//! +//! Every download is checked against its published SHA-256 before anything is unpacked, a +//! running app is never replaced, and a new copy is fully in place before the old one goes. + +use std::path::{Path, PathBuf}; + +use crate::protocol::{CANCELLED, INSTALL_CANCELLED, InstallLocations, JobPhase}; +use crate::release::{self, Asset, Release}; +use crate::{App, InstallSource, Installed, PackageKind, version}; + +use crate::http::{Http, Stop, Transfer}; +use crate::manifest::Record; +use crate::paths::Paths; +use crate::process; + +/// What an install needs from the backend. +pub struct Job<'a> { + pub http: &'a Http, + pub paths: &'a Paths, + pub stop: &'a Stop, + pub phase: &'a mut dyn FnMut(JobPhase), + pub install_locations: &'a InstallLocations, + /// Write over whatever [`occupied`] found. + pub replace: bool, +} + +/// Downloads and installs `asset` of `release`. Returns a record when the launcher owns the +/// result (portable ZIP, AppImage, macOS bundle, tarball); MSI installs are found in the registry. +/// `previous` is the launcher's record of the copy being replaced, if it installed it. +pub fn install( + app: &App, + release: &Release, + asset: &Asset, + kind: PackageKind, + existing: Option<&Installed>, + previous: Option<&Record>, + job: &mut Job<'_>, +) -> Result, String> { + install_at(app, release, asset, kind, existing, previous, job, None) +} + +type RecordCommit<'a> = Option<&'a mut dyn FnMut(&Record) -> Result<(), String>>; + +#[allow(clippy::too_many_arguments)] +pub fn install_at( + app: &App, + release: &Release, + asset: &Asset, + kind: PackageKind, + existing: Option<&Installed>, + previous: Option<&Record>, + job: &mut Job<'_>, + commit: RecordCommit<'_>, +) -> Result, String> { + #[cfg(windows)] + let mut commit = commit; + #[cfg(not(windows))] + let _ = commit; + #[allow(unused_variables)] + let destination = destination(app, kind, existing, previous, job.install_locations)?; + if let Some(inst) = existing + && inst.source.launcher_can_update() + && process::is_running(Path::new(&inst.location)) + { + return Err(format!("{} is running. Quit it, then try again.", app.name)); + } + bind_artifact_version(app, release, asset)?; + if !job.replace + && let Some(inst) = existing + && let (Some(rel_v), Some(inst_v)) = (release.version.as_ref(), inst.version.as_ref()) + && version::is_newer(inst_v, rel_v) + { + return Err(format!("Cannot update {} to {}: installed version {} is newer", app.name, release.version_label(), inst_v)); + } + log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label()); + let file = download(release, asset, job)?; + log::info!("{}: download ready ({}), running installer...", app.name, file.display()); + // Past this point the install can't be interrupted halfway. + (job.phase)(JobPhase::Installing); + #[allow(unused_variables)] + let version = release.version_label(); + let result = match kind { + #[cfg(target_os = "macos")] + PackageKind::Dmg => { + macos::install_dmg(app, &file, existing, destination.as_deref(), job.replace, job.paths, &macos::Expect::release(release)) + .map(|path| Some(record(&version, kind, path, Vec::new(), false))) + } + #[cfg(windows)] + PackageKind::Msi => windows::run_msi(&file, destination.as_deref(), job.stop, &mut *job.phase).map(|_| None), + #[cfg(windows)] + PackageKind::PortableZip => { + crate::portable::install(app, &file, &version, previous, job.paths, destination.as_deref(), job.replace, &mut |record| { + if release.version.is_some() && crate::detect::windows::executable_version(&record.path).as_ref() != release.version.as_ref() { + return Err("The portable executable does not match the selected version. The installation was rolled back.".into()); + } + match commit.as_deref_mut() { + Some(commit) => commit(record), + None => Ok(()), + } + }) + .map(Some) + } + #[cfg(target_os = "linux")] + PackageKind::Deb | PackageKind::Rpm | PackageKind::ArchPkg | PackageKind::Flatpak => { + linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| None) + } + #[cfg(target_os = "linux")] + PackageKind::AppImage => { + linux::install_appimage(&file, &asset.name, destination.as_deref(), previous, job.replace).map(|(path, replaced)| { + let files = linux::integrate(app, &path, &replaced, previous); + Some(record(&version, kind, path, files, false)) + }) + } + #[cfg(target_os = "freebsd")] + PackageKind::TarGz => freebsd::install_tree(app, &file, job.paths).map(|(bin, files, root)| Some(record(&version, kind, bin, files, root))), + #[cfg(target_os = "linux")] + PackageKind::TarGz => linux::install_tree(app, &file, job.paths).map(|path| Some(record(&version, kind, path, Vec::new(), false))), + other => Err(format!("{} isn't supported on this system", other.label())), + }; + match &result { + Ok(_) => log::info!("{}: successfully installed/updated to {}", app.name, release.version_label()), + Err(e) if e == CANCELLED => log::info!("{}: installation cancelled", app.name), + Err(e) => log::error!("{}: installation failed: {e}", app.name), + } + // A failed install keeps the download: the next try checks it against its published checksum + // again and skips the transfer. + if result.is_ok() { + let _ = std::fs::remove_file(&file); + } + // The system's own prompt was dismissed: the download itself wasn't cancelled. + result.map_err(|e| if e == CANCELLED { INSTALL_CANCELLED.to_string() } else { e }) +} + +/// Rejects an artifact whose own identity disagrees with the release that authorized it. +/// +/// A feed or GitHub converter supplies the release version, the asset filename, and the download +/// URL independently. The downgrade guard and the persisted record both trust `release.version`, +/// so a controller could advertise a new version while pointing at an older official artifact — +/// bypassing the guard and recording a false installed version (finding: version not bound to the +/// release). We require the version encoded in the asset filename and the release tag embedded in +/// the download URL to agree with the release version before any bytes are fetched. +pub(crate) fn bind_artifact_version(app: &App, release: &Release, asset: &Asset) -> Result<(), String> { + let mismatch = || format!("{}'s download does not match the advertised version {}.", app.name, release.version_label()); + let release_version = release.version.as_ref(); + + // The version in the asset filename (`---.`), when it carries one. + let stems: Vec<&str> = app.stems().collect(); + if let Some(release_version) = release_version + && let Some(parsed) = crate::platform::parse_asset_name(&asset.name, &stems) + && version::parse(&parsed.version).as_ref() != Some(release_version) + { + return Err(mismatch()); + } + + // The release tag and filename embedded in a release download URL (`.../releases/download//`). + if let Some(rest) = asset.url.split("/releases/download/").nth(1) { + let mut parts = rest.split('/'); + let tag = parts.next().unwrap_or(""); + let url_filename = parts.next().unwrap_or(""); + if tag.is_empty() + || release_version + .map_or(tag != release.tag, |release_version| tag != release.tag && version::parse(tag).as_ref() != Some(release_version)) + { + return Err(mismatch()); + } + if !url_filename.is_empty() && url_filename != asset.name { + return Err(format!("{}'s download URL filename does not match selected asset {}.", app.name, asset.name)); + } + } + Ok(()) +} + +/// What a new install would write over: something already at its destination that the launcher +/// didn't put there. +pub fn occupied( + app: &App, + #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] asset: &Asset, + kind: PackageKind, + existing: Option<&Installed>, + previous: Option<&Record>, + locations: &InstallLocations, + paths: &Paths, +) -> Result, String> { + if existing.is_some() || previous.is_some() { + return Ok(None); + } + let folder = destination(app, kind, None, None, locations)?; + let target = match kind { + PackageKind::Msi => folder, + PackageKind::PortableZip => Some(folder.unwrap_or_else(|| paths.apps()).join(app.id)), + PackageKind::Dmg => folder.map(|f| f.join(format!("{}.app", app.name))), + #[cfg(target_os = "linux")] + PackageKind::AppImage => folder.or_else(crate::detect::linux::appimage_dir).map(|f| f.join(&asset.name)), + _ => None, + }; + Ok(target.filter(|t| std::fs::symlink_metadata(t).is_ok())) +} + +/// The folder a new copy goes in: beside the launcher's copy it replaces, else the user's choice, +/// else the platform default (`None`). macOS updates find their bundle's folder themselves. +fn destination( + app: &App, + kind: PackageKind, + existing: Option<&Installed>, + previous: Option<&Record>, + locations: &InstallLocations, +) -> Result, String> { + if kind == PackageKind::PortableZip && previous.is_some_and(|record| record.kind == PackageKind::PortableZip) { + return Ok(None); + } + if let Some(inst) = existing { + let exe = Path::new(&inst.location); + return Ok(match kind { + PackageKind::AppImage => previous.filter(|r| r.kind == kind).and_then(|r| r.path.parent()).map(Path::to_path_buf), + PackageKind::Msi => exe.parent().filter(|_| exe.is_absolute()).map(Path::to_path_buf), + _ => None, + }); + } + let Some(folder) = locations.folder(app.id) else { return Ok(None) }; + if !matches!(kind, PackageKind::Dmg | PackageKind::AppImage | PackageKind::Msi | PackageKind::PortableZip) || process::in_sandbox() { + return Err("custom install folders aren't supported on this system".into()); + } + let parent = selected_folder(folder, kind != PackageKind::Msi)?; + Ok(Some(if kind == PackageKind::Msi { parent.join(app.name) } else { parent })) +} + +fn selected_folder(folder: &str, writable: bool) -> Result { + let path = if folder == "~" { + dirs::home_dir().ok_or("no home folder")? + } else if let Some(relative) = folder.strip_prefix("~/") { + dirs::home_dir().ok_or("no home folder")?.join(relative) + } else { + PathBuf::from(folder) + }; + let invalid = |reason: &str| format!("couldn't use install folder {}: {reason}", path.display()); + if folder.chars().any(|c| c.is_control() || (cfg!(windows) && c == '"')) { + return Err(invalid("the path contains invalid characters")); + } + if !path.is_absolute() { + return Err(invalid("choose an absolute path")); + } + if !path.is_dir() { + return Err(invalid("the folder is unavailable. Create it or reconnect its drive first")); + } + if writable { + let probe = path.join(format!( + ".artcraft-launcher-write-test-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()) + )); + let output = std::fs::OpenOptions::new().write(true).create_new(true).open(&probe).map_err(|e| invalid(&e.to_string()))?; + drop(output); + std::fs::remove_file(&probe).map_err(|e| invalid(&e.to_string()))?; + } + Ok(path) +} + +#[cfg(any(windows, test))] +fn msi_folder_argument(path: &Path) -> Result { + let value = path.to_str().ok_or("unusual characters in a path")?; + if value.chars().any(|c| c == '"' || c.is_control()) { + return Err(format!("couldn't use install folder {value}: the path contains invalid characters")); + } + Ok(format!("INSTALLFOLDER=\"{value}\"")) +} + +/// Where `asset` is downloaded to (and its partial file kept while unfinished). +pub fn download_path(paths: &Paths, asset: &Asset) -> PathBuf { + paths.downloads().join(&asset.name) +} + +#[allow(dead_code)] +fn record(version: &str, kind: PackageKind, path: PathBuf, files: Vec, needs_root: bool) -> Record { + Record { version: version.to_string(), kind, path, installed_at: crate::now(), files, needs_root } +} + +/// The package format to install `app` with: the format of the copy that's there (an update +/// stays a `.deb` update), else the user's choice, else the best this system supports. +/// +/// Returns an empty `Vec` when `app` is already installed outside the launcher (such as an AUR +/// or repository Arch package) and must not be updated or replaced by the launcher. +pub fn choose_kinds(app: &App, existing: Option<&Installed>, record: Option<&Record>, preferred: &[PackageKind]) -> Vec { + let available = available_kinds(); + let can_pacman = available.contains(&PackageKind::ArchPkg) || can_pacman(); + choose_kinds_with(app, existing, record, preferred, &available, can_pacman) +} + +pub(crate) fn choose_kinds_with( + app: &App, + existing: Option<&Installed>, + record: Option<&Record>, + preferred: &[PackageKind], + available: &[PackageKind], + can_pacman: bool, +) -> Vec { + if let Some(inst) = existing { + let kind = match inst.source { + InstallSource::Launcher => record.map(|r| r.kind), + InstallSource::MacApp => Some(PackageKind::Dmg), + InstallSource::Msi => Some(PackageKind::Msi), + InstallSource::Deb => Some(PackageKind::Deb), + InstallSource::Rpm => Some(PackageKind::Rpm), + // The release's own Arch package is updated like a .deb. AUR builds and repository + // packages are updated outside the launcher (see `managed_elsewhere`); the launcher + // opens and removes them but never replaces them. + InstallSource::Pacman if is_release_arch_package(app, inst) => Some(PackageKind::ArchPkg), + InstallSource::Pacman => return Vec::new(), + InstallSource::Flatpak => Some(PackageKind::Flatpak), + InstallSource::AppImage => Some(PackageKind::AppImage), + InstallSource::Binary => Some(PackageKind::TarGz), + }; + if let Some(k) = kind.filter(|k| available.contains(k) || (*k == PackageKind::ArchPkg && can_pacman)) { + return vec![k]; + } + } + let mut kinds: Vec = preferred.iter().copied().filter(|k| available.contains(k)).collect(); + for k in available { + if !kinds.contains(k) { + kinds.push(*k); + } + } + kinds +} + +/// Whether this system has a working pacman installation and pacman database. +fn can_pacman() -> bool { + #[cfg(target_os = "linux")] + return find_in(&SYSTEM_BIN_DIRS, std::ffi::OsStr::new("pacman")).is_some() && crate::detect::linux::has_pacman_db(); + #[cfg(not(target_os = "linux"))] + return false; +} + +/// Whether a pacman install is the release's own Arch package rather than an AUR build. The +/// release names it by the bundle id (`ai.storyteller.`), which no AUR package uses +/// (they are ``, `-bin` and `-git`), so an AUR helper never mistakes it +/// for one of its own and rebuilds it from source. +pub fn is_release_arch_package(app: &App, installed: &Installed) -> bool { + installed.source == InstallSource::Pacman && installed.package.as_deref() == Some(&app.bundle_id()) +} + +/// Package formats this machine can install, best first. +pub fn available_kinds() -> Vec { + #[cfg(target_os = "macos")] + return vec![PackageKind::Dmg]; + #[cfg(windows)] + return vec![PackageKind::Msi, PackageKind::PortableZip]; + #[cfg(target_os = "linux")] + return linux::available_kinds(); + #[cfg(target_os = "freebsd")] + return vec![PackageKind::TarGz]; + #[allow(unreachable_code)] + Vec::new() +} + +/// Fetches `asset` into the download folder, checked against its published SHA-256: the feed's +/// or GitHub's per-file digest, else the release's `SHA256SUMS.txt`. A release with neither is +/// refused before anything is fetched: a file nothing vouches for is never installed (#90). +pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result { + if !release::valid_file_name(&asset.name) { + return Err("the release has an unexpected file name".into()); + } + let sha = match &asset.sha256 { + Some(s) => Some(s.clone()), + None => published_checksum(release, asset, job.http)?, + }; + let Some(sha) = sha else { + return Err(format!( + "the release publishes no checksum for {}, so it can't be verified and wasn't installed. Try again later, or report it on GitHub.", + asset.name + )); + }; + let dest = download_path(job.paths, asset); + let phase = &mut *job.phase; + job.http.download(&asset.url, &dest, asset.size, &sha, job.stop, &mut |t| match t { + Transfer::Bytes { done, total } => phase(JobPhase::Downloading { done, total }), + Transfer::Reconnecting { done, total } => phase(JobPhase::Reconnecting { done, total }), + })?; + (job.phase)(JobPhase::Verifying); + Ok(dest) +} + +/// The file's line in the release's `SHA256SUMS.txt`. `Ok(None)` only when the release really +/// publishes no checksum for it; a list that exists but can't be fetched is an error, so a +/// network hiccup never downgrades the check. +fn published_checksum(release: &Release, asset: &Asset, http: &Http) -> Result, String> { + let Some(sums) = release.sums_asset() else { return Ok(None) }; + let Some(text) = http.get_text(&sums.url).map_err(|e| format!("couldn't fetch the release's checksums ({e}). Try again"))? else { + return Ok(None); + }; + Ok(release::parse_sha256sums(&text).into_iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h)) +} + +/// When `installed` is owned by something the launcher doesn't drive, what to tell the user +/// instead of installing a second copy. Arch packages other than the release's own are removed +/// through pacman like any system package, but updated by whatever they came from: a system +/// update for a repository package (Omarchy's, Chaotic-AUR, ...), the user's AUR helper (which +/// builds as the user and can't run behind the password prompt) for anything else. +pub fn managed_elsewhere(app: &App, installed: &Installed, uninstall: bool) -> Option { + managed_elsewhere_with(app, installed, uninstall, pacman_repo) +} + +fn managed_elsewhere_with(app: &App, installed: &Installed, uninstall: bool, repo_fn: impl Fn(&str) -> Option) -> Option { + match installed.source { + InstallSource::Pacman if !uninstall && !is_release_arch_package(app, installed) => { + let pkg = installed.package.clone().unwrap_or_else(|| app.id.to_string()); + Some(pacman_update_hint(app, &pkg, repo_fn(&pkg).as_deref())) + } + InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)), + _ => None, + } +} + +/// How to update an Arch package the launcher doesn't own: `repo` is the sync repository it +/// comes from, or `None` for a foreign (AUR) package. +fn pacman_update_hint(app: &App, pkg: &str, repo: Option<&str>) -> String { + let helper = ["paru", "yay"].into_iter().find(|h| which(h).is_some()); + let cmd = match helper { + Some(h) => format!("{h} -S {pkg}"), + None => format!("your AUR helper (for example: yay -S {pkg})"), + }; + if let Some(repo) = repo { + return format!("{} was installed from the {repo} repository or the AUR ({pkg}). Update it with {cmd} or sudo pacman -Syu", app.name); + } + format!("{} was installed from the AUR ({pkg}). Update it with {cmd}", app.name) +} + +/// The sync repository (`extra`, `omarchy`, ...) that carries the installed package `pkg`, or +/// `None` when no configured repository does (an AUR or locally built package) or pacman can't +/// be asked. +fn pacman_repo(pkg: &str) -> Option { + #[cfg(target_os = "linux")] + if !crate::detect::linux::has_pacman_db() { + return None; + } + let pacman = which("pacman")?; + let t = std::time::Duration::from_secs(5); + // For packages carried by a configured sync repository, ask pacman -Si which repo carries it. + // pacman translates its field names; the C locale keeps "Repository" parseable. + let mut si = process::command(&pacman); + si.args(["-Si", "--", pkg]).env("LC_ALL", "C"); + let out = process::run_command(si, "pacman", t).ok()?; + parse_pacman_repo(&out) +} + +/// The `Repository` field of `pacman -Si` output (the first one, when several repos carry it: +/// that is the one pacman installs from). +fn parse_pacman_repo(si: &str) -> Option { + si.lines().find_map(|l| { + let (key, value) = l.split_once(':')?; + (key.trim() == "Repository").then(|| value.trim().to_string()).filter(|r| !r.is_empty()) + }) +} + +/// Removes an installed copy. `phase` hears when the removal has to wait for another install; +/// `stop` cancels it while it waits. +pub fn uninstall( + app: &App, + installed: &Installed, + record: Option<&Record>, + paths: &Paths, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), +) -> Result<(), String> { + let _ = (record, stop, &phase, paths); + let path = PathBuf::from(&installed.location); + if let Some(msg) = managed_elsewhere(app, installed, true) { + return Err(msg); + } + if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher) + && process::is_running(&path) + { + return Err(format!("{} is running. Quit it, then try again.", app.name)); + } + match installed.source { + #[cfg(windows)] + InstallSource::Launcher if record.is_some_and(|r| r.kind == PackageKind::PortableZip) => { + crate::portable::remove(app, record.ok_or("the launcher has no record of these files")?) + } + #[cfg(windows)] + InstallSource::Msi => windows::uninstall_msi(app, stop, phase), + #[cfg(target_os = "macos")] + InstallSource::MacApp | InstallSource::Launcher => macos::move_to_trash(&path), + #[cfg(target_os = "linux")] + InstallSource::Deb | InstallSource::Rpm | InstallSource::Pacman | InstallSource::Flatpak => { + linux::remove_package(app, installed, stop, phase) + } + #[cfg(target_os = "freebsd")] + InstallSource::Launcher => match record { + Some(r) => freebsd::remove_files(&r.files, r.needs_root), + None => Err("the launcher has no record of these files".into()), + }, + #[cfg(target_os = "linux")] + InstallSource::AppImage => remove_appimage(app, &path, record), + // A tarball the launcher unpacked into its own folder: take the tree out and leave the + // app's settings (`home/`) alone, so a reinstall finds its projects where they were. + #[cfg(target_os = "linux")] + InstallSource::Launcher if record.is_some_and(|r| r.kind == PackageKind::TarGz) => linux::remove_tree(app, paths), + #[cfg(target_os = "linux")] + InstallSource::Launcher => remove_appimage(app, &path, record), + other => Err(format!("{} can't be removed from here", other.label())), + } +} + +/// Deletes an AppImage, the menu entry and icon the launcher wrote for it, and the menu entry +/// the app may have written for itself on first run. +#[cfg(target_os = "linux")] +fn remove_appimage(app: &App, path: &Path, record: Option<&Record>) -> Result<(), String> { + if path.extension().is_none_or(|e| !e.eq_ignore_ascii_case("AppImage")) || !path.is_file() { + return Err(format!("{} isn't an AppImage, so it was left alone", path.display())); + } + // AppImages found elsewhere (Gear Lever's ~/AppImages, ~/.local/bin) are the user's own: + // remove only the launcher's folder or a copy the launcher recorded installing. + let ours = + path.parent().is_some_and(|dir| crate::detect::linux::appimage_dir().is_some_and(|own| own == dir)) || record.is_some_and(|r| r.path == path); + if !ours { + return Err(format!("{} wasn't installed by the launcher. Remove it the way it was added.", path.display())); + } + std::fs::remove_file(path).map_err(|e| format!("couldn't remove {} ({e})", path.display()))?; + linux::unintegrate(app, path, record); + Ok(()) +} + +/// Starts an installed app. +pub fn launch(app: &App, installed: &Installed, paths: &Paths) -> Result<(), String> { + let _ = paths; + let path = Path::new(&installed.location); + match installed.source { + InstallSource::Flatpak => { + let mut cmd = process::flatpak_command(); + cmd.args(["run", &app.bundle_id()]); + process::spawn_detached(cmd) + } + InstallSource::MacApp => process::open(path.as_os_str()), + // A tarball the launcher unpacked into its own folder: run it here, against its own + // home, rather than on the host through `flatpak-spawn` (which is what `launch_binary` + // would do inside the sandbox). Its settings then land beside it instead of in the + // launcher's or the host's home folder. + #[cfg(target_os = "linux")] + InstallSource::Launcher if path.starts_with(paths.apps()) => { + let home = paths.app_home(app.id); + prepare_home(&home); + let mut cmd = process::command(path); + for (name, value) in home_env(&home) { + cmd.env(name, value); + } + if let Some(dir) = path.parent() { + cmd.current_dir(dir); + } + process::spawn_detached(cmd) + } + _ if cfg!(target_os = "macos") && path.extension().is_some_and(|e| e == "app") => process::open(path.as_os_str()), + _ => { + if !path.exists() { + return Err(format!("{} isn't where it was installed ({})", app.name, path.display())); + } + process::launch_binary(path, &[]) + } + } + .map_err(|e| format!("Couldn't open {}: {e}", app.name)) +} + +/// The environment a Craft App unpacked by the launcher runs with: its own `HOME` and the XDG +/// base directories inside it. Apps read their settings from these, so everything they keep +/// (settings, projects, caches) stays in that one folder. +#[cfg(target_os = "linux")] +fn home_env(home: &Path) -> [(&'static str, PathBuf); 5] { + [ + ("HOME", home.to_path_buf()), + ("XDG_CONFIG_HOME", home.join(".config")), + ("XDG_DATA_HOME", home.join(".local/share")), + ("XDG_CACHE_HOME", home.join(".cache")), + ("XDG_STATE_HOME", home.join(".local/state")), + ] +} + +/// Creates the folders an app unpacked by the launcher expects to find, and links the host's +/// `~/.vst3` and `~/.clap` (SoundCraft's plugin folders) in when they exist, so an app running +/// inside the sandbox still sees the plugins installed on the host. Every failure here is +/// ignored: the app is worth starting even if its home is not quite ready. +#[cfg(target_os = "linux")] +fn prepare_home(home: &Path) { + for (_, dir) in home_env(home).iter().skip(1) { + let _ = std::fs::create_dir_all(dir); + } + let Some(real) = dirs::home_dir() else { return }; + if real == home { + return; + } + for name in [".vst3", ".clap"] { + let link = home.join(name); + if link.symlink_metadata().is_ok() { + continue; + } + if real.join(name).is_dir() { + let _ = std::os::unix::fs::symlink(real.join(name), link); + } + } +} + +/// A new folder of our own inside `dir`, removed when dropped: a predictable name there may be +/// the user's. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn staging_dir(dir: &Path) -> Result { + tempfile::Builder::new() + .prefix(".artcraft-launcher-") + .tempdir_in(dir) + .map_err(|e| format!("couldn't create a staging folder in {} ({e})", dir.display())) +} + +/// Moves `staged` into place at `dest`, keeping the old copy until the new one is there. +#[allow(dead_code)] +fn swap_into_place(staged: &Path, dest: &Path) -> Result<(), String> { + swap_into_place_via(staged, dest, |from, to| std::fs::rename(from, to)) +} + +fn swap_into_place_via(staged: &Path, dest: &Path, rename: impl Fn(&Path, &Path) -> std::io::Result<()>) -> Result<(), String> { + // Reserve a directory of our own beside the destination. A predictable sibling such as + // `app.old` may be a user's backup, and must never be removed or overwritten. + let backup = if dest.exists() { + let parent = dest.parent().ok_or("the installed app has no parent folder")?; + let backup = tempfile::Builder::new() + .prefix(".artcraft-launcher-backup-") + .tempdir_in(parent) + .map_err(|e| format!("couldn't create a backup folder in {} ({e})", parent.display()))?; + rename(dest, &backup.path().join("old")).map_err(|e| format!("couldn't move the old copy aside ({e})"))?; + Some(backup) + } else { + None + }; + if let Err(e) = rename(staged, dest) { + if let Some(backup) = backup + && let Err(restore) = rename(&backup.path().join("old"), dest) + { + // A failed rollback must not let TempDir's cleanup delete the only old copy. + let saved = backup.keep().join("old"); + return Err(format!( + "couldn't put the new copy in place ({e}); couldn't restore the old copy ({restore}); it was kept at {}", + saved.display() + )); + } + return Err(format!("couldn't put the new copy in place ({e})")); + } + Ok(()) +} + +/// After unpacking an archive into `dir`: its single top-level folder, if it has one. +#[allow(dead_code)] +fn single_top_folder(dir: &Path) -> Result { + let entries: Vec<_> = std::fs::read_dir(dir).map_err(|e| e.to_string())?.flatten().collect(); + match entries.as_slice() { + [only] if only.file_type().is_ok_and(|ft| ft.is_dir() && !ft.is_symlink()) => Ok(only.path()), + _ => Ok(dir.to_path_buf()), + } +} + +/// The app binary inside an unpacked archive: `bin/`, `` or `.exe`. +#[allow(dead_code)] +fn find_binary(root: &Path, app: &App) -> Option { + let exe = if cfg!(windows) { format!("{}.exe", app.id) } else { app.id.to_string() }; + [root.join("bin").join(&exe), root.join(&exe)].into_iter().find(|p| p.is_file()) +} + +/// Finds a program on `PATH`. +#[allow(dead_code)] +fn which(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file()) +} + +/// The root-owned directories that programs run as root come from. `pkexec` looks a bare program +/// name up on the caller's `PATH`, so it is always handed an absolute path from one of these. +#[cfg(target_os = "linux")] +const SYSTEM_BIN_DIRS: [&str; 4] = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; +/// FreeBSD's packages, polkit's `pkexec` among them, install under `/usr/local`. +#[cfg(target_os = "freebsd")] +const SYSTEM_BIN_DIRS: [&str; 6] = ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/local/bin", "/usr/local/sbin"]; + +/// `name` in the first of `dirs` that has it. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn find_in(dirs: &[impl AsRef], name: &std::ffi::OsStr) -> Option { + dirs.iter().map(|d| d.as_ref().join(name)).find(|p| p.is_file()) +} + +/// `args` with its program as an absolute path: a bare name is looked up in `dirs`. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn with_absolute_program(dirs: &[impl AsRef], args: &[&std::ffi::OsStr]) -> Result, String> { + let (program, rest) = args.split_first().ok_or("there is no command to run")?; + let path = Path::new(program); + let program = if path.is_absolute() { + path.to_path_buf() + } else if path.file_name() == Some(*program) { + find_in(dirs, program).ok_or_else(|| format!("{} isn't installed", path.display()))? + } else { + return Err(format!("{} isn't a program name", path.display())); + }; + Ok(std::iter::once(program.into_os_string()).chain(rest.iter().map(|a| a.to_os_string())).collect()) +} + +/// polkit's `pkexec`, from the same directories as the programs it runs. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn pkexec() -> Option { + find_in(&SYSTEM_BIN_DIRS, std::ffi::OsStr::new("pkexec")) +} + +/// Runs a command as root through polkit (`pkexec`), which shows the desktop's password prompt. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> { + let args = with_absolute_program(&SYSTEM_BIN_DIRS, args)?; + let pkexec = pkexec().ok_or("installing system-wide needs polkit (pkexec), which isn't installed")?; + let status = process::command(pkexec).args(&args).status().map_err(|e| format!("couldn't ask for permission ({e})"))?; + root_outcome(status.code()) +} + +/// What `pkexec`'s exit code says about the command it ran. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn root_outcome(code: Option) -> Result<(), String> { + match code { + Some(0) => Ok(()), + // pkexec: 126 = the password prompt was dismissed, 127 = not authorized. + Some(126 | 127) => Err("cancelled".into()), + Some(c) => Err(format!("the system installer stopped with error {c}")), + None => Err("the system installer was interrupted".into()), + } +} + +/// How the error of an installer call shared by several apps ends (see `linux::shared_outcome`). +const SHARED_CALL: &str = "apps together. Try them one at a time."; + +/// Whether an install failed in one installer call shared with other apps (Linux packages +/// installed together). Such a call can fail after it has installed some of its apps; any other +/// failure is the app's own. +pub fn failed_in_shared_call(error: &str) -> bool { + cfg!(target_os = "linux") && error.ends_with(SHARED_CALL) +} + +/// The system's installer runs one job at a time; the others wait their turn here. +#[cfg(any(target_os = "linux", windows))] +static INSTALLER: crate::queue::Queue = crate::queue::Queue::new(); + +/// For [`INSTALLER`]: reports [`JobPhase::Waiting`] while a job waits, then `working` again. +#[cfg(any(target_os = "linux", windows))] +fn turn<'a>(phase: &'a mut dyn FnMut(JobPhase), working: JobPhase) -> impl FnMut(bool) + 'a { + move |waiting| phase(if waiting { JobPhase::Waiting } else { working }) +} + +/// For [`INSTALLER`]: whether the user cancelled the job while it waits. +#[cfg(any(target_os = "linux", windows))] +fn cancelled(stop: &Stop) -> impl Fn() -> bool + '_ { + || stop.check().is_err_and(|e| e == crate::protocol::CANCELLED) +} + +#[cfg(target_os = "linux")] +pub(crate) mod linux { + use std::ffi::{OsStr, OsString}; + use std::os::unix::fs::PermissionsExt; + + use crate::distro; + use crate::protocol::CANCELLED; + + use super::{ + App, INSTALLER, InstallSource, Installed, JobPhase, PackageKind, Path, PathBuf, Paths, Record, SHARED_CALL, SYSTEM_BIN_DIRS, Stop, cancelled, + find_in, pkexec, process, run_as_root, swap_into_place, tree, turn, + }; + + /// apt waits this long for another package tool (a software centre, automatic updates) to + /// let go of its lock, instead of failing at once. + const APT_WAITS: &str = "DPkg::Lock::Timeout=60"; + + /// A package tool that root commands can run, from the same directories `run_as_root` uses. + fn system_tool(name: &str) -> Option { + find_in(&SYSTEM_BIN_DIRS, OsStr::new(name)) + } + + /// Where commands that need root go: the installer's queue, then `pkexec`. + pub struct Root<'a> { + pub queue: &'a crate::queue::Queue, + pub run: &'a (dyn Fn(&[&OsStr]) -> Result<(), String> + Sync), + } + + const SYSTEM: Root<'static> = Root { queue: &INSTALLER, run: &run_as_root }; + + /// Runs `command target` as root. Jobs waiting for the same command go into one call + /// (`apt-get install a.deb b.deb`), so the password is asked for once. + fn as_root( + root: &Root<'_>, + command: &[&str], + target: &OsStr, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), + working: JobPhase, + ) -> Result<(), String> { + let command = command.iter().map(OsString::from).collect(); + root.queue.batched(command, target.to_os_string(), &cancelled(stop), &mut turn(phase, working), &|command, targets| { + let args: Vec<&OsStr> = command.iter().chain(targets).map(OsString::as_os_str).collect(); + shared_outcome((root.run)(&args), targets.len()) + }) + } + + /// What one call's result means for each of the `count` apps it handled: a failure can't be + /// pinned on one of them, and trying each again by itself would ask for the password each time. + pub fn shared_outcome(result: Result<(), String>, count: usize) -> Result<(), String> { + match result { + Err(e) if count > 1 && e != CANCELLED => Err(format!("{e} while handling {count} {SHARED_CALL}")), + other => other, + } + } + + /// The command that installs (or upgrades) a downloaded system package. + pub fn install_command(kind: PackageKind) -> Option<&'static [&'static str]> { + match kind { + // apt resolves dependencies and records the package like any other. + PackageKind::Deb => Some(&["apt-get", "-o", APT_WAITS, "install", "-y", "--allow-downgrades"]), + PackageKind::Rpm if system_tool("dnf").is_some() => Some(&["dnf", "install", "-y"]), + PackageKind::Rpm if system_tool("zypper").is_some() => Some(&["zypper", "--non-interactive", "install", "--allow-unsigned-rpm"]), + PackageKind::Rpm => Some(&["rpm", "-U", "--replacepkgs"]), + PackageKind::ArchPkg => Some(&["pacman", "-U", "--noconfirm"]), + _ => None, + } + } + + /// Fedora Atomic (Silverblue, Kinoite, Bazzite…): rpm-ostree manages the root filesystem and + /// `dnf install` of a local package is refused (Bazzite wraps `/usr/bin/dnf` to say so). + fn immutable_ostree() -> bool { + Path::new("/run/ostree-booted").exists() + } + + /// What this system can install, ordered with the best default first. + /// + /// The default package format aligns with how the user installed the launcher itself: + /// - Running as a Flatpak: prefers Flatpak (per-user, desktop integration). When the host + /// has no usable Flatpak (none installed, or `flatpak-spawn` refused) it prefers the + /// release tarball instead, which the launcher unpacks and runs inside the sandbox; an + /// AppImage cannot mount there and a distro package needs root, so both are dead ends. + /// - Running as an AppImage: prefers AppImage. + /// - Otherwise (system install): prefers the distro's own package format (`.deb` on the + /// Debian family, `.rpm` on the Fedora, Red Hat and SUSE families, `.pkg.tar.zst` on Arch, + /// read from `os-release` rather than from whichever tools are on `PATH`), then Flatpak if + /// available, then AppImage. + pub fn available_kinds() -> Vec { + let flatpak = process::is_flatpak_available(); + let is_flatpak = process::in_sandbox(); + let is_appimage = std::env::var_os("APPIMAGE").is_some(); + let root = pkexec().is_some(); + let os = ["/etc/os-release", "/usr/lib/os-release"].iter().find_map(|p| std::fs::read_to_string(p).ok()).unwrap_or_default(); + let native = distro::native_package(&distro::parse_os_release(&os)); + // An ostree system can't take .deb or .rpm installs, whatever os-release says. + let root = root && !immutable_ostree(); + let can_deb = root && native == Some(PackageKind::Deb) && system_tool("apt-get").is_some() && system_tool("dpkg").is_some(); + let can_rpm = root && native == Some(PackageKind::Rpm) && system_tool("rpm").is_some(); + let can_arch = root && native == Some(PackageKind::ArchPkg) && system_tool("pacman").is_some() && crate::detect::linux::has_pacman_db(); + order_available_kinds(is_flatpak, is_appimage, flatpak, can_deb, can_rpm, can_arch) + } + + pub(crate) fn order_available_kinds( + is_flatpak: bool, + is_appimage: bool, + flatpak_available: bool, + can_deb: bool, + can_rpm: bool, + can_arch: bool, + ) -> Vec { + let mut kinds = Vec::new(); + if is_flatpak && flatpak_available { + kinds.push(PackageKind::Flatpak); + } else if is_flatpak { + kinds.push(PackageKind::TarGz); + } else if is_appimage { + kinds.push(PackageKind::AppImage); + } + + if can_deb { + kinds.push(PackageKind::Deb); + } + if can_rpm { + kinds.push(PackageKind::Rpm); + } + if can_arch { + kinds.push(PackageKind::ArchPkg); + } + + if flatpak_available && !kinds.contains(&PackageKind::Flatpak) { + kinds.push(PackageKind::Flatpak); + } + // The tarball runs inside the sandbox, so it is only ever offered there. + if is_flatpak && !kinds.contains(&PackageKind::TarGz) { + kinds.push(PackageKind::TarGz); + } + if !kinds.contains(&PackageKind::AppImage) { + kinds.push(PackageKind::AppImage); + } + kinds + } + + /// Installs (or upgrades) a downloaded package with the system's own tools. + pub fn install_package(kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + if kind == PackageKind::Deb && system_tool("apt-get").is_none() { + return Err("this system has no apt-get to install a Debian package with".into()); + } + if kind == PackageKind::ArchPkg && system_tool("pacman").is_none() { + return Err("this system has no pacman to install an Arch package with".into()); + } + install_package_via(&SYSTEM, kind, file, stop, phase) + } + + pub fn install_package_via(root: &Root<'_>, kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + let f = file.as_os_str(); + if let Some(command) = install_command(kind) { + return as_root(root, command, f, stop, phase, JobPhase::Installing); + } + match kind { + PackageKind::Flatpak => { + let install_one = || { + log::info!("executing flatpak install --reinstall --bundle for {}", file.display()); + let output = process::flatpak_command() + .args(["install", "--user", "-y", "--noninteractive", "--reinstall", "--bundle"]) + .arg(f) + .output() + .map_err(|e| format!("couldn't start Flatpak ({e})"))?; + if output.status.success() { + log::info!("flatpak install succeeded for {}", file.display()); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let err = stderr.trim(); + let err = if err.is_empty() { String::from_utf8_lossy(&output.stdout).trim().to_string() } else { err.to_string() }; + if err.is_empty() { + Err("Flatpak couldn't install the bundle (it needs the Flathub remote for its runtime)".into()) + } else { + let clean_err = err.strip_prefix("error: ").or_else(|| err.strip_prefix("Error: ")).unwrap_or(&err); + Err(format!("Flatpak couldn't install the bundle: {clean_err}")) + } + } + }; + root.queue.alone(&cancelled(stop), &mut turn(phase, JobPhase::Installing), install_one).unwrap_or(Err(CANCELLED.into())) + } + other => Err(format!("{} isn't a system package", other.label())), + } + } + + pub fn remove_package(app: &App, installed: &Installed, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + let mut remove = |command: &[&str], package: &str| as_root(&SYSTEM, command, OsStr::new(package), stop, phase, JobPhase::Removing); + match installed.source { + InstallSource::Deb if system_tool("apt-get").is_some() => remove(&["apt-get", "-o", APT_WAITS, "remove", "-y"], app.id), + InstallSource::Deb => remove(&["dpkg", "-r"], app.id), + InstallSource::Rpm => { + if system_tool("dnf").is_some() { + remove(&["dnf", "remove", "-y"], app.id) + } else { + remove(&["rpm", "-e"], app.id) + } + } + // The package detected (photocraft, photocraft-bin or photocraft-git). + InstallSource::Pacman => remove(&["pacman", "-R", "--noconfirm"], installed.package.as_deref().unwrap_or(app.id)), + InstallSource::Flatpak => { + let id = app.bundle_id(); + let user = process::flatpak_command().args(["uninstall", "--user", "-y", "--noninteractive", &id]).status(); + if user.is_ok_and(|s| s.success()) { + return Ok(()); + } + if process::in_sandbox() { + let system = process::flatpak_command().args(["uninstall", "--system", "-y", "--noninteractive", &id]).status(); + if system.is_ok_and(|s| s.success()) { + return Ok(()); + } + return Err(format!("couldn't uninstall {id}")); + } + remove(&["flatpak", "uninstall", "--system", "-y", "--noninteractive"], &id) + } + other => Err(format!("{} isn't a system package", other.label())), + } + } + + pub fn install_appimage( + file: &Path, + name: &str, + destination: Option<&Path>, + previous: Option<&Record>, + replace: bool, + ) -> Result<(PathBuf, Vec), String> { + let dir = match destination { + Some(dir) => dir.to_path_buf(), + None => { + let dir = crate::detect::linux::appimage_dir().ok_or("no home folder")?; + std::fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?; + dir + } + }; + let dest = dir.join(name); + let ours = previous.is_some_and(|r| r.kind == PackageKind::AppImage && r.path == dest); + if std::fs::symlink_metadata(&dest).is_ok_and(|m| m.is_dir() || !(replace || ours && m.is_file())) { + return Err(format!("{} already exists and wasn't installed by ArtCraft Launcher", dest.display())); + } + let staging = super::staging_dir(&dir)?; + let partial = staging.path().join(name); + std::fs::copy(file, &partial).map_err(|e| format!("couldn't copy the AppImage ({e})"))?; + // The AppImage is the program the user just installed and must be executable; 0o755 is + // not group/world-writable. + std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?; // NOSONAR + std::fs::rename(&partial, &dest).map_err(|e| format!("couldn't put the AppImage in place ({e})"))?; + let mut removed = Vec::new(); + if let Some(old) = previous.filter(|r| r.kind == PackageKind::AppImage && r.path != dest) + && std::fs::remove_file(&old.path).is_ok() + { + removed.push(old.path.clone()); + } + Ok((dest, removed)) + } + + /// Unpacks a release tarball (`bin/`, `share/…`) into `/apps/`, a folder + /// only the launcher owns, and returns the app's binary. This is the fallback for the + /// Flatpak launcher when the host has no usable Flatpak: the app then runs inside the + /// sandbox instead of on the host. `bin` and `share` are swapped in one at a time, so an + /// update leaves the app's own `home/` beside them untouched. + pub fn install_tree(app: &App, file: &Path, paths: &Paths) -> Result { + let staged = paths.cache.join(format!("{}.new", app.id)); + let result = unpack_tree(app, file, &staged, paths); + let _ = std::fs::remove_dir_all(&staged); + result + } + + fn unpack_tree(app: &App, file: &Path, staged: &Path, paths: &Paths) -> Result { + let tree = tree::unpack(app, file, staged)?; + // Verify tree root is strictly within staged and is not a symlink (CWE-59). + let rel_tree = tree.strip_prefix(staged).map_err(|_| "staging layout escapes staging".to_string())?; + let mut check_root = staged.to_path_buf(); + for part in rel_tree.components() { + check_root.push(part); + let meta = std::fs::symlink_metadata(&check_root).map_err(|e| format!("couldn't check {} ({e})", check_root.display()))?; + if meta.file_type().is_symlink() { + return Err("unpacked tree root is a symlink".into()); + } + } + // Validate the staged binary and set its mode *before* either swap: a rejected update + // (e.g. a symlinked app binary) must never disturb the installed tree it replaces. + let staged_bin = tree.join("bin").join(app.id); + make_runnable_within(&tree, &staged_bin)?; + let root = paths.apps().join(app.id); + std::fs::create_dir_all(&root).map_err(|e| format!("couldn't create {} ({e})", root.display()))?; + for dir in ["bin", "share"] { + if tree.join(dir).is_dir() { + swap_into_place(&tree.join(dir), &root.join(dir))?; + } + } + Ok(root.join("bin").join(app.id)) + } + + /// Makes `bin` executable (0755) without ever following an archive-controlled symlink out of + /// the installation tree. + /// + /// `bin` comes from an untrusted tarball. If it, or one of its ancestors inside `root`, is a + /// symlink, a plain `std::fs::set_permissions(bin, …)` would follow the link and change the + /// mode of an existing file *outside* the tree (CWE-59). We reject any symlinked component + /// before changing permissions, so the chmod can only ever reach a regular file that lives at + /// the verified path inside the launcher's own apps folder. + fn make_runnable_within(root: &Path, bin: &Path) -> Result<(), String> { + let root_meta = std::fs::symlink_metadata(root).map_err(|e| format!("couldn't check {} ({e})", root.display()))?; + if root_meta.file_type().is_symlink() { + return Err("the install tree root is a symlink".into()); + } + // No component between `root` and `bin` may be a symlink. + let rel = bin.strip_prefix(root).map_err(|_| "the app binary isn't inside the install tree".to_string())?; + let mut here = root.to_path_buf(); + for part in rel.components() { + here.push(part); + let meta = std::fs::symlink_metadata(&here).map_err(|e| format!("couldn't check {} ({e})", here.display()))?; + if meta.file_type().is_symlink() { + return Err("the download's app binary escapes the install tree through a symlink".into()); + } + } + // The final component is a verified non-symlink regular file inside the launcher's own + // apps folder, so changing its mode cannot reach anything outside the tree. + if !here.metadata().map_err(|e| e.to_string())?.file_type().is_file() { + return Err("the download's app binary isn't a regular file".into()); + } + // Verified non-symlink regular file inside the launcher's apps folder; the app binary must + // be executable and 0o755 is not group/world-writable. + std::fs::set_permissions(bin, std::fs::Permissions::from_mode(0o755)).map_err(|e| format!("couldn't make {} runnable ({e})", bin.display())) // NOSONAR + } + + /// Removes a tree the launcher unpacked in [`install_tree`], keeping the app's `home/`: the + /// settings and projects in it are the user's, not the installer's. Only the two folders the + /// launcher put there are removed, and only inside the launcher's own apps folder. + pub fn remove_tree(app: &App, paths: &Paths) -> Result<(), String> { + let root = paths.apps().join(app.id); + let mut removed = false; + for dir in ["bin", "share"] { + let path = root.join(dir); + if path.starts_with(paths.apps()) && path.is_dir() { + std::fs::remove_dir_all(&path).map_err(|e| format!("couldn't remove {} ({e})", path.display()))?; + removed = true; + } + } + if removed { Ok(()) } else { Err(format!("{} isn't installed in this folder", app.name)) } + } + + /// Where menu entries and icons go: `$XDG_DATA_HOME`, else `~/.local/share`. Inside the + /// launcher's own Flatpak that variable points into the sandbox, so the host's is used. + pub fn host_data_dir() -> Option { + if Path::new("/.flatpak-info").exists() { + if let Some(dir) = std::env::var_os("HOST_XDG_DATA_HOME").map(PathBuf::from).filter(|p| p.is_absolute()) { + return Some(dir); + } + return dirs::home_dir().map(|h| h.join(".local").join("share")); + } + dirs::data_dir() + } + + /// The app's 128 px icon, as bundled in `assets/app-icons`. + fn icon_png(app: &App) -> Option<&'static [u8]> { + app.icon_png + } + + fn desktop_path(data: &Path, app: &App) -> PathBuf { + data.join("applications").join(format!("{}.desktop", app.bundle_id())) + } + + fn icon_path(data: &Path, app: &App) -> PathBuf { + data.join("icons/hicolor/128x128/apps").join(format!("{}.png", app.bundle_id())) + } + + /// Adds `appimage` to the desktop's menu. Best effort: a failure here never fails the + /// install. Returns the files written, for the record. + pub fn integrate(app: &App, appimage: &Path, replaced: &[PathBuf], previous: Option<&Record>) -> Vec { + match host_data_dir() { + Some(data) => integrate_into(&data, app, appimage, replaced, previous), + None => Vec::new(), + } + } + + /// [`integrate`] into the data folder `data`. Only writes over files the launcher wrote + /// before: its own marked menu entry, an entry pointing at a copy this install replaced, and + /// an icon listed in `previous`. + pub(crate) fn integrate_into(data: &Path, app: &App, appimage: &Path, replaced: &[PathBuf], previous: Option<&Record>) -> Vec { + let ours_before = |p: &Path| previous.is_some_and(|r| r.files.iter().any(|f| f == p)); + let mut written = Vec::new(); + let icon = icon_path(data, app); + let has_icon = match icon_png(app) { + Some(png) if !icon.exists() || ours_before(&icon) => match crate::paths::write_atomic(&icon, png) { + Ok(()) => { + written.push(icon.clone()); + true + } + Err(e) => { + log::warn!("{}: couldn't install the menu icon: {e}", app.name); + false + } + }, + // Someone else's icon under the app's id (the app's own AppImage puts one there). + _ => icon.is_file(), + }; + let Some(text) = crate::desktop_entry::render(app, appimage, has_icon) else { + log::warn!("{}: {} can't be written into a menu entry; skipping it", app.name, appimage.display()); + return written; + }; + let desktop = desktop_path(data, app); + let replace = match std::fs::read_to_string(&desktop) { + Ok(old) => { + crate::desktop_entry::is_ours(&old) + || crate::desktop_entry::exec_program(&old) + .is_some_and(|p| Path::new(&p) == appimage || replaced.iter().any(|r| r == Path::new(&p))) + } + Err(_) => !desktop.exists(), + }; + if !replace { + log::info!("{}: leaving the existing menu entry {} alone", app.name, desktop.display()); + return written; + } + match crate::paths::write_atomic(&desktop, text.as_bytes()) { + Ok(()) => written.push(desktop), + Err(e) => log::warn!("{}: couldn't add the menu entry: {e}", app.name), + } + written + } + + /// Takes away what [`integrate`] added for the removed AppImage `appimage`. + pub fn unintegrate(app: &App, appimage: &Path, record: Option<&Record>) { + if let Some(data) = host_data_dir() { + unintegrate_from(&data, app, appimage, record); + } + } + + /// [`unintegrate`] from the data folder `data`: the menu entry when it runs the removed copy, + /// or when the launcher wrote it and what it runs is gone; the icon only when the + /// launcher's record lists it. + pub(crate) fn unintegrate_from(data: &Path, app: &App, appimage: &Path, record: Option<&Record>) { + let desktop = desktop_path(data, app); + if let Ok(text) = std::fs::read_to_string(&desktop) { + let program = crate::desktop_entry::exec_program(&text).map(PathBuf::from); + let runs_removed = program.as_deref() == Some(appimage); + let ours_and_dangling = crate::desktop_entry::is_ours(&text) && program.is_none_or(|p| !p.exists()); + if runs_removed || ours_and_dangling { + let _ = std::fs::remove_file(&desktop); + } + } + let icon = icon_path(data, app); + if record.is_some_and(|r| r.files.contains(&icon)) { + let _ = std::fs::remove_file(&icon); + } + } +} + +#[cfg(target_os = "macos")] +pub mod macos { + use std::time::Duration; + + use crate::version::Version; + + use super::{App, InstallSource, Installed, Path, PathBuf, Paths, Release, process, staging_dir, swap_into_place}; + + /// `/Applications` when we can write there, else `~/Applications`. + fn applications_dir(existing: Option<&Installed>, destination: Option<&Path>) -> Result { + if let Some(parent) = + existing.filter(|i| i.source == InstallSource::MacApp).and_then(|i| Path::new(&i.location).parent().map(Path::to_path_buf)) + { + return Ok(parent); + } + if let Some(dir) = destination { + return Ok(dir.to_path_buf()); + } + let system = PathBuf::from("/Applications"); + let probe = system.join(".artcraft-launcher-write-test"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + return Ok(system); + } + let user = dirs::home_dir().ok_or("no home folder")?.join("Applications"); + std::fs::create_dir_all(&user).map_err(|e| format!("couldn't create {} ({e})", user.display()))?; + Ok(user) + } + + /// The Apple Developer team that signs every Craft App and ArtCraft Launcher (Learning + /// Machines LLC): the macOS side of the Windows publisher check. + pub const TEAM_ID: &str = "DJ6XS33FX8"; + + /// What a new copy must prove before it replaces anything. The checksum shows the download is + /// the file the release lists; these show the app inside is the one the release promises. + pub struct Expect<'a> { + /// The team whose Developer ID signature the bundle must carry. `None` only in tests, + /// which build unsigned bundles. + pub team: Option<&'a str>, + /// The release's version, which the bundle must report. `None` when the tag isn't one. + pub version: Option<&'a Version>, + } + + impl<'a> Expect<'a> { + pub fn release(release: &'a Release) -> Self { + Self { team: Some(TEAM_ID), version: release.version.as_ref() } + } + } + + /// Held while this process asks diskimages-helper for something (an attach here; the tests' + /// `hdiutil create` too), since it turns concurrent requests away. + pub(crate) static DISK_IMAGES: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Apple's tools, by full path: a launcher started from Finder or at login gets a minimal + /// `PATH`, and nothing earlier on it should stand in for these. + const HDIUTIL: &str = "/usr/bin/hdiutil"; + const DITTO: &str = "/usr/bin/ditto"; + const CODESIGN: &str = "/usr/bin/codesign"; + + pub fn install_dmg( + app: &App, + dmg: &Path, + existing: Option<&Installed>, + destination: Option<&Path>, + replace: bool, + paths: &Paths, + expect: &Expect<'_>, + ) -> Result { + // A new, empty mount point for every install. A fixed one can still hold an image from an + // attach that was interrupted or timed out, and hdiutil won't mount another over it. + let attempt = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let mount = paths.cache.join(format!("mount-{}-{}-{attempt}", app.id, std::process::id())); + std::fs::create_dir_all(&mount).map_err(|e| format!("couldn't create a folder to open the disk image in ({e})"))?; + let (Some(dmg_s), Some(mount_s)) = (dmg.to_str(), mount.to_str()) else { + let _ = std::fs::remove_dir(&mount); + return Err("unusual characters in the cache folder's path".into()); + }; + // diskimages-helper refuses an attach while it is busy with another image ("Resource + // temporarily unavailable"): attach one image at a time (several apps may be updating at + // once), and wait a little and try again when something outside the launcher holds it. + // The download's SHA-256 is already checked, so hdiutil's own checksum pass (-noverify) + // would only add minutes on a large image. + let attached = { + let _one_at_a_time = DISK_IMAGES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let attach = || { + process::run( + HDIUTIL, + &["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", mount_s, dmg_s], + Duration::from_secs(300), + ) + }; + let mut attached = attach(); + for wait in [1, 2, 4, 8] { + if attached.is_ok() { + break; + } + let _ = detach(&mount); + std::thread::sleep(Duration::from_secs(wait)); + attached = attach(); + } + attached + }; + if let Err(reason) = attached { + let _ = std::fs::remove_dir(&mount); + return Err(format!("couldn't open the disk image: {reason}")); + } + let result = copy_app(app, &mount, existing, destination, replace, expect); + let _ = detach(&mount); + let _ = std::fs::remove_dir(&mount); + result + } + + fn copy_app( + app: &App, + mount: &Path, + existing: Option<&Installed>, + destination: Option<&Path>, + replace: bool, + expect: &Expect<'_>, + ) -> Result { + let bundle = format!("{}.app", app.name); + let src = mount.join(&bundle); + crate::detect::macos::read_bundle(app, &src).ok_or("the disk image doesn't contain the app")?; + let dir = applications_dir(existing, destination)?; + let dest = existing.filter(|i| i.source == InstallSource::MacApp).map_or_else(|| dir.join(&bundle), |i| PathBuf::from(&i.location)); + if !replace + && std::fs::symlink_metadata(&dest).is_ok() + && (std::fs::symlink_metadata(&dest).is_ok_and(|m| m.file_type().is_symlink()) || crate::detect::macos::read_bundle(app, &dest).is_none()) + { + return Err(format!("{} already exists and wasn't installed by ArtCraft Launcher", dest.display())); + } + let staging = super::staging_dir(&dir)?; + let staged = staging.path().join(&bundle); + let (Some(src_s), Some(staged_s)) = (src.to_str(), staged.to_str()) else { return Err("unusual characters in a path".into()) }; + // ditto keeps the code signature and symlinks intact. The disk image's volume adds Finder + // metadata (com.apple.FinderInfo) to the files; leave it and the extended attributes + // behind, or `codesign --verify --strict` rejects the bundle as carrying "detritus". A + // bundle's signature lives in its files, not in extended attributes. + process::run(DITTO, &["--norsrc", "--noextattr", "--noacl", src_s, staged_s], Duration::from_secs(600)) + .map_err(|reason| format!("couldn't copy the app: {reason}"))?; + let checked = crate::detect::macos::read_bundle(app, &staged) + .ok_or_else(|| "the copied app is incomplete".to_string()) + .and_then(|found| check(&staged, &found, expect)); + checked?; + swap_into_place(&staged, &dest)?; + Ok(dest) + } + + /// Checks a copied bundle against what its release promises, before it replaces anything. + fn check(bundle: &Path, found: &Installed, expect: &Expect<'_>) -> Result<(), String> { + if let Some(team) = expect.team + && !signed_by(bundle, team) + { + return Err("the app isn't signed by its developer, so it wasn't installed".into()); + } + if let Some(want) = expect.version + && !found.version.as_ref().is_some_and(|v| same_release(v, want)) + { + return Err(format!("the app inside isn't version {want}, so it wasn't installed")); + } + Ok(()) + } + + /// Whether `bundle` has a valid Developer ID signature from `team`, nested code included. The + /// requirement pins the signer, so a valid ad-hoc or other developer's signature fails it. + /// Unlike a Gatekeeper assessment it needs no network. + pub fn signed_by(bundle: &Path, team: &str) -> bool { + let Some(path) = bundle.to_str() else { return false }; + if team.is_empty() || !team.bytes().all(|b| b.is_ascii_alphanumeric()) { + return false; + } + let requirement = format!("=anchor apple generic and certificate leaf[subject.OU] = \"{team}\""); + process::capture(CODESIGN, &["--verify", "--deep", "-R", &requirement, path], Duration::from_secs(120)).is_some() + } + + /// Whether a bundle's version is the release's. A bundle that reports only the numbers + /// (`CFBundleShortVersionString`) matches a pre-release with the same numbers. + fn same_release(found: &Version, release: &Version) -> bool { + (found.major, found.minor, found.patch) == (release.major, release.minor, release.patch) && (found.pre.is_empty() || found.pre == release.pre) + } + + fn detach(mount: &Path) -> Option { + let m = mount.to_str()?; + if !mount.join(".").exists() { + return None; + } + process::capture(HDIUTIL, &["detach", m, "-quiet"], Duration::from_secs(60)) + .or_else(|| process::capture(HDIUTIL, &["detach", m, "-force", "-quiet"], Duration::from_secs(60))) + } + + /// Moves an app to the user's Trash (so it can be put back). + pub fn move_to_trash(path: &Path) -> Result<(), String> { + let trash = dirs::home_dir().ok_or("no home folder")?.join(".Trash"); + let name = path.file_name().ok_or("not an app")?; + let mut dest = trash.join(name); + if dest.exists() { + dest = trash.join(format!("{} {}", name.to_string_lossy(), crate::now())); + } + std::fs::rename(path, &dest).map_err(|e| format!("couldn't move {} to Trash ({e}). Remove it in Finder instead.", path.display())) + } +} + +/// FreeBSD: the release tarball holds a `/usr/local`-style tree (`bin/`, +/// `share/applications`, icons). Older releases had the files flat in one folder; those are laid +/// out the same way first. +#[cfg(unix)] +#[allow(dead_code)] +mod tree { + use super::{App, Path, PathBuf, single_top_folder}; + + /// Unpacks `file` and returns a staging folder laid out like `/usr/local`. + pub fn unpack(app: &App, file: &Path, staging: &Path) -> Result { + let _ = std::fs::remove_dir_all(staging); + std::fs::create_dir_all(staging).map_err(|e| format!("couldn't create {} ({e})", staging.display()))?; + let gz = std::fs::File::open(file).map_err(|e| e.to_string())?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(gz)); + archive.set_preserve_permissions(true); + // Bound entry count and cumulative unpacked bytes to prevent decompression bombs (CWE-409). + const MAX_ENTRIES: usize = 50_000; + const MAX_UNPACKED_BYTES: u64 = 2 * 1024 * 1024 * 1024; + let mut entry_count = 0; + let mut total_unpacked = 0u64; + let mut directories = Vec::new(); + let entries = archive.entries().map_err(|e| format!("couldn't read archive entries ({e})"))?; + for entry in entries { + let mut entry = entry.map_err(|e| format!("invalid tar entry ({e})"))?; + entry_count += 1; + if entry_count > MAX_ENTRIES { + return Err("archive has too many entries".into()); + } + let size = entry.size(); + total_unpacked = total_unpacked.saturating_add(size); + if total_unpacked > MAX_UNPACKED_BYTES { + return Err("archive exceeds maximum unpacked size".into()); + } + if entry.header().entry_type().is_dir() { + directories.push(entry); + } else { + entry.unpack_in(staging).map_err(|e| format!("couldn't unpack archive entry ({e})"))?; + } + } + // Delay directory entries until child entries are written so restrictive modes do not break child extraction. + directories.sort_by(|a, b| b.path_bytes().cmp(&a.path_bytes())); + for mut dir in directories { + dir.unpack_in(staging).map_err(|e| format!("couldn't unpack archive directory ({e})"))?; + } + let top = single_top_folder(staging)?; + if top.join("bin").join(app.id).is_file() { + return Ok(top); + } + // Flat layout: put each file where /usr/local keeps it. + // Ensure tree is a fresh directory and not an archive-created symlink or directory (CWE-59). + let tree = staging.join("tree"); + if let Ok(m) = std::fs::symlink_metadata(&tree) { + if m.file_type().is_symlink() || !m.is_dir() { + let _ = std::fs::remove_file(&tree); + } else { + let _ = std::fs::remove_dir_all(&tree); + } + } + std::fs::create_dir_all(&tree).map_err(|e| e.to_string())?; + let id = app.bundle_id(); + for (from, to) in [ + (top.join(app.id), tree.join("bin").join(app.id)), + (top.join(format!("{}-cli", app.id)), tree.join("bin").join(format!("{}-cli", app.id))), + (top.join(format!("{id}.desktop")), tree.join("share/applications").join(format!("{id}.desktop"))), + (top.join(format!("{id}.png")), tree.join("share/pixmaps").join(format!("{id}.png"))), + ] { + if std::fs::symlink_metadata(&from).is_ok_and(|m| m.file_type().is_file()) { + if let Some(dir) = to.parent() { + let rel = dir.strip_prefix(&tree).map_err(|_| "destination escapes staging tree".to_string())?; + let mut check = tree.clone(); + for part in rel.components() { + check.push(part); + if let Ok(m) = std::fs::symlink_metadata(&check) + && m.file_type().is_symlink() + { + return Err("destination parent directory is a symlink".into()); + } + } + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::rename(&from, &to).map_err(|e| e.to_string())?; + } + } + if !tree.join("bin").join(app.id).is_file() { + return Err("the download doesn't contain the app".into()); + } + Ok(tree) + } + + /// Every file under `root`, relative to it. Does not descend through symlinks (CWE-59, CWE-400). + pub fn files(root: &Path) -> Result, String> { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + let mut visited = 0; + const MAX_FILES: usize = 50_000; + while let Some(dir) = stack.pop() { + let entries = std::fs::read_dir(&dir).map_err(|e| format!("couldn't read directory {}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("couldn't read directory entry: {e}"))?; + visited += 1; + if visited > MAX_FILES { + return Err("directory tree exceeds maximum file limit of 50,000".into()); + } + let ft = entry.file_type().map_err(|e| format!("couldn't read file type: {e}"))?; + let p = entry.path(); + if ft.is_dir() && !ft.is_symlink() { + stack.push(p); + } else { + let rel = p.strip_prefix(root).map_err(|e| e.to_string())?; + out.push(rel.to_path_buf()); + } + } + } + out.sort(); + Ok(out) + } +} + +#[cfg(target_os = "freebsd")] +mod freebsd { + use std::ffi::OsStr; + + use super::{App, Path, PathBuf, Paths, pkexec, process, run_as_root, tree}; + + /// Copies the release's tree into `/usr/local` (asking for permission through polkit), or + /// into `~/.local` when polkit isn't installed. Returns the binary, every file installed, + /// and whether removing them needs root. + pub fn install_tree(app: &App, file: &Path, paths: &Paths) -> Result<(PathBuf, Vec, bool), String> { + let staging = paths.cache.join(format!("{}.new", app.id)); + let result = install_from(app, file, &staging); + let _ = std::fs::remove_dir_all(&staging); + result + } + + fn install_from(app: &App, file: &Path, staging: &Path) -> Result<(PathBuf, Vec, bool), String> { + let tree = tree::unpack(app, file, staging)?; + let rel = tree::files(&tree)?; + let mut source = tree.into_os_string(); + source.push("/."); + if pkexec().is_some() { + let prefix = PathBuf::from("/usr/local"); + run_as_root(&[OsStr::new("/bin/cp"), OsStr::new("-R"), &source, prefix.as_os_str()])?; + return Ok((prefix.join("bin").join(app.id), rel.iter().map(|r| prefix.join(r)).collect(), true)); + } + let prefix = dirs::home_dir().ok_or("no home folder")?.join(".local"); + std::fs::create_dir_all(&prefix).map_err(|e| e.to_string())?; + let status = process::command("/bin/cp").arg("-R").arg(&source).arg(&prefix).status().map_err(|e| e.to_string())?; + if !status.success() { + return Err("couldn't copy the app into ~/.local".into()); + } + // ~/.local/bin is often not on PATH for the desktop: point the menu entry at the binary. + let bin = prefix.join("bin").join(app.id); + let desktop = prefix.join("share/applications").join(format!("{}.desktop", app.bundle_id())); + if let Ok(text) = std::fs::read_to_string(&desktop) { + let exec = format!("Exec=\"{}\"", bin.display()); + let fixed: String = text + .lines() + .map(|l| { + if let Some(rest) = l.strip_prefix("Exec=").and_then(|r| r.strip_prefix(app.id)) { + format!("{exec}{rest}\n") + } else { + format!("{l}\n") + } + }) + .collect(); + let _ = std::fs::write(&desktop, fixed); + } + Ok((bin, rel.iter().map(|r| prefix.join(r)).collect(), false)) + } + + /// Removes the files a tree install put down. + pub fn remove_files(files: &[PathBuf], needs_root: bool) -> Result<(), String> { + if files.is_empty() { + return Err("the launcher has no record of these files".into()); + } + let prefix = if needs_root { PathBuf::from("/usr/local") } else { dirs::home_dir().ok_or("no home folder")?.join(".local") }; + let mut safe_files = Vec::new(); + for f in files { + // Refuse paths that climb or escape install prefix (CWE-59). + if !f.starts_with(&prefix) || f.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + return Err(format!("refusing to remove file outside install prefix: {}", f.display())); + } + let mut has_symlink_ancestor = false; + if let Ok(rel) = f.strip_prefix(&prefix) { + let mut check = prefix.clone(); + for part in rel.components() { + if let Ok(meta) = std::fs::symlink_metadata(&check) + && meta.file_type().is_symlink() + { + has_symlink_ancestor = true; + break; + } + check.push(part); + } + } + if !has_symlink_ancestor { + safe_files.push(f); + } + } + if safe_files.is_empty() { + return Ok(()); + } + if needs_root { + let mut args: Vec<&OsStr> = vec![OsStr::new("/bin/rm"), OsStr::new("-f"), OsStr::new("--")]; + args.extend(safe_files.iter().map(|f| f.as_os_str())); + return run_as_root(&args); + } + for f in safe_files { + let _ = std::fs::remove_file(f); + } + Ok(()) + } +} + +/// Builds the Windows Installer command separately so its package argument can be checked +/// without starting an installer. +#[cfg_attr(not(windows), allow(dead_code))] +fn msi_command(program: &Path, msi: &Path) -> Result { + let msi = msi_path(msi)?; + let mut command = process::command(program); + // A verbose log beside the package: Windows Installer's own errors (1603 above all) say + // nothing without it. + command.arg("/i").arg(&msi).args(["/passive", "/norestart", "/l*v"]).arg(msi_log(&msi)); + Ok(command) +} + +/// Where `msi_command` asks Windows Installer to write its log. +#[cfg_attr(not(windows), allow(dead_code))] +fn msi_log(msi: &Path) -> PathBuf { + msi.with_extension("log") +} + +/// Windows Installer's storage APIs can fail on paths through junctions or symlinks (#338). +/// Keep ordinary drive/UNC syntax when possible: Windows canonicalization alone introduces +/// a `\\?\` prefix that older APIs also cannot handle. +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn msi_path(msi: &Path) -> Result { + #[cfg(windows)] + let resolved = dunce::canonicalize(msi); + #[cfg(not(windows))] + let resolved = std::fs::canonicalize(msi); + resolved.map_err(|e| format!("couldn't resolve the Windows Installer package {} ({e})", msi.display())) +} + +#[cfg(windows)] +pub mod windows { + use crate::protocol::CANCELLED; + + use super::{App, INSTALLER, JobPhase, Path, PathBuf, Stop, cancelled, msi_command, msi_folder_argument, msi_log, process, turn}; + + fn msiexec() -> PathBuf { + std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from).join(r"System32\msiexec.exe") + } + + fn explain(code: Option) -> Result<(), String> { + match code { + Some(0 | 3010 | 1641) => Ok(()), + Some(1602) => Err("cancelled".into()), + Some(1618) => Err("another installation is in progress. Wait for it to finish, then try again.".into()), + Some(1925 | 1303) => Err("Windows didn't allow the install (administrator permission is needed)".into()), + Some(c) => Err(format!("the Windows Installer stopped with error {c}")), + None => Err("the Windows Installer was interrupted".into()), + } + } + + /// Runs an MSI with a progress window and no restart. Windows asks for permission itself. + pub fn run_msi(msi: &Path, destination: Option<&Path>, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + in_turn(stop, phase, JobPhase::Installing, || { + use std::os::windows::process::CommandExt; + let mut command = msi_command(&msiexec(), msi)?; + if let Some(dir) = destination { + command.raw_arg(msi_folder_argument(dir)?); + } + let status = command.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?; + explain(status.code()).map_err(|e| match status.code() { + Some(1602) | None => e, + _ => format!("{e}. Its log is at {}", msi_log(msi).display()), + }) + }) + } + + /// Runs the launcher's own new MSI for a self-update, with the same progress window and no + /// restart as [`run_msi`], and waits for it. + pub fn reinstall(msi: &Path) -> Result<(), String> { + let status = msi_command(&msiexec(), msi)?.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?; + explain(status.code()) + } + + /// Runs `work` once no other Windows Installer job of ours is running (a second one would + /// stop with error 1618). Cancelled while it waits, it never runs. + fn in_turn(stop: &Stop, phase: &mut dyn FnMut(JobPhase), working: JobPhase, work: impl FnOnce() -> Result<(), String>) -> Result<(), String> { + INSTALLER.alone(&cancelled(stop), &mut turn(phase, working), work).unwrap_or_else(|| Err(CANCELLED.into())) + } + + pub fn uninstall_msi(app: &App, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + let installs = crate::detect::windows::msi_installs(app); + if installs.is_empty() { + return Err(format!("{} isn't registered with the Windows Installer", app.name)); + } + in_turn(stop, phase, JobPhase::Removing, || { + for m in installs { + let status = process::command(msiexec()) + .args(["/x", &m.product_code, "/passive", "/norestart"]) + .status() + .map_err(|e| format!("couldn't start the Windows Installer ({e})"))?; + explain(status.code())?; + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn msi_install_uses_the_package_target_instead_of_a_symlinked_cache() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("persistent cache"); + std::fs::create_dir(&cache).unwrap(); + let file = cache.join("PhotoCraft.msi"); + std::fs::write(&file, b"test package").unwrap(); + let link = dir.path().join("current cache"); + std::os::unix::fs::symlink(&cache, &link).unwrap(); + let command = msi_command(Path::new("msiexec.exe"), &link.join("PhotoCraft.msi")).unwrap(); + let args: Vec<_> = command.get_args().collect(); + assert_eq!( + args, + [ + std::ffi::OsStr::new("/i"), + file.canonicalize().unwrap().as_os_str(), + std::ffi::OsStr::new("/passive"), + std::ffi::OsStr::new("/norestart"), + std::ffi::OsStr::new("/l*v"), + file.canonicalize().unwrap().with_extension("log").as_os_str() + ] + ); + } + + #[test] + fn an_unavailable_msi_is_refused_before_starting_the_installer() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing.msi"); + let error = msi_command(Path::new("msiexec.exe"), &missing).unwrap_err(); + assert!(error.contains("couldn't resolve the Windows Installer package"), "{error}"); + } + + #[cfg(windows)] + #[test] + fn a_short_local_msi_path_keeps_ordinary_windows_syntax() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("package.msi"); + std::fs::write(&file, b"test package").unwrap(); + let resolved = msi_path(&file).unwrap(); + assert!(!resolved.as_os_str().to_string_lossy().starts_with(r"\\?\"), "{resolved:?}"); + assert_eq!(std::fs::read(resolved).unwrap(), b"test package"); + } + + #[test] + fn selected_folders_must_exist_and_leave_no_probe_files() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(selected_folder(dir.path().to_str().unwrap(), true).unwrap(), dir.path()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + #[cfg(unix)] + { + let quoted = dir.path().join("Craft \"Apps\""); + std::fs::create_dir("ed).unwrap(); + assert_eq!(selected_folder(quoted.to_str().unwrap(), true).unwrap(), quoted); + } + let missing = dir.path().join("unavailable/Apps"); + assert!(selected_folder(missing.to_str().unwrap(), true).is_err()); + assert!(!missing.exists()); + assert!(selected_folder("relative/folder", true).is_err()); + assert!(selected_folder("bad\nfolder", true).is_err()); + let app = App::by_id("photocraft").unwrap(); + let locations = InstallLocations { root: Some(dir.path().display().to_string()), ..Default::default() }; + assert_eq!(destination(app, PackageKind::Msi, None, None, &locations).unwrap(), Some(dir.path().join("PhotoCraft"))); + assert_eq!(destination(app, PackageKind::PortableZip, None, None, &locations).unwrap(), Some(dir.path().to_path_buf())); + let old = record("0.5.0", PackageKind::PortableZip, dir.path().join("PhotoCraft/photocraft.exe"), vec![], false); + let unavailable = InstallLocations { root: Some(dir.path().join("missing").display().to_string()), ..Default::default() }; + assert_eq!(destination(app, PackageKind::PortableZip, None, Some(&old), &unavailable).unwrap(), None); + let msi = Installed { + version: None, + source: InstallSource::Msi, + location: dir.path().join("MSI/PhotoCraft/photocraft.exe").display().to_string(), + package: None, + }; + assert_eq!(destination(app, PackageKind::Msi, Some(&msi), Some(&old), &unavailable).unwrap(), Some(dir.path().join("MSI/PhotoCraft"))); + assert!(destination(app, PackageKind::TarGz, None, None, &locations).unwrap_err().contains("aren't supported")); + } + + #[test] + fn updates_stay_beside_the_launchers_copy_and_new_installs_use_the_chosen_folder() { + let dir = tempfile::tempdir().unwrap(); + let app = App::by_id("photocraft").unwrap(); + let path = dir.path().join("old.AppImage"); + let rec = record("0.5.0", PackageKind::AppImage, path.clone(), vec![], false); + let launcher = Installed { version: None, source: InstallSource::Launcher, location: path.display().to_string(), package: None }; + let users = Installed { version: None, source: InstallSource::AppImage, location: "/home/me/AppImages/p.AppImage".into(), package: None }; + let chosen = dir.path().join("New apps"); + std::fs::create_dir(&chosen).unwrap(); + let mut locations = InstallLocations { root: Some(chosen.display().to_string()), ..Default::default() }; + assert_eq!(destination(app, PackageKind::AppImage, Some(&launcher), Some(&rec), &locations).unwrap(), Some(dir.path().into())); + assert_eq!(destination(app, PackageKind::AppImage, Some(&users), None, &locations).unwrap(), None); + if !process::in_sandbox() { + assert_eq!(destination(app, PackageKind::AppImage, None, None, &locations).unwrap(), Some(chosen.clone())); + locations.per_app.insert(app.id.into(), dir.path().display().to_string()); + assert_eq!(destination(app, PackageKind::Dmg, None, None, &locations).unwrap(), Some(dir.path().into())); + } + } + + #[test] + fn msi_folder_assignment_preserves_spaces_unicode_and_root_separators() { + for (value, argument) in [ + (r"D:\", r#"INSTALLFOLDER="D:\""#), + (r"D:\Craft Apps\PhotoCraft", r#"INSTALLFOLDER="D:\Craft Apps\PhotoCraft""#), + (r"\\server\apps\绘画", r#"INSTALLFOLDER="\\server\apps\绘画""#), + (r"D:\Craft Apps\", r#"INSTALLFOLDER="D:\Craft Apps\""#), + ] { + assert_eq!(msi_folder_argument(Path::new(value)).unwrap(), argument); + } + assert!(msi_folder_argument(Path::new("D:\\bad\" TRANSFORMS=evil")).is_err()); + assert!(msi_folder_argument(Path::new("D:\\bad\nfolder")).is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn appimage_updates_replace_only_the_recorded_file_in_the_same_folder() { + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("Craft Apps"); + std::fs::create_dir(&folder).unwrap(); + let download = dir.path().join("download"); + std::fs::write(&download, "first").unwrap(); + let unrelated = folder.join("photocraft-unrelated.AppImage"); + std::fs::write(&unrelated, "user copy").unwrap(); + let (old, removed) = linux::install_appimage(&download, "photocraft-0.5.0.AppImage", Some(&folder), None, false).unwrap(); + assert!(removed.is_empty()); + assert_eq!(std::fs::read(&old).unwrap(), b"first"); + let rec = record("0.5.0", PackageKind::AppImage, old.clone(), vec![], false); + std::fs::write(&download, "updated").unwrap(); + let (new, removed) = linux::install_appimage(&download, "photocraft-0.6.0.AppImage", Some(&folder), Some(&rec), false).unwrap(); + assert_eq!(new, folder.join("photocraft-0.6.0.AppImage")); + assert_eq!(std::fs::read(&new).unwrap(), b"updated"); + assert_eq!(removed.as_slice(), std::slice::from_ref(&old)); + assert!(!old.exists()); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy"); + assert!(linux::install_appimage(&download, "photocraft-unrelated.AppImage", Some(&folder), Some(&rec), false).is_err()); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy"); + let new_rec = record("0.6.0", PackageKind::AppImage, new.clone(), vec![], false); + assert!(linux::install_appimage(&download, "photocraft-0.6.0.AppImage", Some(&folder), Some(&new_rec), false).is_ok()); + assert_eq!(std::fs::read_dir(&folder).unwrap().count(), 2); + let link = folder.join("linked.AppImage"); + std::os::unix::fs::symlink(&unrelated, &link).unwrap(); + let link_rec = record("0.6.0", PackageKind::AppImage, link, vec![], false); + assert!(linux::install_appimage(&download, "linked.AppImage", Some(&folder), Some(&link_rec), false).is_err()); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy"); + } + + #[cfg(target_os = "linux")] + #[test] + fn an_appimage_writes_over_a_users_file_only_when_asked_to() { + let dir = tempfile::tempdir().unwrap(); + let download = dir.path().join("download"); + std::fs::write(&download, "new").unwrap(); + let folder = dir.path().join("Apps"); + std::fs::create_dir(&folder).unwrap(); + let theirs = folder.join("photocraft.AppImage"); + let personal = dir.path().join("personal.txt"); + std::fs::write(&theirs, "user copy").unwrap(); + std::fs::write(&personal, "user file").unwrap(); + std::os::unix::fs::symlink(&personal, folder.join(".photocraft.AppImage.partial")).unwrap(); + assert!(linux::install_appimage(&download, "photocraft.AppImage", Some(&folder), None, false).is_err()); + assert_eq!(std::fs::read(&theirs).unwrap(), b"user copy"); + linux::install_appimage(&download, "photocraft.AppImage", Some(&folder), None, true).unwrap(); + assert_eq!(std::fs::read(&theirs).unwrap(), b"new"); + assert_eq!(std::fs::read(&personal).unwrap(), b"user file"); + assert_eq!(std::fs::read_dir(&folder).unwrap().count(), 2, "staging was left behind"); + } + + #[test] + fn a_new_install_reports_what_it_would_write_over() { + let dir = tempfile::tempdir().unwrap(); + let app = App::by_id("photocraft").unwrap(); + let paths = Paths::under(dir.path()); + let locations = InstallLocations { root: Some(dir.path().display().to_string()), ..Default::default() }; + let asset = Asset { name: "photocraft.zip".into(), ..Default::default() }; + let occupied = |previous| occupied(app, &asset, PackageKind::PortableZip, None, previous, &locations, &paths).unwrap(); + assert_eq!(occupied(None), None); + std::fs::create_dir(dir.path().join("photocraft")).unwrap(); + assert_eq!(occupied(None), Some(dir.path().join("photocraft"))); + let old = record("0.5.0", PackageKind::PortableZip, dir.path().join("photocraft/photocraft.exe"), vec![], false); + assert_eq!(occupied(Some(&old)), None); + } + + #[test] + fn swap_keeps_the_old_copy_until_the_new_one_is_in_place() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("app"); + let staged = dir.path().join("app.new"); + std::fs::create_dir(&dest).unwrap(); + std::fs::write(dest.join("v"), "old").unwrap(); + std::fs::create_dir(&staged).unwrap(); + std::fs::write(staged.join("v"), "new").unwrap(); + swap_into_place(&staged, &dest).unwrap(); + assert_eq!(std::fs::read_to_string(dest.join("v")).unwrap(), "new"); + assert!(!staged.exists() && !dir.path().join("app.old").exists()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + // A missing staged copy leaves the installed one alone. + assert!(swap_into_place(&staged, &dest).is_err()); + assert_eq!(std::fs::read_to_string(dest.join("v")).unwrap(), "new"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + + #[test] + fn swap_preserves_an_unrelated_backup_file() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("app"); + let staged = dir.path().join("app.new"); + let unrelated = dest.with_extension("old"); + std::fs::write(&dest, b"installed app").unwrap(); + std::fs::write(&staged, b"updated app").unwrap(); + std::fs::write(&unrelated, b"user backup").unwrap(); + swap_into_place(&staged, &dest).unwrap(); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"user backup"); + assert_eq!(std::fs::read(&dest).unwrap(), b"updated app"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2); + } + + #[test] + fn swap_preserves_an_unrelated_backup_directory() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("app"); + let staged = dir.path().join("app.new"); + let unrelated = dest.with_extension("old"); + std::fs::create_dir(&dest).unwrap(); + std::fs::write(dest.join("v"), b"installed app").unwrap(); + std::fs::create_dir(&staged).unwrap(); + std::fs::write(staged.join("v"), b"updated app").unwrap(); + std::fs::create_dir(&unrelated).unwrap(); + std::fs::write(unrelated.join("keep.txt"), b"user backup").unwrap(); + swap_into_place(&staged, &dest).unwrap(); + assert_eq!(std::fs::read(unrelated.join("keep.txt")).unwrap(), b"user backup"); + assert_eq!(std::fs::read(dest.join("v")).unwrap(), b"updated app"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2); + } + + #[cfg(unix)] + #[test] + fn swap_preserves_a_dangling_backup_symlink() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("app"); + let staged = dir.path().join("app.new"); + let unrelated = dest.with_extension("old"); + let target = dir.path().join("missing"); + std::fs::write(&dest, b"installed app").unwrap(); + std::fs::write(&staged, b"updated app").unwrap(); + std::os::unix::fs::symlink(&target, &unrelated).unwrap(); + swap_into_place(&staged, &dest).unwrap(); + assert_eq!(std::fs::read_link(&unrelated).unwrap(), target); + assert_eq!(std::fs::read(&dest).unwrap(), b"updated app"); + } + + #[test] + fn a_failed_rollback_keeps_the_old_app_and_reports_its_backup_path() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("app"); + let staged = dir.path().join("app.new"); + std::fs::write(&dest, b"installed app").unwrap(); + std::fs::write(&staged, b"updated app").unwrap(); + let calls = std::cell::Cell::new(0); + let err = swap_into_place_via(&staged, &dest, |from, to| { + calls.set(calls.get() + 1); + match calls.get() { + 1 => std::fs::rename(from, to), + 2 => Err(std::io::Error::other("staged move failed")), + _ => Err(std::io::Error::other("restore failed")), + } + }) + .unwrap_err(); + assert_eq!(calls.get(), 3); + assert!(err.contains("staged move failed") && err.contains("restore failed")); + let backup = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_dir()).unwrap(); + let saved = backup.join("old"); + assert_eq!(std::fs::read(&saved).unwrap(), b"installed app"); + assert!(err.contains(&saved.display().to_string())); + assert_eq!(std::fs::read(&staged).unwrap(), b"updated app"); + } + + #[test] + fn a_failed_install_keeps_the_download_for_the_next_try() { + use crate::http::tests::{Reply, body, serve}; + let base = App::by_id("wordcraft").unwrap(); + let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base }; + let app = &wordcraft; + let (body, sha) = body(); + let (url, requests) = serve(body.clone(), vec![Reply::Full, Reply::Full]); + let asset = Asset { name: "wordcraft-0.3.0-test.pkg".into(), size: body.len() as u64, url, sha256: Some(sha) }; + let release = Release { assets: vec![asset.clone()], ..Release::default() }; + let dir = tempfile::tempdir().unwrap(); + let paths = Paths::under(dir.path()); + let http = Http::new(crate::TEST_AGENT).unwrap(); + // A format no build of the launcher installs here stands in for any failing install step. + let kind = if cfg!(windows) { PackageKind::Dmg } else { PackageKind::Msi }; + let mut phases = Vec::new(); + for _ in 0..2 { + let mut phase = |p: JobPhase| phases.push(p); + let mut job = Job { + http: &http, + paths: &paths, + stop: &Stop::default(), + phase: &mut phase, + install_locations: &InstallLocations::default(), + replace: false, + }; + let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err(); + assert!(err.contains("isn't supported"), "{err}"); + assert_eq!(std::fs::read(download_path(&paths, &asset)).unwrap(), body); + } + assert_eq!(requests.lock().unwrap().len(), 1, "the second try reuses the kept file"); + assert_eq!(phases.iter().filter(|p| **p == JobPhase::Installing).count(), 2); + } + + #[test] + fn an_unavailable_custom_folder_is_rejected_before_downloading() { + use crate::http::tests::{Reply, body, serve}; + let base = App::by_id("wordcraft").unwrap(); + let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base }; + let app = &wordcraft; + let (body, sha) = body(); + let (url, requests) = serve(body.clone(), vec![Reply::Full]); + let asset = Asset { name: "wordcraft-0.3.0-linux-x86_64.AppImage".into(), size: body.len() as u64, url, sha256: Some(sha) }; + let release = Release { assets: vec![asset.clone()], ..Release::default() }; + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("unavailable/Apps"); + let locations = InstallLocations { root: Some(missing.display().to_string()), ..Default::default() }; + let paths = Paths::under(dir.path()); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let mut phases = vec![]; + let mut phase = |p| phases.push(p); + let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase, install_locations: &locations, replace: false }; + let error = install(app, &release, &asset, PackageKind::AppImage, None, None, &mut job).unwrap_err(); + assert!(error.contains("folder is unavailable") || (process::in_sandbox() && error.contains("aren't supported")), "{error}"); + assert!(requests.lock().unwrap().is_empty()); + assert!(phases.is_empty()); + assert!(!missing.exists()); + } + + #[test] + fn a_release_without_a_checksum_is_refused_before_anything_is_fetched() { + use crate::http::tests::{Reply, body, serve}; + let base = App::by_id("wordcraft").unwrap(); + let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base }; + let app = &wordcraft; + let (body, _) = body(); + let (url, requests) = serve(body.clone(), vec![Reply::Full]); + // Neither a per-file digest nor a SHA256SUMS.txt in the release. + let asset = Asset { name: "wordcraft-0.3.1-test.pkg".into(), size: body.len() as u64, url, sha256: None }; + let release = Release { assets: vec![asset.clone()], ..Release::default() }; + let dir = tempfile::tempdir().unwrap(); + let paths = Paths::under(dir.path()); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let kind = if cfg!(windows) { PackageKind::Dmg } else { PackageKind::Msi }; + let mut phases = Vec::new(); + let mut phase = |p: JobPhase| phases.push(p); + let mut job = Job { + http: &http, + paths: &paths, + stop: &Stop::default(), + phase: &mut phase, + install_locations: &InstallLocations::default(), + replace: false, + }; + let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err(); + assert!(err.contains("publishes no checksum"), "{err}"); + assert!(!download_path(&paths, &asset).exists()); + assert!(requests.lock().unwrap().is_empty(), "nothing was fetched"); + assert!(!phases.contains(&JobPhase::Installing)); + } + + #[cfg(unix)] + fn tarball(path: &Path, entries: &[&str]) { + let f = std::fs::File::create(path).unwrap(); + let mut b = tar::Builder::new(flate2::write::GzEncoder::new(f, flate2::Compression::fast())); + for name in entries { + let mut h = tar::Header::new_gnu(); + h.set_size(2); + h.set_mode(0o755); + h.set_cksum(); + b.append_data(&mut h, name, &b"hi"[..]).unwrap(); + } + b.into_inner().unwrap().finish().unwrap(); + } + + #[cfg(unix)] + #[test] + fn lays_out_both_freebsd_tarball_styles_like_usr_local() { + let base = App::by_id("wordcraft").unwrap(); + let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base }; + let app = &wordcraft; + let dir = tempfile::tempdir().unwrap(); + let tree_style = dir.path().join("tree.tar.gz"); + tarball( + &tree_style, + &["wordcraft-0.3.0-freebsd-x86_64/bin/wordcraft", "wordcraft-0.3.0-freebsd-x86_64/share/applications/ai.storyteller.wordcraft.desktop"], + ); + let flat_style = dir.path().join("flat.tar.gz"); + tarball( + &flat_style, + &[ + "wordcraft-0.3.0-freebsd-x86_64/wordcraft", + "wordcraft-0.3.0-freebsd-x86_64/wordcraft-cli", + "wordcraft-0.3.0-freebsd-x86_64/README.md", + "wordcraft-0.3.0-freebsd-x86_64/ai.storyteller.wordcraft.desktop", + "wordcraft-0.3.0-freebsd-x86_64/ai.storyteller.wordcraft.png", + ], + ); + for file in [tree_style, flat_style] { + let root = tree::unpack(app, &file, &dir.path().join("staging")).unwrap(); + let files = tree::files(&root).unwrap(); + assert!(files.contains(&PathBuf::from("bin/wordcraft")), "{files:?}"); + assert!(files.contains(&PathBuf::from("share/applications/ai.storyteller.wordcraft.desktop")), "{files:?}"); + assert!(!files.iter().any(|f| f.ends_with("README.md")), "{files:?}"); + } + let bad = dir.path().join("bad.tar.gz"); + tarball(&bad, &["x/README.md"]); + assert!(tree::unpack(app, &bad, &dir.path().join("staging")).is_err()); + } + + #[cfg(unix)] + #[test] + fn tree_files_does_not_traverse_directory_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("root"); + let external = dir.path().join("external"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&external).unwrap(); + std::fs::write(root.join("regular.txt"), b"hi").unwrap(); + std::fs::write(external.join("secret.txt"), b"secret").unwrap(); + std::os::unix::fs::symlink(&external, root.join("link_to_external")).unwrap(); + + let list = tree::files(&root).unwrap(); + assert_eq!(list, vec![PathBuf::from("link_to_external"), PathBuf::from("regular.txt")]); + } + + #[cfg(target_os = "freebsd")] + #[test] + fn remove_files_refuses_escaping_paths() { + assert!(freebsd::remove_files(&[PathBuf::from("/etc/shadow")], false).is_err()); + if let Some(home) = dirs::home_dir() { + let prefix = home.join(".local"); + assert!(freebsd::remove_files(&[prefix.join("../escaped")], false).is_err()); + } + } + + #[test] + fn single_top_folder_rejects_symlink() { + let dir = tempfile::tempdir().unwrap(); + let real_dir = dir.path().join("real"); + std::fs::create_dir_all(&real_dir).unwrap(); + let staging = dir.path().join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&real_dir, staging.join("symlink_dir")).unwrap(); + let res = single_top_folder(&staging).unwrap(); + assert_eq!(res, staging, "a directory symlink must not be chosen as top folder"); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn a_tarball_install_lives_in_the_launchers_folder_and_keeps_the_apps_home() { + let base = App::by_id("wordcraft").unwrap(); + let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base }; + let app = &wordcraft; + let dir = tempfile::tempdir().unwrap(); + let paths = Paths::under(dir.path()); + let file = dir.path().join("wordcraft-0.3.0-linux-x86_64.tar.gz"); + tarball( + &file, + &[ + "wordcraft-0.3.0-linux-x86_64/bin/wordcraft", + "wordcraft-0.3.0-linux-x86_64/bin/wordcraft-cli", + "wordcraft-0.3.0-linux-x86_64/share/applications/ai.storyteller.wordcraft.desktop", + ], + ); + + let bin = linux::install_tree(app, &file, &paths).unwrap(); + let root = paths.apps().join("wordcraft"); + assert_eq!(bin, root.join("bin").join("wordcraft")); + assert!(bin.is_file(), "the app's binary wasn't unpacked"); + assert!(root.join("share/applications/ai.storyteller.wordcraft.desktop").is_file(), "share/ wasn't unpacked"); + assert!(!paths.cache.join("wordcraft.new").exists(), "the staging folder was left behind"); + + // The app's own home sits beside the tree, and an update keeps it. + let home = paths.app_home(app.id); + std::fs::create_dir_all(home.join(".config/wordcraft")).unwrap(); + std::fs::write(home.join(".config/wordcraft/settings.toml"), "keep me").unwrap(); + linux::install_tree(app, &file, &paths).unwrap(); + assert_eq!(std::fs::read_to_string(home.join(".config/wordcraft/settings.toml")).unwrap(), "keep me"); + + // Removing the install takes the tree and nothing else. + linux::remove_tree(app, &paths).unwrap(); + assert!(!bin.exists() && !root.join("share").exists()); + assert!(home.join(".config/wordcraft/settings.toml").is_file(), "the app's settings were removed with it"); + assert!(linux::remove_tree(app, &paths).is_err(), "a second removal says there is nothing there"); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_tarball_binary_that_is_a_symlink_out_of_the_tree_is_refused() { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + let base = App::by_id("wordcraft").unwrap(); + let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base }; + let app = &wordcraft; + let dir = tempfile::tempdir().unwrap(); + let paths = Paths::under(dir.path()); + + // A working install the rejected update must leave untouched. + let good = dir.path().join("wordcraft-0.3.0-linux-x86_64.tar.gz"); + tarball(&good, &["wordcraft-0.3.0-linux-x86_64/bin/wordcraft"]); + let bin = linux::install_tree(app, &good, &paths).unwrap(); + assert_eq!(std::fs::read_to_string(&bin).unwrap(), "hi"); + + // A file outside the install tree that the archive tries to chmod through a symlink. + let outside = dir.path().join("victim"); + std::fs::write(&outside, b"secret").unwrap(); + std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o600)).unwrap(); + + // A tarball whose bin/wordcraft is a symlink to that external file. + let file = dir.path().join("evil.tar.gz"); + let f = std::fs::File::create(&file).unwrap(); + let mut b = tar::Builder::new(flate2::write::GzEncoder::new(f, flate2::Compression::fast())); + let mut link = tar::Header::new_gnu(); + link.set_entry_type(tar::EntryType::Symlink); + link.set_size(0); + link.set_mode(0o777); + link.set_cksum(); + b.append_link(&mut link, "wordcraft-0.3.0-linux-x86_64/bin/wordcraft", &outside).unwrap(); + // A second real file so the top folder is detected. + let mut reg = tar::Header::new_gnu(); + reg.set_size(2); + reg.set_mode(0o644); + reg.set_cksum(); + b.append_data(&mut reg, "wordcraft-0.3.0-linux-x86_64/share/applications/ai.storyteller.wordcraft.desktop", &b"hi"[..]).unwrap(); + b.into_inner().unwrap().finish().unwrap(); + drop(std::io::stdout().flush()); + + let result = linux::install_tree(app, &file, &paths); + assert!(result.is_err(), "a symlink binary must be rejected: {result:?}"); + assert!(result.unwrap_err().contains("symlink")); + // The external file keeps its original mode: the chmod never reached it. + let mode = std::fs::metadata(&outside).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "the external file's permissions must be untouched"); + // The rejected update left the previously installed, working binary fully intact. + assert!(bin.is_file(), "the rejected update must not remove the working binary"); + assert_eq!(std::fs::read_to_string(&bin).unwrap(), "hi"); + } + + #[cfg(target_os = "linux")] + #[test] + fn an_app_sees_its_own_home_and_the_xdg_folders_inside_it() { + let home = PathBuf::from("/data/apps/photocraft/home"); + let env = home_env(&home); + assert_eq!(env[0], ("HOME", home.clone())); + for (name, dir) in &env[1..] { + assert!(dir.starts_with(&home), "{name}: {}", dir.display()); + } + for wanted in ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"] { + assert!(env.iter().any(|(name, _)| *name == wanted), "{wanted} isn't set"); + } + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + #[test] + fn root_commands_run_programs_by_absolute_path_from_the_given_dirs() { + use std::ffi::OsStr; + let root = tempfile::tempdir().unwrap(); + let (usr_bin, sbin) = (root.path().join("usr/bin"), root.path().join("sbin")); + std::fs::create_dir_all(&usr_bin).unwrap(); + std::fs::create_dir_all(&sbin).unwrap(); + std::fs::write(usr_bin.join("rpm"), b"").unwrap(); + std::fs::write(sbin.join("rpm"), b"").unwrap(); + std::fs::write(sbin.join("zypper"), b"").unwrap(); + let dirs = [&usr_bin, &sbin]; + let run = |args: &[&str]| { + let args: Vec<&OsStr> = args.iter().map(OsStr::new).collect(); + with_absolute_program(&dirs, &args).map(|a| a.into_iter().map(PathBuf::from).collect::>()) + }; + + // The first directory that has the program wins, and the arguments follow unchanged. + assert_eq!(run(&["rpm", "-e", "wordcraft"]), Ok(vec![usr_bin.join("rpm"), "-e".into(), "wordcraft".into()])); + assert_eq!(run(&["zypper"]), Ok(vec![sbin.join("zypper")])); + // A program that isn't in those directories isn't looked for anywhere else. + assert_eq!(run(&["dnf", "remove"]), Err("dnf isn't installed".into())); + // An absolute path is kept; a relative path with folders in it is refused. + assert_eq!(run(&["/bin/cp", "-R"]), Ok(vec!["/bin/cp".into(), "-R".into()])); + assert!(run(&["../usr/bin/rpm"]).is_err()); + assert!(run(&["bin/rpm"]).is_err()); + assert!(run(&[]).is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn several_debs_fit_one_apt_call_and_share_its_result() { + // The files are appended to this, so it must end where apt expects packages. + assert_eq!( + linux::install_command(PackageKind::Deb), + Some(&["apt-get", "-o", "DPkg::Lock::Timeout=60", "install", "-y", "--allow-downgrades"][..]) + ); + assert_eq!(linux::install_command(PackageKind::Flatpak), None); + let failed = || Err("the system installer stopped with error 100".to_string()); + assert_eq!(linux::shared_outcome(Ok(()), 3), Ok(())); + assert_eq!(linux::shared_outcome(failed(), 1), failed()); + assert_eq!( + linux::shared_outcome(failed(), 3), + Err("the system installer stopped with error 100 while handling 3 apps together. Try them one at a time.".into()) + ); + // A dismissed password prompt stays a cancellation for every app, not an error. + assert_eq!(linux::shared_outcome(Err("cancelled".into()), 3), Err("cancelled".into())); + } + + /// Installs two `.deb` files that reach the installer together (both wait behind a running + /// install), with a stand-in for `pkexec` that exits with `code`. Returns every argument + /// list that reached it, and each job's result. + #[cfg(target_os = "linux")] + fn two_debs_through_pkexec(code: i32) -> (Vec>, Vec>) { + use std::sync::{Mutex, mpsc}; + let queue = crate::queue::Queue::new(); + let ran = Mutex::new(Vec::new()); + let run = |args: &[&std::ffi::OsStr]| { + ran.lock().unwrap().push(args.iter().map(|a| a.to_string_lossy().into_owned()).collect::>()); + root_outcome(Some(code)) + }; + let root = linux::Root { queue: &queue, run: &run }; + let (started, has_started) = mpsc::channel(); + let (release, released) = mpsc::channel::<()>(); + let (waiting, waits) = mpsc::channel(); + let results = std::thread::scope(|scope| { + let (queue, root) = (&queue, &root); + scope.spawn(move || { + queue.alone(&|| false, &mut |_| {}, || { + started.send(()).unwrap(); + released.recv().unwrap(); + }); + }); + has_started.recv().unwrap(); + let jobs = ["/downloads/a.deb", "/downloads/b.deb"].map(|file| { + let waiting = waiting.clone(); + scope.spawn(move || { + let mut phase = |p: JobPhase| { + if p == JobPhase::Waiting { + waiting.send(()).unwrap(); + } + }; + linux::install_package_via(root, PackageKind::Deb, Path::new(file), &Stop::default(), &mut phase) + }) + }); + waits.recv().unwrap(); + waits.recv().unwrap(); + release.send(()).unwrap(); + jobs.map(|j| j.join().unwrap()).to_vec() + }); + (ran.into_inner().unwrap(), results) + } + + #[cfg(target_os = "linux")] + #[test] + fn two_debs_reach_root_as_one_apt_call() { + let (ran, results) = two_debs_through_pkexec(0); + assert_eq!(ran.len(), 1, "{ran:?}"); + let (command, files) = ran[0].split_at(6); + assert_eq!(command, ["apt-get", "-o", "DPkg::Lock::Timeout=60", "install", "-y", "--allow-downgrades"]); + let mut files = files.to_vec(); + files.sort(); + assert_eq!(files, ["/downloads/a.deb", "/downloads/b.deb"]); + assert_eq!(results, [Ok(()), Ok(())]); + + // The password prompt was dismissed: one call, and both apps are cancelled, not failed. + let (ran, results) = two_debs_through_pkexec(126); + assert_eq!(ran.len(), 1, "{ran:?}"); + assert_eq!(results, vec![Err("cancelled".to_string()); 2]); + + // apt failed: both apps say so, and how to find the one at fault. + let (ran, results) = two_debs_through_pkexec(100); + assert_eq!(ran.len(), 1, "{ran:?}"); + let error = "the system installer stopped with error 100 while handling 2 apps together. Try them one at a time."; + assert_eq!(results, vec![Err(error.to_string()); 2]); + } + + #[cfg(target_os = "linux")] + #[test] + fn flatpak_bundle_install_failure_reports_clean_error() { + if !crate::process::is_flatpak_available() { + return; + } + let queue = crate::queue::Queue::new(); + let root = linux::Root { queue: &queue, run: &|_| Ok(()) }; + let result = linux::install_package_via(&root, PackageKind::Flatpak, Path::new("/nonexistent/file.flatpak"), &Stop::default(), &mut |_| ()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.starts_with("Flatpak couldn't install the bundle: "), "unexpected error: {err}"); + } + + #[cfg(target_os = "linux")] + #[test] + fn adds_and_removes_only_its_own_menu_entry_and_icon() { + let base = App::by_id("wordcraft").unwrap(); + let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base }; + let app = &wordcraft; + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("share"); + let desktop = data.join("applications/ai.storyteller.wordcraft.desktop"); + let icon = data.join("icons/hicolor/128x128/apps/ai.storyteller.wordcraft.png"); + let old = root.path().join("My Apps/wordcraft-0.2.0-linux-x86_64.AppImage"); + let new = root.path().join("My Apps/wordcraft-0.3.0-linux-x86_64.AppImage"); + std::fs::create_dir_all(new.parent().unwrap()).unwrap(); + std::fs::write(&new, b"").unwrap(); + + // Fresh install: entry and icon written and recorded. + let files = linux::integrate_into(&data, app, &new, &[], None); + assert_eq!(files, [icon.clone(), desktop.clone()]); + let text = std::fs::read_to_string(&desktop).unwrap(); + assert!(text.contains("Exec=\"") && text.contains("My Apps/wordcraft-0.3.0-linux-x86_64.AppImage\" %F\n"), "{text}"); + assert!(text.contains("Icon=ai.storyteller.wordcraft\n")); + assert!(std::fs::read(&icon).unwrap().starts_with(b"\x89PNG")); + let rec = record("0.3.0", PackageKind::AppImage, new.clone(), files, false); + + // An update rewrites both. + assert_eq!(linux::integrate_into(&data, app, &new, &[], Some(&rec)).len(), 2); + + // Someone else's entry and icon are left alone (and not recorded). + std::fs::write(&desktop, "[Desktop Entry]\nExec=/usr/bin/wordcraft\n").unwrap(); + std::fs::write(&icon, b"theirs").unwrap(); + assert!(linux::integrate_into(&data, app, &new, &[], None).is_empty()); + assert_eq!(std::fs::read(&icon).unwrap(), b"theirs"); + // ...unless the entry runs a copy this install just replaced (the app wrote it itself). + std::fs::write(&desktop, format!("[Desktop Entry]\nExec=\"{}\" %F\n", old.display())).unwrap(); + assert_eq!(linux::integrate_into(&data, app, &new, std::slice::from_ref(&old), None), std::slice::from_ref(&desktop)); + assert!(desktop_entry_is_ours(&desktop)); + + // Uninstall: an icon the record doesn't list stays; the entry for the removed copy goes. + std::fs::remove_file(&new).unwrap(); + linux::unintegrate_from(&data, app, &new, None); + assert!(!desktop.exists()); + assert_eq!(std::fs::read(&icon).unwrap(), b"theirs"); + // With the record, the launcher's icon goes too. + std::fs::remove_file(&icon).unwrap(); + std::fs::write(&new, b"").unwrap(); + let files = linux::integrate_into(&data, app, &new, &[], None); + let rec = record("0.3.0", PackageKind::AppImage, new.clone(), files, false); + std::fs::remove_file(&new).unwrap(); + linux::unintegrate_from(&data, app, &new, Some(&rec)); + assert!(!desktop.exists() && !icon.exists()); + // Someone else's entry survives an uninstall. + std::fs::write(&desktop, "[Desktop Entry]\nExec=/usr/bin/wordcraft\n").unwrap(); + linux::unintegrate_from(&data, app, &new, Some(&rec)); + assert!(desktop.exists()); + } + + #[cfg(target_os = "linux")] + fn desktop_entry_is_ours(path: &Path) -> bool { + crate::desktop_entry::is_ours(&std::fs::read_to_string(path).unwrap()) + } + + #[cfg(target_os = "linux")] + #[test] + fn immutable_ostree_offers_no_deb_or_rpm() { + if !Path::new("/run/ostree-booted").exists() { + return; + } + let kinds = available_kinds(); + assert!(!kinds.contains(&PackageKind::Rpm), "{kinds:?}"); + assert!(!kinds.contains(&PackageKind::Deb), "{kinds:?}"); + assert!(kinds.contains(&PackageKind::AppImage), "{kinds:?}"); + } + + #[test] + fn updates_keep_the_installed_format() { + let inst = |source| Installed { version: None, source, location: String::new(), package: None }; + let app = crate::App::by_id("photocraft").unwrap(); + let available = available_kinds(); + if let Some(first) = available.first() { + assert_eq!(choose_kinds(app, None, None, &[]).first(), Some(first)); + } + #[cfg(target_os = "macos")] + assert_eq!(choose_kinds(app, Some(&inst(InstallSource::MacApp)), None, &[]), [PackageKind::Dmg]); + #[cfg(windows)] + assert_eq!(choose_kinds(app, Some(&inst(InstallSource::Msi)), None, &[]), [PackageKind::Msi]); + #[cfg(target_os = "linux")] + assert_eq!(choose_kinds(app, Some(&inst(InstallSource::AppImage)), None, &[]), [PackageKind::AppImage]); + let _ = inst; + } + + #[test] + fn only_the_release_arch_package_is_updated_by_the_launcher() { + let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let app = crate::App::by_id("wordcraft").unwrap(); + assert!(is_release_arch_package(app, &pacman("ai.storyteller.wordcraft"))); + for aur in ["wordcraft", "wordcraft-bin", "wordcraft-git"] { + assert!(!is_release_arch_package(app, &pacman(aur)), "{aur}"); + } + assert_eq!(managed_elsewhere_with(app, &pacman("ai.storyteller.wordcraft"), false, |_| None), None); + assert_eq!(managed_elsewhere_with(app, &pacman("wordcraft-bin"), true, |_| None), None); + } + + #[test] + fn arch_packages_from_a_repository_are_updated_with_the_system() { + let app = crate::App::by_id("wordcraft").unwrap(); + let hint = pacman_update_hint(app, "wordcraft-bin", Some("omarchy")); + assert!(hint.starts_with("WordCraft was installed from the omarchy repository or the AUR (wordcraft-bin).")); + assert!(hint.ends_with("or sudo pacman -Syu")); + + let aur_hint = pacman_update_hint(app, "wordcraft-bin", None); + assert!(aur_hint.starts_with("WordCraft was installed from the AUR (wordcraft-bin).")); + + let si = "Repository : omarchy\nName : wordcraft-bin\nVersion : 0.6.1-1\n"; + assert_eq!(parse_pacman_repo(si).as_deref(), Some("omarchy")); + assert_eq!(parse_pacman_repo("Name : wordcraft-bin\n"), None); + assert_eq!(parse_pacman_repo("Repository :\n"), None); + } + + #[test] + fn aur_packages_are_not_updated_as_arch_pkg() { + let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let app = crate::App::by_id("filmcraft").unwrap(); + let installed = pacman("filmcraft-bin"); + assert!(managed_elsewhere_with(app, &installed, false, |_| None).is_some()); + assert_eq!(choose_kinds_with(app, Some(&installed), None, &[], &[], false), Vec::::new()); + assert_eq!(choose_kinds_with(app, Some(&installed), None, &[PackageKind::ArchPkg], &[PackageKind::ArchPkg], true), Vec::::new()); + } + + #[test] + fn release_arch_packages_are_updated_as_arch_pkg() { + let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let app = crate::App::by_id("filmcraft").unwrap(); + let installed = pacman("ai.storyteller.filmcraft"); + assert_eq!(managed_elsewhere_with(app, &installed, false, |_| None), None); + assert_eq!(choose_kinds_with(app, Some(&installed), None, &[], &[], true), vec![PackageKind::ArchPkg]); + assert_eq!(choose_kinds_with(app, Some(&installed), None, &[], &[PackageKind::ArchPkg], false), vec![PackageKind::ArchPkg]); + } + + #[test] + fn unavailable_arch_package_not_selected_for_new_install() { + let app = crate::App::by_id("filmcraft").unwrap(); + assert_eq!(choose_kinds_with(app, None, None, &[PackageKind::ArchPkg], &[], true), Vec::::new()); + assert_eq!(choose_kinds_with(app, None, None, &[PackageKind::ArchPkg], &[], false), Vec::::new()); + assert_eq!(choose_kinds_with(app, None, None, &[PackageKind::ArchPkg], &[PackageKind::ArchPkg], false), vec![PackageKind::ArchPkg]); + } + + #[test] + fn install_refuses_to_downgrade_when_target_release_is_older() { + let dir = tempfile::tempdir().unwrap(); + let paths = Paths::under(dir.path()); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let phase = &mut |_| (); + let locations = InstallLocations::default(); + let app = App::by_id("photocraft").unwrap(); + let release = Release { version: version::parse("0.4.0"), tag: "v0.4.0".into(), ..Release::default() }; + let asset = Asset { name: "photocraft-0.4.0-linux-x86_64.AppImage".into(), ..Asset::default() }; + let installed = Installed { + version: version::parse("0.5.0"), + source: InstallSource::Launcher, + location: dir.path().join("PhotoCraft.AppImage").display().to_string(), + package: None, + }; + let stop = Stop::default(); + let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false }; + let result = install_at(app, &release, &asset, PackageKind::AppImage, Some(&installed), None, &mut job, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("installed version 0.5.0 is newer")); + } + + #[test] + fn install_rejects_an_artifact_whose_version_is_not_the_advertised_one() { + // The release advertises 99.0.0, but the selected artifact is an older official build + // (filename and URL tag say 0.4.0). The guard must refuse before any download. + let dir = tempfile::tempdir().unwrap(); + let paths = Paths::under(dir.path()); + let http = Http::new(crate::TEST_AGENT).unwrap(); + let phase = &mut |_| (); + let locations = InstallLocations::default(); + let app = App::by_id("photocraft").unwrap(); + let release = Release { version: version::parse("99.0.0"), tag: "v99.0.0".into(), ..Release::default() }; + let asset = Asset { + name: "photocraft-0.4.0-linux-x86_64.AppImage".into(), + url: "https://github.com/storytold/photocraft/releases/download/v0.4.0/photocraft-0.4.0-linux-x86_64.AppImage".into(), + ..Asset::default() + }; + let stop = Stop::default(); + let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false }; + let result = install_at(app, &release, &asset, PackageKind::AppImage, None, None, &mut job, None); + assert!(result.is_err(), "mismatched artifact version must be rejected"); + assert!(result.unwrap_err().contains("does not match the advertised version")); + + // A matching artifact passes the guard (it fails later for unrelated reasons, not the bind). + let good = Asset { + name: "photocraft-99.0.0-linux-x86_64.AppImage".into(), + url: "https://github.com/storytold/photocraft/releases/download/v99.0.0/photocraft-99.0.0-linux-x86_64.AppImage".into(), + ..Asset::default() + }; + assert!(bind_artifact_version(app, &release, &good).is_ok()); + + // URL filename mismatch is rejected (CWE-494). + let bad_url_name = Asset { + name: "photocraft-99.0.0-linux-x86_64.AppImage".into(), + url: "https://github.com/storytold/photocraft/releases/download/v99.0.0/other.AppImage".into(), + ..Asset::default() + }; + assert!(bind_artifact_version(app, &release, &bad_url_name).is_err()); + + // URL tag mismatch is rejected. + let bad_tag = Asset { + name: "photocraft-99.0.0-linux-x86_64.AppImage".into(), + url: "https://github.com/storytold/photocraft/releases/download/unparsed_tag/photocraft-99.0.0-linux-x86_64.AppImage".into(), + ..Asset::default() + }; + assert!(bind_artifact_version(app, &release, &bad_tag).is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn package_kinds_order_matches_launcher_format() { + use super::linux::order_available_kinds; + + // Flatpak launcher defaults to Flatpak if available + let kinds = order_available_kinds(true, false, true, true, false, false); + assert_eq!(kinds.first(), Some(&PackageKind::Flatpak)); + assert!(kinds.contains(&PackageKind::Deb)); + assert!(kinds.contains(&PackageKind::AppImage)); + + // AppImage launcher defaults to AppImage + let kinds = order_available_kinds(false, true, true, true, false, false); + assert_eq!(kinds.first(), Some(&PackageKind::AppImage)); + assert!(kinds.contains(&PackageKind::Deb)); + assert!(kinds.contains(&PackageKind::Flatpak)); + + // Base Linux launcher defaults to native package format (deb, rpm, or arch) if available + let kinds = order_available_kinds(false, false, true, true, false, false); + assert_eq!(kinds.first(), Some(&PackageKind::Deb)); + + let kinds = order_available_kinds(false, false, true, false, true, false); + assert_eq!(kinds.first(), Some(&PackageKind::Rpm)); + + let kinds = order_available_kinds(false, false, true, false, false, true); + assert_eq!(kinds.first(), Some(&PackageKind::ArchPkg)); + + // Base Linux launcher without root/distro packaging falls back to Flatpak then AppImage + let kinds = order_available_kinds(false, false, true, false, false, false); + assert_eq!(kinds.first(), Some(&PackageKind::Flatpak)); + assert_eq!(kinds.get(1), Some(&PackageKind::AppImage)); + + // The tarball runs inside the sandbox, so it is only offered there: with a usable host + // Flatpak it stays an alternative, without one it is the default (an AppImage cannot + // mount in a sandbox, and a distro package needs root). + let kinds = order_available_kinds(true, false, true, false, false, false); + assert_eq!(kinds.first(), Some(&PackageKind::Flatpak)); + assert_eq!(kinds.get(1), Some(&PackageKind::TarGz)); + let kinds = order_available_kinds(true, false, false, false, false, false); + assert_eq!(kinds, [PackageKind::TarGz, PackageKind::AppImage]); + for (appimage, flatpak) in [(true, true), (false, false), (false, true)] { + assert!(!order_available_kinds(false, appimage, flatpak, true, true, false).contains(&PackageKind::TarGz)); + } + } + + #[cfg(target_os = "macos")] + mod macos_checks { + use crate::{SUITE, version}; + + use super::macos::{Expect, TEAM_ID, install_dmg, signed_by}; + use super::*; + + fn photocraft() -> &'static App { + SUITE.iter().find(|a| a.id == "photocraft").unwrap() + } + + /// A minimal `PhotoCraft.app` at `dir`, reporting `version`. + fn bundle(dir: &Path, version: &str) -> PathBuf { + let app = dir.join("PhotoCraft.app"); + let contents = app.join("Contents"); + std::fs::create_dir_all(contents.join("MacOS")).unwrap(); + std::fs::write( + contents.join("Info.plist"), + format!( + "\ + CFBundleIdentifierai.storyteller.photocraft\ + CFBundleExecutablePhotoCraft\ + CFBundleShortVersionString{version}" + ), + ) + .unwrap(); + std::fs::write(contents.join("MacOS/PhotoCraft"), version).unwrap(); + app + } + + /// A release-style disk image holding a `PhotoCraft.app` that reports `version`. The + /// disk-image helper turns requests away while it is busy (`attach` retries for the same + /// reason), so `hdiutil create` is tried a few times, and what it said is kept for the + /// failure message. + fn dmg(dir: &Path, version: &str) -> PathBuf { + let src = dir.join(format!("src-{version}")); + bundle(&src, version); + let dmg = dir.join(format!("photocraft-{version}-macos-universal.dmg")); + let _one_at_a_time = super::macos::DISK_IMAGES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut said = String::new(); + for attempt in 0..5u64 { + if attempt > 0 { + std::thread::sleep(std::time::Duration::from_millis(400 * attempt)); + let _ = std::fs::remove_file(&dmg); + } + let output = std::process::Command::new("hdiutil") + .args(["create", "-quiet", "-format", "UDZO", "-volname", "PhotoCraft", "-srcfolder"]) + .arg(&src) + .arg(&dmg) + .output() + .unwrap(); + if output.status.success() { + return dmg; + } + said = String::from_utf8_lossy(&output.stderr).trim().to_string(); + } + panic!("hdiutil create failed after 5 tries: {said}"); + } + + #[test] + fn custom_bundles_are_detected_and_updates_stay_in_their_actual_location() { + let dir = tempfile::tempdir().unwrap(); + let apps = dir.path().join("Craft Apps"); + std::fs::create_dir(&apps).unwrap(); + let paths = Paths::under(dir.path()); + let image = dmg(dir.path(), "0.6.0"); + let expect = Expect { team: None, version: None }; + let placed = install_dmg(photocraft(), &image, None, Some(&apps), false, &paths, &expect).unwrap(); + assert_eq!(placed, apps.join("PhotoCraft.app")); + let mut manifest = crate::manifest::Manifest::default(); + manifest.apps.insert("photocraft".into(), record("0.6.0", PackageKind::Dmg, placed.clone(), vec![], false)); + let existing = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap(); + assert_eq!(existing.source, InstallSource::MacApp); + assert_eq!(existing.location, placed.display().to_string()); + assert_eq!(existing.version.unwrap().to_string(), "0.6.0"); + let existing = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap(); + let new_default = dir.path().join("Other Apps"); + std::fs::create_dir(&new_default).unwrap(); + let update = dmg(dir.path(), "0.7.0"); + assert_eq!(install_dmg(photocraft(), &update, Some(&existing), Some(&new_default), false, &paths, &expect).unwrap(), placed); + assert!(!new_default.join("PhotoCraft.app").exists()); + let detected = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap(); + assert_eq!(detected.version.unwrap().to_string(), "0.7.0"); + std::fs::write(placed.join("Contents/Info.plist"), "invalid").unwrap(); + assert_ne!( + crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).map(|i| i.source), + Some(InstallSource::Launcher) + ); + } + + #[test] + fn an_unrelated_bundle_at_the_selected_destination_is_preserved() { + let dir = tempfile::tempdir().unwrap(); + let placed = bundle(dir.path(), "0.5.0"); + let plist = placed.join("Contents/Info.plist"); + let unrelated = std::fs::read_to_string(&plist).unwrap().replace("ai.storyteller.photocraft", "com.example.other"); + std::fs::write(&plist, &unrelated).unwrap(); + let image = dmg(dir.path(), "0.6.0"); + let result = + install_dmg(photocraft(), &image, None, Some(dir.path()), false, &Paths::under(dir.path()), &Expect { team: None, version: None }); + assert!(result.unwrap_err().contains("already exists")); + assert_eq!(std::fs::read_to_string(plist).unwrap(), unrelated); + assert_eq!(std::fs::read(placed.join("Contents/MacOS/PhotoCraft")).unwrap(), b"0.5.0"); + } + + #[test] + fn only_the_teams_signature_passes() { + let dir = tempfile::tempdir().unwrap(); + let app = bundle(dir.path(), "0.6.0"); + assert!(!signed_by(&app, TEAM_ID), "an unsigned bundle passed"); + + // A valid signature, but not from the team. + let ok = std::process::Command::new("codesign").args(["--force", "--sign", "-"]).arg(&app).status().unwrap().success(); + assert!(ok, "codesign failed"); + assert!(!signed_by(&app, TEAM_ID), "an ad-hoc signature passed"); + + // A team id can't smuggle anything into the requirement. + assert!(!signed_by(&app, "X\" or anchor apple generic or \"Y")); + assert!(!signed_by(&app, "")); + } + + #[test] + fn the_bundle_must_be_the_releases_version() { + let dir = tempfile::tempdir().unwrap(); + let apps = dir.path().join("Applications"); + let current = bundle(&apps, "0.5.0"); + let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None }; + let paths = Paths::under(dir.path()); + let image = dmg(dir.path(), "0.6.0"); + + for (release, ok) in [("0.6.0", true), ("0.7.0", false), ("0.6.0-rc.1", true), ("0.5.0", false)] { + std::fs::write(current.join("Contents/MacOS/PhotoCraft"), "0.5.0").unwrap(); + let want = version::parse(release).unwrap(); + let expect = Expect { team: None, version: Some(&want) }; + let result = install_dmg(photocraft(), &image, Some(&existing), None, false, &paths, &expect); + assert_eq!(result.is_ok(), ok, "release {release}: {result:?}"); + let now = std::fs::read_to_string(current.join("Contents/MacOS/PhotoCraft")).unwrap(); + assert_eq!(now, if ok { "0.6.0" } else { "0.5.0" }, "release {release}"); + if !ok { + assert_eq!(result, Err(format!("the app inside isn't version {release}, so it wasn't installed"))); + } + assert_eq!( + std::fs::read_dir(&apps).unwrap().map(|e| e.unwrap().file_name()).collect::>(), + [std::ffi::OsString::from("PhotoCraft.app")] + ); + } + + // A bundle that names a pre-release must name the release's. + let rc = dmg(dir.path(), "0.8.0-rc.2"); + let want = version::parse("0.8.0-rc.1").unwrap(); + let expect = Expect { team: None, version: Some(&want) }; + assert!(install_dmg(photocraft(), &rc, Some(&existing), None, false, &paths, &expect).is_err()); + } + + #[test] + fn an_unsigned_update_leaves_the_installed_copy_alone() { + let dir = tempfile::tempdir().unwrap(); + let apps = dir.path().join("Applications"); + let current = bundle(&apps, "0.5.0"); + let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None }; + let image = dmg(dir.path(), "0.6.0"); + let release = Release { tag: "v0.6.0".into(), version: version::parse("0.6.0"), ..Release::default() }; + + let result = install_dmg(photocraft(), &image, Some(&existing), None, false, &Paths::under(dir.path()), &Expect::release(&release)); + assert_eq!(result, Err("the app isn't signed by its developer, so it wasn't installed".into())); + assert_eq!(std::fs::read_to_string(current.join("Contents/MacOS/PhotoCraft")).unwrap(), "0.5.0"); + assert_eq!( + std::fs::read_dir(&apps).unwrap().map(|e| e.unwrap().file_name()).collect::>(), + [std::ffi::OsString::from("PhotoCraft.app")] + ); + } + + /// A real release passes. Run with `CRAFT_APP=/path/to/PhotoCraft.app cargo test -p + /// artcraft-launcher-native -- --ignored a_real_craft_app`. + #[test] + #[ignore = "needs a Craft App from a real release"] + fn a_real_craft_app_carries_the_teams_signature() { + let app = std::env::var("CRAFT_APP").expect("set CRAFT_APP to a Craft App bundle"); + assert!(signed_by(Path::new(&app), TEAM_ID), "{app} isn't signed by {TEAM_ID}"); + } + } +} diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs new file mode 100644 index 00000000..ab61612e --- /dev/null +++ b/crates/update/src/lib.rs @@ -0,0 +1,72 @@ +//! Release checking, downloading, installing and self-updating for the Craft App Suite and ArtCraft Launcher. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod app; +pub mod desktop_entry; +pub mod detect; +pub mod distro; +pub mod http; +pub mod install; +pub mod manifest; +pub mod packages; +pub mod paths; +pub mod platform; +#[cfg(windows)] +pub mod portable; +pub mod process; +pub mod protocol; +pub mod proxy; +pub mod queue; +pub mod release; +pub mod self_update; +pub mod source; +pub mod status; +pub mod suite; +pub mod time; +pub mod version; + +#[cfg(feature = "egui")] +pub mod dialog; + +pub use app::App; +pub use http::{Http, Stop}; +pub use paths::Paths; +pub use platform::{Arch, Os, PackageKind, Platform, pick_asset}; +pub use protocol::{InstallLocations, JobPhase, LatestRelease, Source}; +pub use release::{Asset, Release, parse_github_release, parse_github_release_list}; +pub use status::{InstallSource, Installed}; +pub use suite::SUITE; +pub use time::parse_rfc3339; +pub use version::{Version, is_newer, parse as parse_version}; + +#[cfg(feature = "egui")] +pub use dialog::{StartupPrompt, StartupPromptResponse}; + +/// Checks for an update against GitHub releases for the given app. +pub fn check_for_update(app: &App, http: &Http, current_version: &str) -> Result, String> { + let url = format!("https://api.github.com/repos/{}/releases?per_page=30", app.repo); + match http.get_api(&url, None)? { + crate::http::Fetched::Body { text, .. } => { + let latest = crate::release::parse_github_release_list(&text, app.repo, false)?; + if let Some(release) = latest + && let Some(rel_v) = &release.version + && let Some(cur_v) = crate::version::parse(current_version) + && crate::version::is_newer(rel_v, &cur_v) + { + return Ok(Some(release)); + } + Ok(None) + } + crate::http::Fetched::RateLimited { .. } => Err("GitHub's request limit was reached".into()), + crate::http::Fetched::NotModified | crate::http::Fetched::NotFound => Ok(None), + } +} + +/// Unix seconds now. +pub fn now() -> i64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) +} + +#[cfg(test)] +pub(crate) const TEST_AGENT: &str = "ArtCraft-Launcher-Test/0.0"; diff --git a/crates/update/src/manifest.rs b/crates/update/src/manifest.rs new file mode 100644 index 00000000..bcf9b06c --- /dev/null +++ b/crates/update/src/manifest.rs @@ -0,0 +1,61 @@ +//! The launcher's record of what it installed itself. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::PackageKind; +use serde::{Deserialize, Serialize}; + +/// One app the launcher installed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + pub version: String, + pub kind: PackageKind, + /// The `.app`, the exe, the AppImage, or the folder of an unpacked tarball. + pub path: PathBuf, + /// Unix seconds. + pub installed_at: i64, + /// Every file installed, for tree installs (FreeBSD), so removal takes exactly those. + #[serde(default)] + pub files: Vec, + /// The files are in a system prefix and removing them needs root. + #[serde(default)] + pub needs_root: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Manifest { + pub apps: BTreeMap, +} + +impl Manifest { + pub fn load(path: &Path) -> Self { + std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + } + + pub fn save(&self, path: &Path) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?; + crate::paths::write_atomic(path, &bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_survives_garbage() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("installed.json"); + assert_eq!(Manifest::load(&path), Manifest::default()); + let mut m = Manifest::default(); + m.apps.insert( + "photocraft".into(), + Record { version: "0.5.0".into(), kind: PackageKind::AppImage, path: "/x/y".into(), installed_at: 1, files: vec![], needs_root: false }, + ); + m.save(&path).unwrap(); + assert_eq!(Manifest::load(&path), m); + std::fs::write(&path, b"{not json").unwrap(); + assert_eq!(Manifest::load(&path), Manifest::default()); + } +} diff --git a/crates/update/src/packages.rs b/crates/update/src/packages.rs new file mode 100644 index 00000000..1820c2f1 --- /dev/null +++ b/crates/update/src/packages.rs @@ -0,0 +1,161 @@ +//! Reading system package lists (dpkg, rpm, pacman, Flatpak) and matching them to Craft Apps. +//! +//! Pure functions over the tools' text output, so they're tested on every platform; only Linux +//! runs the tools (`detect::linux`). + +use crate::{App, InstallSource, version}; + +/// One installed package that belongs to a Craft App. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Package { + pub manager: InstallSource, + /// The package's name (`photocraft-bin`) or Flatpak app id. + pub name: String, + /// As the package manager reports it (`0.5.0-1`); empty when it doesn't say. + pub version: String, +} + +/// `dpkg-query -W -f '${Package}|${db:Status-Status}|${Version}\n'`: installed packages only. +pub fn parse_dpkg(text: &str) -> Vec<(String, String)> { + text.lines() + .filter_map(|line| { + let mut f = line.split('|'); + match (f.next(), f.next(), f.next()) { + (Some(name), Some("installed"), Some(ver)) => { + let name = name.split_once(':').map(|(n, _)| n).unwrap_or(name); + Some((name.to_string(), ver.to_string())) + } + _ => None, + } + }) + .collect() +} + +/// `rpm -qa --qf '%{NAME}|%{VERSION}\n'`. +pub fn parse_rpm(text: &str) -> Vec<(String, String)> { + text.lines().filter_map(|line| line.split_once('|').map(|(n, v)| (n.to_string(), v.to_string()))).collect() +} + +/// `pacman -Q`: `name version` per line. +pub fn parse_pacman(text: &str) -> Vec<(String, String)> { + text.lines() + .filter_map(|line| { + let mut f = line.split_whitespace(); + Some((f.next()?.to_string(), f.next()?.to_string())) + }) + .collect() +} + +/// `flatpak list --app --columns=application,version`: the version column is often empty for +/// bundles built from a release tarball. +pub fn parse_flatpak_list(text: &str) -> Vec<(String, String)> { + text.lines() + .filter_map(|line| { + let mut f = line.split('\t'); + let id = f.next()?.trim(); + (!id.is_empty()).then(|| (id.to_string(), f.next().unwrap_or("").trim().to_string())) + }) + .collect() +} + +/// The `Version:` line of `flatpak info `. +pub fn parse_flatpak_info(text: &str) -> Option { + text.lines().find_map(|l| l.trim().strip_prefix("Version:").map(|v| v.trim().to_string())).filter(|v| !v.is_empty()) +} + +/// The package names a Craft App may be installed under with `manager`. On Arch the AUR carries +/// each app three ways: built from source (`photocraft`), the release binary (`photocraft-bin`) +/// and the latest commit (`photocraft-git`). Debian and RPM packages may use either the short stem +/// (`photocraft`) or the reverse-DNS bundle id (`ai.storyteller.photocraft`). +pub fn candidate_names(app: &App, manager: InstallSource) -> Vec { + match manager { + InstallSource::Flatpak => vec![app.bundle_id()], + InstallSource::Pacman => { + app.stems().flat_map(|s| [s.to_string(), format!("{s}-bin"), format!("{s}-git")]).chain(std::iter::once(app.bundle_id())).collect() + } + InstallSource::Deb | InstallSource::Rpm => app.stems().map(str::to_string).chain(std::iter::once(app.bundle_id())).collect(), + _ => app.stems().map(str::to_string).collect(), + } +} + +/// The package of `app` among `installed`, preferring the manager listed first in `order`. +pub fn find<'a>(app: &App, installed: &'a [Package], order: &[InstallSource]) -> Option<&'a Package> { + order.iter().find_map(|manager| { + let names = candidate_names(app, *manager); + names.iter().find_map(|n| installed.iter().find(|p| p.manager == *manager && p.name == *n)) + }) +} + +/// A package's version, read the package-manager way (`0.5.0-1` is 0.5.0). +pub fn version_of(p: &Package) -> Option { + version::parse_package(&p.version) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkgs(manager: InstallSource, list: Vec<(String, String)>) -> Vec { + list.into_iter().map(|(name, version)| Package { manager, name, version }).collect() + } + + #[test] + fn reads_each_package_managers_listing() { + assert_eq!( + parse_dpkg("photocraft|installed|0.5.0-1\nphotocraft:amd64|installed|0.6.0-1\nwordcraft|config-files|0.3.0-1\n"), + [("photocraft".into(), "0.5.0-1".into()), ("photocraft".into(), "0.6.0-1".into())] + ); + assert_eq!(parse_rpm("gridcraft|0.3.0\n"), [("gridcraft".into(), "0.3.0".into())]); + assert_eq!(parse_pacman("photocraft-bin 0.5.0-1\nlinux 6.11.1.arch1-1\n").len(), 2); + assert_eq!( + parse_flatpak_list("ai.storyteller.wordcraft\t\nai.storyteller.cadcraft\t0.3.0\n"), + [("ai.storyteller.wordcraft".into(), String::new()), ("ai.storyteller.cadcraft".into(), "0.3.0".into())] + ); + let info = "\nWordCraft - Word processor\n\n ID: ai.storyteller.wordcraft\n Version: 0.3.0\n Branch: master\n"; + assert_eq!(parse_flatpak_info(info).as_deref(), Some("0.3.0")); + assert_eq!(parse_flatpak_info("Version: \n"), None); + } + + #[test] + fn candidate_names_cover_bundle_ids_for_deb_rpm_and_pacman() { + let photo = App::by_id("photocraft").unwrap(); + let deb_names = candidate_names(photo, InstallSource::Deb); + assert!(deb_names.contains(&"photocraft".to_string())); + assert!(deb_names.contains(&"ai.storyteller.photocraft".to_string())); + + let rpm_names = candidate_names(photo, InstallSource::Rpm); + assert!(rpm_names.contains(&"photocraft".to_string())); + assert!(rpm_names.contains(&"ai.storyteller.photocraft".to_string())); + + let pacman_names = candidate_names(photo, InstallSource::Pacman); + assert!(pacman_names.contains(&"photocraft".to_string())); + assert!(pacman_names.contains(&"photocraft-bin".to_string())); + assert!(pacman_names.contains(&"ai.storyteller.photocraft".to_string())); + } + + #[test] + fn finds_aur_packages_in_every_flavour() { + let photo = App::by_id("photocraft").unwrap(); + let pdf = App::by_id("pdfcraft").unwrap(); + let installed = + pkgs(InstallSource::Pacman, parse_pacman("linux 6.11.1.arch1-1\nphotocraft-git 0.3.0.r47.g47f9306-1\nprintcraft-bin 0.2.1-1\n")); + let hit = find(photo, &installed, &[InstallSource::Pacman]).unwrap(); + assert_eq!(hit.name, "photocraft-git"); + assert_eq!(version_of(hit).unwrap().to_string(), "0.3.0"); + // PdfCraft under its old name. + assert_eq!(find(pdf, &installed, &[InstallSource::Pacman]).unwrap().name, "printcraft-bin"); + assert!(find(App::by_id("wordcraft").unwrap(), &installed, &[InstallSource::Pacman]).is_none()); + } + + #[test] + fn prefers_managers_in_order_and_reads_revisions() { + let photo = App::by_id("photocraft").unwrap(); + let mut installed = pkgs(InstallSource::Deb, parse_dpkg("photocraft|installed|0.5.0-1\n")); + installed.extend(pkgs(InstallSource::Flatpak, parse_flatpak_list("ai.storyteller.photocraft\t0.4.0\n"))); + let hit = find(photo, &installed, &[InstallSource::Deb, InstallSource::Flatpak]).unwrap(); + assert_eq!(hit.manager, InstallSource::Deb); + // 0.5.0-1 is the 0.5.0 release, not a pre-release of it. + assert_eq!(version_of(hit).unwrap().to_string(), "0.5.0"); + assert_eq!(find(photo, &installed, &[InstallSource::Flatpak]).unwrap().version, "0.4.0"); + } +} diff --git a/crates/update/src/paths.rs b/crates/update/src/paths.rs new file mode 100644 index 00000000..b43feb15 --- /dev/null +++ b/crates/update/src/paths.rs @@ -0,0 +1,131 @@ +//! Where an installer keeps its own files: downloads, records, logs. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::app::App; + +/// An installer's folders. +#[derive(Clone, Debug)] +pub struct Paths { + /// Records of what was installed, and tarball installs. + pub data: PathBuf, + /// Saved release answers and in-progress downloads. + pub cache: PathBuf, +} + +impl Paths { + /// Discovers paths for an app, respecting portable mode if `portable.txt` is next to current exe. + pub fn for_app(app: &App) -> Result { + if let Ok(exe) = std::env::current_exe() { + return Self::for_exe(app, &exe); + } + let dir = if cfg!(any(target_os = "macos", target_os = "windows")) { app.name } else { app.id }; + Self::per_user(dir) + } + + /// Discovers paths for an app from a specific executable path. + pub fn for_exe(app: &App, exe: &Path) -> Result { + if let Some(parent) = exe.parent() + && parent.join("portable.txt").is_file() + { + let data_dir_name = format!("{}Data", app.name.replace(' ', "")); + return Ok(Self::under(&parent.join(data_dir_name))); + } + let dir = if cfg!(any(target_os = "macos", target_os = "windows")) { app.name } else { app.id }; + Self::per_user(dir) + } + + /// The per-user folders named `dir` (`ArtCraft Launcher` on macOS and Windows, + /// `artcraft-launcher` elsewhere, by convention). + pub fn per_user(dir: &str) -> Result { + let data = dirs::data_local_dir().ok_or("no per-user data folder on this system")?.join(dir); + let cache = dirs::cache_dir().ok_or("no per-user cache folder on this system")?.join(dir); + Ok(Self { data, cache }) + } + + /// Everything under one folder (portable mode, tests). + pub fn under(root: &std::path::Path) -> Self { + Self { data: root.join("data"), cache: root.join("cache") } + } + + pub fn downloads(&self) -> PathBuf { + self.cache.join("downloads") + } + + /// Log files, and the error of a failed self-update waiting for the next start. + pub fn logs(&self) -> PathBuf { + self.data.join("logs") + } + + /// Where tarball installs (FreeBSD, Linux without AppImage) are unpacked: `/apps/`. + pub fn apps(&self) -> PathBuf { + self.data.join("apps") + } + + /// The folder an app unpacked in [`Self::apps`] sees as its home: `/apps//home`. + /// A Craft App keeps its settings in the XDG folders (`~/.config`, `~/.local/share`, + /// `~/.cache`), so pointing `HOME` and those variables here keeps every app's configuration + /// beside it, inside the folder the installer owns. + #[cfg(target_os = "linux")] + pub fn app_home(&self, id: &str) -> PathBuf { + self.apps().join(id).join("home") + } +} + +/// Writes `bytes` to `path` via a temporary file and a rename, so a crash never leaves half a file. +pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { + let dir = path.parent().ok_or("no parent folder")?; + std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?; + let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| format!("couldn't create a temporary file in {} ({e})", dir.display()))?; + tmp.write_all(bytes).map_err(|e| format!("couldn't write {} ({e})", tmp.path().display()))?; + tmp.as_file().sync_all().map_err(|e| format!("couldn't sync {} ({e})", tmp.path().display()))?; + tmp.persist(path).map_err(|e| format!("couldn't replace {} ({e})", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn atomic_write_does_not_overwrite_an_unrelated_temporary_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("installed.json"); + let unrelated = path.with_extension("tmp"); + std::fs::write(&unrelated, b"another writer's data").unwrap(); + write_atomic(&path, b"new manifest").unwrap(); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"another writer's data"); + assert_eq!(std::fs::read(&path).unwrap(), b"new manifest"); + } + + #[test] + fn atomic_write_replaces_a_complete_file_and_cleans_up_on_failure() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("installed.json"); + std::fs::write(&path, b"old manifest").unwrap(); + write_atomic(&path, b"new manifest").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new manifest"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + + let blocked = dir.path().join("directory.json"); + std::fs::create_dir(&blocked).unwrap(); + std::fs::write(blocked.join("keep.txt"), b"keep").unwrap(); + assert!(write_atomic(&blocked, b"replacement").is_err()); + assert_eq!(std::fs::read(blocked.join("keep.txt")).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2); + } + + #[cfg(unix)] + #[test] + fn atomic_write_does_not_follow_a_temporary_file_symlink() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("installed.json"); + let victim = dir.path().join("unrelated.txt"); + std::fs::write(&victim, b"keep this file").unwrap(); + std::os::unix::fs::symlink(&victim, path.with_extension("tmp")).unwrap(); + write_atomic(&path, b"new manifest").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep this file"); + assert_eq!(std::fs::read(&path).unwrap(), b"new manifest"); + } +} diff --git a/crates/update/src/platform.rs b/crates/update/src/platform.rs new file mode 100644 index 00000000..a16a852d --- /dev/null +++ b/crates/update/src/platform.rs @@ -0,0 +1,423 @@ +//! Platforms, package formats, and choosing the right release asset. +//! +//! Every Craft App release names its files `---[-portable].`, for +//! example `photocraft-0.5.0-windows-x64.msi` or `photocraft-0.5.0-linux-aarch64.AppImage`. +//! +//! Labels are English. The UI translates [`PackageKind::label`]; OS and architecture names are +//! shown as they are. + +use serde::{Deserialize, Serialize}; + +use crate::release::{Asset, Release}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Os { + Windows, + MacOs, + Linux, + FreeBsd, +} + +impl Os { + pub fn label(self) -> &'static str { + match self { + Self::Windows => "Windows", + Self::MacOs => "macOS", + Self::Linux => "Linux", + Self::FreeBsd => "FreeBSD", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Arch { + X64, + X86, + Arm64, + Riscv64, +} + +impl Arch { + pub fn label(self) -> &'static str { + match self { + Self::X64 => "x64", + Self::X86 => "x86", + Self::Arm64 => "ARM64", + Self::Riscv64 => "RISC-V", + } + } +} + +/// A package format a release ships. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PackageKind { + /// Windows Installer package (installs for all users into Program Files). + Msi, + /// Windows zip with the exe at the top level. + PortableZip, + /// macOS disk image with the `.app` bundle. + Dmg, + AppImage, + Deb, + Rpm, + /// Arch Linux package (`.pkg.tar.zst`). + ArchPkg, + Flatpak, + /// Linux or FreeBSD tarball (`bin/`, `share/…`). + TarGz, +} + +impl PackageKind { + pub fn label(self) -> &'static str { + match self { + Self::Msi => "Installer (.msi)", + Self::PortableZip => "Portable (.zip)", + Self::Dmg => "Disk image (.dmg)", + Self::AppImage => "AppImage", + Self::Deb => "Debian package (.deb)", + Self::Rpm => "RPM package (.rpm)", + Self::ArchPkg => "Arch package (.pkg.tar.zst)", + Self::Flatpak => "Flatpak bundle", + Self::TarGz => "Tarball (.tar.gz)", + } + } + + fn suffix(self) -> &'static str { + match self { + Self::Msi => ".msi", + Self::PortableZip => "-portable.zip", + Self::Dmg => ".dmg", + Self::AppImage => ".AppImage", + Self::Deb => ".deb", + Self::Rpm => ".rpm", + Self::ArchPkg => ".pkg.tar.zst", + Self::Flatpak => ".flatpak", + Self::TarGz => ".tar.gz", + } + } +} + +/// Where the launcher is running (or, on the web, the visitor's machine). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Platform { + pub os: Os, + pub arch: Arch, +} + +impl Platform { + /// The platform this binary was built for, or `None` for wasm and unsupported targets. + pub fn current() -> Option { + let os = if cfg!(target_os = "windows") { + Os::Windows + } else if cfg!(target_os = "macos") { + Os::MacOs + } else if cfg!(target_os = "linux") { + Os::Linux + } else if cfg!(target_os = "freebsd") { + Os::FreeBsd + } else { + return None; + }; + let arch = if cfg!(target_arch = "x86_64") { + Arch::X64 + } else if cfg!(target_arch = "x86") { + Arch::X86 + } else if cfg!(target_arch = "aarch64") { + Arch::Arm64 + } else if cfg!(target_arch = "riscv64") { + Arch::Riscv64 + } else { + return None; + }; + Some(Self { os, arch }) + } + + pub fn label(self) -> String { + match self.os { + Os::MacOs => "macOS".into(), + os => format!("{} {}", os.label(), self.arch.label()), + } + } + + /// Asset platform tokens to try, best first. Windows on ARM and 64-bit Windows can fall back + /// to emulated builds; macOS prefers universal, then native builds. Apple silicon can fall + /// back to Intel builds, but Intel Macs cannot run ARM builds. + fn tokens(self) -> &'static [&'static str] { + match (self.os, self.arch) { + (Os::Windows, Arch::X64) => &["windows-x64", "windows-x86"], + (Os::Windows, Arch::X86) => &["windows-x86"], + (Os::Windows, Arch::Arm64) => &["windows-arm64", "windows-x64", "windows-x86"], + (Os::Windows, _) => &[], + (Os::MacOs, Arch::X64) => &["macos-universal", "macos-x86_64"], + (Os::MacOs, Arch::Arm64) => &["macos-universal", "macos-aarch64", "macos-arm64", "macos-x86_64"], + (Os::MacOs, _) => &[], + (Os::Linux, Arch::X64) => &["linux-x86_64"], + (Os::Linux, Arch::Arm64) => &["linux-aarch64"], + (Os::Linux, Arch::Riscv64) => &["linux-riscv64"], + (Os::Linux, Arch::X86) => &[], + (Os::FreeBsd, Arch::X64) => &["freebsd-x86_64"], + (Os::FreeBsd, Arch::Arm64) => &["freebsd-aarch64"], + (Os::FreeBsd, _) => &[], + } + } + + /// Package formats the launcher installs on this OS, in order of preference. + pub fn default_kinds(self) -> &'static [PackageKind] { + match self.os { + Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip], + Os::MacOs => &[PackageKind::Dmg], + Os::Linux => &[PackageKind::AppImage, PackageKind::TarGz], + Os::FreeBsd => &[PackageKind::TarGz], + } + } + + /// Every format offered as a manual download on this OS (the web build's download menu). + pub fn download_kinds(self) -> &'static [PackageKind] { + match self.os { + Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip], + Os::MacOs => &[PackageKind::Dmg], + Os::Linux => &[PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz], + Os::FreeBsd => &[PackageKind::TarGz], + } + } +} + +/// What an asset name says about itself. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetName { + pub stem: String, + pub version: String, + pub token: String, + pub kind: PackageKind, +} + +/// Reads `---[-portable].`; `None` for checksums, zsync files, CLI +/// and web bundles, or anything else. +pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option { + const KINDS: [PackageKind; 9] = [ + PackageKind::PortableZip, + PackageKind::TarGz, + PackageKind::Msi, + PackageKind::Dmg, + PackageKind::AppImage, + PackageKind::Deb, + PackageKind::Rpm, + PackageKind::ArchPkg, + PackageKind::Flatpak, + ]; + let (kind, base) = KINDS.iter().find_map(|k| name.strip_suffix(k.suffix()).map(|b| (*k, b)))?; + for stem in stems { + let Some(rest) = base.strip_prefix(stem).and_then(|r| r.strip_prefix('-')) else { continue }; + // rest = --; os and arch never contain '-', versions may. + let mut parts = rest.rsplitn(3, '-'); + let (arch, os, version) = (parts.next()?, parts.next()?, parts.next()?); + if crate::version::parse(version).is_none() || !version.starts_with(|c: char| c.is_ascii_digit()) { + continue; + } + return Some(AssetName { stem: (*stem).to_string(), version: version.to_string(), token: format!("{os}-{arch}"), kind }); + } + None +} + +/// The best asset of `release` for `platform`, trying `kinds` in order and, within a kind, the +/// platform's tokens in order (native before emulated). +pub fn pick_asset<'a>(release: &'a Release, stems: &[&str], platform: Platform, kinds: &[PackageKind]) -> Option<(&'a Asset, PackageKind)> { + let parsed: Vec<(&Asset, AssetName)> = release.assets.iter().filter_map(|a| parse_asset_name(&a.name, stems).map(|n| (a, n))).collect(); + for kind in kinds { + for token in platform.tokens() { + if let Some((asset, _)) = parsed.iter().find(|(_, n)| n.kind == *kind && n.token == *token) { + return Some((asset, *kind)); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(names: &[&str]) -> Release { + Release { + assets: names.iter().map(|n| Asset { name: (*n).into(), size: 1, url: format!("https://x/{n}"), sha256: None }).collect(), + ..Release::default() + } + } + + const PHOTO: &[&str] = &[ + "photocraft-0.5.0-freebsd-x86_64.tar.gz", + "photocraft-0.5.0-linux-aarch64.AppImage", + "photocraft-0.5.0-linux-aarch64.AppImage.zsync", + "photocraft-0.5.0-linux-aarch64.deb", + "photocraft-0.5.0-linux-riscv64.tar.gz", + "photocraft-0.5.0-linux-x86_64.AppImage", + "photocraft-0.5.0-linux-x86_64.tar.gz", + "photocraft-0.5.0-macos-universal.dmg", + "photocraft-0.5.0-windows-x64-portable.zip", + "photocraft-0.5.0-windows-x64.msi", + "photocraft-0.5.0-windows-x86.msi", + "photocraft-cli-0.5.0-macos-universal.zip", + "photocraft-web-0.5.0.zip", + "SHA256SUMS.txt", + ]; + + #[test] + fn macos_prefers_the_published_universal_build() { + // PhotoCraft v0.9.0's GitHub metadata, trimmed to its desktop DMG. + let mut r = crate::release::parse_github_release(include_str!("../tests/fixtures/photocraft-release.json"), "storytold/photocraft").unwrap(); + // Split builds must not outrank the published universal DMG, even when listed first. + r.assets.splice(0..0, release(&["photocraft-0.9.0-macos-arm64.dmg", "photocraft-0.9.0-macos-x86_64.dmg"]).assets); + for arch in [Arch::X64, Arch::Arm64] { + let (asset, kind) = pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]).unwrap(); + assert_eq!(asset.name, "photocraft-0.9.0-macos-universal.dmg"); + assert_eq!(kind, PackageKind::Dmg); + } + } + + #[test] + fn freebsd_picks_the_tarball_for_the_processor() { + // SoundCraft v0.10.0's GitHub metadata, trimmed to its FreeBSD tarballs. + let r = crate::release::parse_github_release(include_str!("../tests/fixtures/soundcraft-release.json"), "storytold/soundcraft").unwrap(); + let pick = |arch| { + let platform = Platform { os: Os::FreeBsd, arch }; + pick_asset(&r, &["soundcraft"], platform, platform.default_kinds()).map(|(a, k)| (a.name.as_str(), k)) + }; + assert_eq!(pick(Arch::X64), Some(("soundcraft-0.10.0-freebsd-x86_64.tar.gz", PackageKind::TarGz))); + assert_eq!(pick(Arch::Arm64), Some(("soundcraft-0.10.0-freebsd-aarch64.tar.gz", PackageKind::TarGz))); + for arch in [Arch::X86, Arch::Riscv64] { + assert_eq!(pick(arch), None); + } + // An x86_64 machine never takes the ARM tarball, nor an ARM machine the x86_64 one. + let only_arm = release(&["soundcraft-0.10.0-freebsd-aarch64.tar.gz"]); + assert_eq!(pick_asset(&only_arm, &["soundcraft"], Platform { os: Os::FreeBsd, arch: Arch::X64 }, &[PackageKind::TarGz]), None); + let only_x64 = release(&["soundcraft-0.10.0-freebsd-x86_64.tar.gz"]); + assert_eq!(pick_asset(&only_x64, &["soundcraft"], Platform { os: Os::FreeBsd, arch: Arch::Arm64 }, &[PackageKind::TarGz]), None); + } + + #[test] + fn macos_split_builds_match_the_processor() { + let r = release(&["photocraft-0.5.0-macos-arm64.dmg", "photocraft-0.5.0-macos-x86_64.dmg"]); + let pick = |arch| pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()); + assert_eq!(pick(Arch::X64), Some("photocraft-0.5.0-macos-x86_64.dmg")); + assert_eq!(pick(Arch::Arm64), Some("photocraft-0.5.0-macos-arm64.dmg")); + } + + #[test] + fn intel_macos_never_selects_an_arm_only_build() { + for token in ["macos-aarch64", "macos-arm64"] { + let name = format!("photocraft-0.5.0-{token}.dmg"); + let r = release(&[&name]); + assert_eq!(pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::X64 }, &[PackageKind::Dmg]), None); + assert_eq!( + pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::Arm64 }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()), + Some(name.as_str()) + ); + } + } + + #[test] + fn apple_silicon_keeps_the_intel_fallback() { + let r = release(&["photocraft-0.5.0-macos-x86_64.dmg"]); + assert_eq!( + pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::Arm64 }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()), + Some("photocraft-0.5.0-macos-x86_64.dmg") + ); + for arch in [Arch::X86, Arch::Riscv64] { + assert_eq!(pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]), None); + } + } + + #[test] + fn reads_asset_names() { + let n = parse_asset_name("photocraft-0.2.0-rc.1-windows-arm64-portable.zip", &["photocraft"]).unwrap(); + assert_eq!((n.version.as_str(), n.token.as_str(), n.kind), ("0.2.0-rc.1", "windows-arm64", PackageKind::PortableZip)); + let n = parse_asset_name("printcraft-0.2.1-linux-x86_64.tar.gz", &["pdfcraft", "printcraft"]).unwrap(); + assert_eq!((n.stem.as_str(), n.kind), ("printcraft", PackageKind::TarGz)); + for other in + ["photocraft-cli-0.5.0-macos-universal.zip", "photocraft-web-0.5.0.zip", "SHA256SUMS.txt", "photocraft-0.5.0-linux-x86_64.AppImage.zsync"] + { + assert_eq!(parse_asset_name(other, &["photocraft"]), None, "{other}"); + } + // FigCraft also ships its collaboration server; that tarball is not the app. + assert_eq!(parse_asset_name("figcraft-server-0.5.1-linux-x86_64.tar.gz", &["figcraft"]), None); + assert!(parse_asset_name("figcraft-0.5.1-linux-x86_64.tar.gz", &["figcraft"]).is_some()); + } + + #[test] + fn picks_the_native_build_first_and_falls_back_to_emulation() { + let r = release(PHOTO); + let pick = |os, arch, kinds: &[PackageKind]| pick_asset(&r, &["photocraft"], Platform { os, arch }, kinds).map(|(a, _)| a.name.clone()); + assert_eq!(pick(Os::MacOs, Arch::Arm64, &[PackageKind::Dmg]).as_deref(), Some("photocraft-0.5.0-macos-universal.dmg")); + assert_eq!(pick(Os::Windows, Arch::X64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi")); + // No arm64 MSI in this release: Windows on ARM runs the x64 build. + assert_eq!(pick(Os::Windows, Arch::Arm64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi")); + assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::AppImage]).as_deref(), Some("photocraft-0.5.0-linux-aarch64.AppImage")); + assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::TarGz]), None); + assert_eq!(pick(Os::Linux, Arch::Riscv64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-linux-riscv64.tar.gz")); + assert_eq!(pick(Os::FreeBsd, Arch::X64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-freebsd-x86_64.tar.gz")); + // Preference order across kinds. + assert_eq!( + pick(Os::Windows, Arch::X64, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(), + Some("photocraft-0.5.0-windows-x64-portable.zip") + ); + assert_eq!(pick(Os::Windows, Arch::X86, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x86.msi")); + + let p_riscv = Platform { os: Os::Linux, arch: Arch::Riscv64 }; + assert_eq!( + pick_asset(&r, &["photocraft"], p_riscv, p_riscv.default_kinds()).map(|(a, _)| a.name.clone()).as_deref(), + Some("photocraft-0.5.0-linux-riscv64.tar.gz") + ); + assert_eq!( + pick_asset(&r, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(), + Some("photocraft-0.5.0-linux-riscv64.tar.gz") + ); + let r_riscv_deb = + release(&["photocraft-0.5.0-linux-riscv64.tar.gz", "photocraft-0.5.0-linux-riscv64.deb", "photocraft-0.5.0-linux-riscv64.rpm"]); + assert_eq!( + pick_asset(&r_riscv_deb, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(), + Some("photocraft-0.5.0-linux-riscv64.deb") + ); + } + + #[test] + fn strictly_matches_architecture_for_all_linux_package_formats() { + let multi_arch_release = release(&[ + "photocraft-0.5.0-linux-aarch64.AppImage", + "photocraft-0.5.0-linux-x86_64.AppImage", + "photocraft-0.5.0-linux-aarch64.deb", + "photocraft-0.5.0-linux-x86_64.deb", + "photocraft-0.5.0-linux-aarch64.rpm", + "photocraft-0.5.0-linux-x86_64.rpm", + "photocraft-0.5.0-linux-aarch64.pkg.tar.zst", + "photocraft-0.5.0-linux-x86_64.pkg.tar.zst", + "photocraft-0.5.0-linux-aarch64.flatpak", + "photocraft-0.5.0-linux-x86_64.flatpak", + "photocraft-0.5.0-linux-aarch64.tar.gz", + "photocraft-0.5.0-linux-x86_64.tar.gz", + ]); + + let p_x64 = Platform { os: Os::Linux, arch: Arch::X64 }; + let p_arm64 = Platform { os: Os::Linux, arch: Arch::Arm64 }; + + for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] { + let pick_x64 = pick_asset(&multi_arch_release, &["photocraft"], p_x64, &[kind]).map(|(a, _)| a.name.clone()); + assert!(pick_x64.as_deref().unwrap().contains("linux-x86_64"), "Expected x86_64 asset for {kind:?}, got {pick_x64:?}"); + + let pick_arm64 = pick_asset(&multi_arch_release, &["photocraft"], p_arm64, &[kind]).map(|(a, _)| a.name.clone()); + assert!(pick_arm64.as_deref().unwrap().contains("linux-aarch64"), "Expected aarch64 asset for {kind:?}, got {pick_arm64:?}"); + } + + // On x86_64 Linux, an aarch64-only asset must NEVER match or fall back + let arm_only_release = release(&[ + "photocraft-0.5.0-linux-aarch64.AppImage", + "photocraft-0.5.0-linux-aarch64.deb", + "photocraft-0.5.0-linux-aarch64.rpm", + "photocraft-0.5.0-linux-aarch64.pkg.tar.zst", + "photocraft-0.5.0-linux-aarch64.flatpak", + "photocraft-0.5.0-linux-aarch64.tar.gz", + ]); + for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] { + assert_eq!(pick_asset(&arm_only_release, &["photocraft"], p_x64, &[kind]), None, "x86_64 must never match an aarch64 asset for {kind:?}"); + } + } +} diff --git a/crates/update/src/portable.rs b/crates/update/src/portable.rs new file mode 100644 index 00000000..257a2290 --- /dev/null +++ b/crates/update/src/portable.rs @@ -0,0 +1,857 @@ +//! Portable Windows packages: bounded extraction and replacement of recorded files only. + +use std::ffi::OsStr; +use std::path::{Component, Path, PathBuf}; + +use crate::manifest::Record; +use crate::paths::Paths; +use crate::{App, PackageKind}; + +const MAX_FILES: usize = 10_000; +const MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +/// Custom destinations may have OS-managed links in their host ancestors. +pub fn validate_directory(path: &Path) -> Result<(), String> { + if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir | Component::CurDir)) { + return Err("Choose an absolute installation folder without parent-directory components.".into()); + } + // Resolve the closest existing ancestor: /home and /var can be OS-managed + // symlinks, and a custom destination need not exist yet. + for ancestor in path.ancestors() { + match std::fs::canonicalize(ancestor) { + Ok(resolved) => { + if !resolved.is_dir() { + return Err("The installation folder contains a non-directory component.".into()); + } + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // A dangling link is not a missing directory we can safely create. + if std::fs::symlink_metadata(ancestor).is_ok() { + return Err("The installation folder contains a dangling link.".into()); + } + } + Err(error) => return Err(error.to_string()), + } + } + Err("The installation folder has no existing ancestor.".into()) +} + +/// Links are allowed above the ownership boundary, never inside it (including root). +fn validate_owned_directory(root: &Path, path: &Path) -> Result<(), String> { + validate_directory(path)?; + if !path.starts_with(root) { + return Err("An installation path points outside its folder.".into()); + } + for ancestor in path.ancestors().take_while(|ancestor| ancestor.starts_with(root)) { + match std::fs::symlink_metadata(ancestor) { + Ok(meta) if linked(&meta) || !meta.is_dir() => { + return Err("The installation folder contains a link or a non-directory component.".into()); + } + Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error.to_string()), + _ => {} + } + } + Ok(()) +} + +fn resolve_missing(path: &Path) -> Result { + for ancestor in path.ancestors() { + match std::fs::canonicalize(ancestor) { + Ok(resolved) => { + let suffix = path.strip_prefix(ancestor).map_err(|e| e.to_string())?; + return Ok(resolved.join(suffix)); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if std::fs::symlink_metadata(ancestor).is_ok() { + return Err("The installation folder contains a dangling link.".into()); + } + } + Err(error) => return Err(error.to_string()), + } + } + Err("The installation folder has no existing ancestor.".into()) +} + +/// Whether two paths name the same Windows folder or file: case, trailing separators and the +/// verbatim `\\?\` form that `canonicalize` returns don't count. +fn same_windows_path(left: &Path, right: &Path) -> bool { + /// `canonicalize` writes `\\?\C:\…` and, for a network share, `\\?\UNC\server\share\…`; + /// Windows reads either prefix in any case. + fn normalized(path: &Path) -> String { + let text = path.to_string_lossy().to_lowercase(); + let text = match text.strip_prefix(r"\\?\unc\") { + Some(share) => format!(r"\\{share}"), + None => text.strip_prefix(r"\\?\").unwrap_or(&text).to_string(), + }; + text.trim_end_matches(['\\', '/']).to_string() + } + normalized(left) == normalized(right) +} + +fn linked(meta: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + meta.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { + meta.file_type().is_symlink() + } +} + +struct Staging(PathBuf); +impl Staging { + /// Stop cleaning up and hand the directory back: used when a rollback failed and the backup + /// is the only copy left for manual recovery. + fn keep(self) -> PathBuf { + let path = self.0.clone(); + // Deliberately skipping Drop here is the whole point — it would delete the recovery + // files. The directory is handed to the operator, not leaked into nothing. + std::mem::forget(self); // NOSONAR + path + } +} +impl Drop for Staging { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn extract(file: &Path, destination: &Path) -> Result<(Vec, Vec), String> { + let file = std::fs::File::open(file).map_err(|e| e.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("couldn't read the portable ZIP ({e})"))?; + if archive.len() > MAX_FILES { + return Err("The portable ZIP contains too many files.".into()); + } + let mut files = Vec::new(); + let mut entries = Vec::new(); + let mut total = 0u64; + let mut seen = std::collections::BTreeSet::new(); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|e| e.to_string())?; + let relative = entry.enclosed_name().ok_or("The portable ZIP contains an unsafe path.")?; + if relative.components().any(|c| !matches!(c, Component::Normal(_))) + || entry.name().contains(['\\', ':']) + || entry.unix_mode().is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err("The portable ZIP contains an unsafe path or link.".into()); + } + // Windows treats case variants as the same file; reject duplicate and colliding names. + if !seen.insert(relative.to_string_lossy().to_lowercase()) { + return Err("The portable ZIP contains duplicate paths.".into()); + } + let target = destination.join(&relative); + entries.push(relative.clone()); + if entry.is_dir() { + std::fs::create_dir_all(&target).map_err(|e| e.to_string())?; + continue; + } + total = total.checked_add(entry.size()).filter(|size| *size <= MAX_BYTES).ok_or("The portable ZIP is too large.")?; + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(&target).map_err(|e| e.to_string())?; + use std::io::Read; + let declared = entry.size(); + let copied = std::io::copy(&mut (&mut entry).take(declared.saturating_add(1)), &mut output).map_err(|e| e.to_string())?; + if copied != declared { + return Err("The portable ZIP has an unexpected unpacked size.".into()); + } + files.push(relative); + } + Ok((files, entries)) +} + +fn recorded_files<'a>(app: &App, record: &'a Record) -> Result<(&'a Path, Vec), String> { + let root = record.path.parent().ok_or("The portable installation has no parent folder.")?; + if record.kind != PackageKind::PortableZip || root.file_name().is_none_or(|n| n != app.id) { + return Err("The launcher has no valid ownership record for this portable folder.".into()); + } + let resolved = resolve_missing(root)?; + if !same_windows_path(root, &resolved) { + return Err("The recorded portable folder resolves through a changed alias. Reinstall it to choose a safe location.".into()); + } + validate_owned_directory(root, root)?; + let mut files = Vec::new(); + for file in &record.files { + let relative = file.strip_prefix(root).map_err(|_| "An installation record points outside its folder.")?; + if relative.as_os_str().is_empty() || relative.components().any(|c| !matches!(c, Component::Normal(_))) { + return Err("The installation record contains an unsafe path.".into()); + } + validate_owned_directory(root, file.parent().ok_or("A recorded file has no parent folder.")?)?; + if let Ok(meta) = std::fs::symlink_metadata(file) + && (linked(&meta) || !meta.is_file()) + { + return Err("A recorded file was replaced by a link or directory.".into()); + } + files.push(relative.to_path_buf()); + } + Ok((root, files)) +} + +/// Keeps existing portable installations at their location and preserves unrecorded user files, +/// unless `replace` allows writing over them. +#[allow(clippy::too_many_arguments)] +pub fn install( + app: &App, + file: &Path, + version: &str, + previous: Option<&Record>, + paths: &Paths, + directory: Option<&Path>, + replace: bool, + commit: &mut dyn FnMut(&Record) -> Result<(), String>, +) -> Result { + let (destination, mut old_files) = match previous { + Some(record) => { + let (root, files) = recorded_files(app, record)?; + (root.to_path_buf(), files) + } + None => { + let parent = directory.map(Path::to_path_buf).unwrap_or_else(|| paths.apps()); + validate_directory(&parent)?; + let parent = resolve_missing(&parent)?; + (parent.join(app.id), Vec::new()) + } + }; + validate_owned_directory(&destination, &destination)?; + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_nanos(); + let staging = paths.cache.join("staging").join(format!("{}-{}-{stamp}", app.id, std::process::id())); + validate_owned_directory(&paths.cache, &staging)?; + std::fs::create_dir_all(staging.parent().ok_or("no staging folder")?).map_err(|e| e.to_string())?; + std::fs::create_dir(&staging).map_err(|e| e.to_string())?; + let staging = Staging(staging); + let unpacked = staging.0.join("unpacked"); + std::fs::create_dir(&unpacked).map_err(|e| e.to_string())?; + let (files, entries) = extract(file, &unpacked)?; + // Accept both a flat layout (files at the ZIP root) and a single wrapper-folder layout, + // but never arbitrary nesting. + let (unpacked, files) = flatten_zip_layout(app, &unpacked, files, &entries)?; + let executable = files + .iter() + .find(|relative| is_root_executable(relative, app)) + .ok_or("The portable ZIP does not contain the expected executable at its root.")? + .clone(); + // Validate the entire transaction before moving any existing program file. + for relative in &files { + let target = destination.join(relative); + validate_owned_directory(&destination, target.parent().ok_or("no destination folder")?)?; + if let Ok(meta) = std::fs::symlink_metadata(&target) { + let recorded = old_files.iter().any(|old| same_windows_path(old, relative)); + if linked(&meta) || !meta.is_file() || !(recorded || replace) { + return Err("The installation would overwrite an unrecorded file.".into()); + } + if !recorded { + old_files.push(relative.clone()); + } + } + } + std::fs::create_dir_all(&destination).map_err(|e| e.to_string())?; + let backup = staging.0.join("backup"); + let record = Record { + version: version.to_string(), + kind: PackageKind::PortableZip, + path: destination.join(executable), + installed_at: crate::now(), + files: files.iter().map(|relative| destination.join(relative)).collect(), + needs_root: false, + }; + let mut moved = Vec::new(); + let mut copied = Vec::new(); + let transaction = (|| { + for relative in &old_files { + let old = destination.join(relative); + if !old.exists() { + continue; + } + let saved = backup.join(relative); + std::fs::create_dir_all(saved.parent().ok_or("no backup folder")?).map_err(|e| e.to_string())?; + // Copy the rollback copy: cache and custom installation can be on different drives. + std::fs::copy(&old, &saved).map_err(|e| e.to_string())?; + std::fs::remove_file(&old).map_err(|e| e.to_string())?; + moved.push(relative.clone()); + } + for relative in &files { + let target = destination.join(relative); + std::fs::create_dir_all(target.parent().ok_or("no destination folder")?).map_err(|e| e.to_string())?; + let mut input = std::fs::File::open(unpacked.join(relative)).map_err(|e| e.to_string())?; + let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(&target).map_err(|e| e.to_string())?; + copied.push(relative.clone()); + std::io::copy(&mut input, &mut output).map_err(|e| e.to_string())?; + } + commit(&record)?; + Ok::<(), String>(()) + })(); + if let Err(error) = transaction { + for relative in copied { + let _ = std::fs::remove_file(destination.join(relative)); + } + for relative in moved { + if let Err(restore) = std::fs::copy(backup.join(&relative), destination.join(&relative)) { + // Preserve backups for manual recovery if the disk won't accept a rollback. + let backup_path = staging.keep(); + return Err(format!("{error}; could not restore the previous version ({restore}). Recovery files: {}", backup_path.display())); + } + } + return Err(error); + } + Ok(record) +} + +/// Whether a ZIP-relative path sits at the archive root and is one of the app's executables. +fn is_root_executable(relative: &Path, app: &App) -> bool { + relative.parent().is_some_and(|parent| parent.as_os_str().is_empty()) + && relative.file_name().and_then(OsStr::to_str).is_some_and(|name| app.stems().any(|stem| name.eq_ignore_ascii_case(&format!("{stem}.exe")))) +} + +/// Flattens a portable ZIP whose files all sit in one top-level folder (the official Windows +/// packaging script's `Compress-Archive -Path $Portable` puts everything under a versioned +/// directory) into the flat layout a root launcher exe requires, passing already-flat layouts +/// through. ZIPs mixing unrelated top-level folders are rejected. +fn flatten_zip_layout(app: &App, unpacked: &Path, files: Vec, entries: &[PathBuf]) -> Result<(PathBuf, Vec), String> { + if files.iter().any(|relative| is_root_executable(relative, app)) { + return Ok((unpacked.to_path_buf(), files)); + } + let prefix = files.first().and_then(|path| path.components().next()).ok_or("The portable ZIP contains no files.")?.as_os_str().to_os_string(); + let prefix = PathBuf::from(prefix); + // Include explicit ZIP directory entries as well as extracted files. + // The wrapper directory itself is allowed; unrelated roots are not. + if !entries.iter().all(|path| path.starts_with(&prefix)) + || !files.iter().all(|path| path.strip_prefix(&prefix).is_ok_and(|relative| relative.components().next().is_some())) + { + return Err("The portable ZIP has an unsupported directory layout.".into()); + } + let unpacked = unpacked.join(&prefix); + let files: Vec = + files.into_iter().map(|path| path.strip_prefix(&prefix).map(Path::to_path_buf).map_err(|e| e.to_string())).collect::, _>>()?; + Ok((unpacked, files)) +} + +/// Removes only recorded program files. Personal settings and documents remain. +pub fn remove(app: &App, record: &Record) -> Result<(), String> { + let (root, files) = recorded_files(app, record)?; + for relative in &files { + let file = root.join(relative); + if file.exists() { + std::fs::remove_file(&file).map_err(|e| e.to_string())?; + } + } + for relative in &files { + let mut directory = root.join(relative).parent().map(Path::to_path_buf); + while let Some(path) = directory.filter(|p| p.starts_with(root)) { + if std::fs::remove_dir(&path).is_err() || path == root { + break; + } + directory = path.parent().map(Path::to_path_buf); + } + } + Ok(()) +} + +/// Unpacks the launcher's own portable zip into `staging`, with the same checks as an app's, and +/// copies it over `folder` for a self-update. `check` must accept every unpacked file before +/// any is copied, and again before the backup is removed. The archive must include `executable`. +/// When every file sits in one top-level folder, that folder's contents are the program. A copy +/// that fails puts the previous files back. When one of them can't be put back, +/// the previous files move to a `-recovery-