diff --git a/.github/workflows/wai-quantum-studio.yml b/.github/workflows/wai-quantum-studio.yml index 22539eef9..120fe462f 100644 --- a/.github/workflows/wai-quantum-studio.yml +++ b/.github/workflows/wai-quantum-studio.yml @@ -169,7 +169,7 @@ jobs: grep -q '^test tests::device_targets_are_listed_shown_and_placed_for_the_page \.\.\. ok$' "$RUNNER_TEMP/test.log" || { echo "::error::the device targets test did not run and pass"; exit 1; } grep -q '^test tests::an_answer_is_read_run_and_sealed_for_the_page \.\.\. ok$' "$RUNNER_TEMP/test.log" || { echo "::error::the answer views test did not run and pass"; exit 1; } grep -q '^test tests::the_course_is_read_and_checked_in_the_app \.\.\. ok$' "$RUNNER_TEMP/test.log" || { echo "::error::the course test did not run and pass"; exit 1; } - for t in an_update_is_held_to_this_build_s_key_and_the_version_it_was_signed_for only_a_copy_installed_by_its_own_installer_updates_itself the_updater_asks_the_release_s_manifest_and_requires_a_signed_version; do + for t in a_read_only_volume_is_told_from_a_writable_one an_update_is_held_to_this_build_s_key_and_the_version_it_was_signed_for only_a_copy_installed_by_its_own_installer_updates_itself the_updater_asks_the_release_s_manifest_and_requires_a_signed_version; do grep -q "^test tests::$t \.\.\. ok\$" "$RUNNER_TEMP/test.log" || { echo "::error::the update test $t did not run and pass"; exit 1; } done grep -q '^test tests::a_program_in_another_format_opens_in_the_app \.\.\. ok$' "$RUNNER_TEMP/test.log" || { echo "::error::the formats test did not run and pass"; exit 1; } diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index a568a8fa1..a175dc493 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -38,6 +38,17 @@ wire format may change and such changes are recorded here. - **Breaking:** `api:rust:wai::route_record::Reason::ALL`: the list of §8's codes holds those three, an array of 25. A use that names its length needs the new one. +### wai-quantum-desktop 0.1.1: an app on an external drive updates itself, and one on a disk image does not + +The macOS app took any path under `/Volumes` to be its disk image, and any other path not to be. An app kept on an +external drive was told to move and did not update, while a disk image mounted elsewhere, or a copy Gatekeeper had +translocated, said it would update itself and could not. It now asks whether the volume it runs from can be written. + +- **wai-quantum-desktop 0.1.1:** `update-status` and the updater read the volume's read-only flag, and say why when a + copy cannot update. The disk image is the same as before. It also carries `tauri-plugin-updater` 2.14.1 and `zip` 9, + past RUSTSEC-2026-0429. It is the first version with installers for every platform: the 0.1.0 release has none for + macOS on Apple silicon, because this check stopped that build. + ### `c2pa` 0.91 - **`c2pa` 0.90 → 0.91** behind `c2pa_emit`, off by default. It takes `zip` 9, past RUSTSEC-2026-0429 (which `c2pa` diff --git a/wai/wai-quantum-desktop/Cargo.lock b/wai/wai-quantum-desktop/Cargo.lock index 26fa2efdf..d55616346 100644 --- a/wai/wai-quantum-desktop/Cargo.lock +++ b/wai/wai-quantum-desktop/Cargo.lock @@ -4483,10 +4483,11 @@ dependencies = [ [[package]] name = "wai-quantum-desktop" -version = "0.1.0" +version = "0.1.1" dependencies = [ "base64 0.22.1", "minisign-verify", + "rustix", "serde_json", "tauri", "tauri-build", diff --git a/wai/wai-quantum-desktop/Cargo.toml b/wai/wai-quantum-desktop/Cargo.toml index fb0a824fd..353f2c5d8 100644 --- a/wai/wai-quantum-desktop/Cargo.toml +++ b/wai/wai-quantum-desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum-desktop" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -29,6 +29,9 @@ tauri-plugin-updater = "2.14.0" minisign-verify = "0.3.0" base64 = "0.22.1" +[target.'cfg(target_os = "macos")'.dependencies] +rustix = { version = "1.1.5", features = ["fs"] } + [features] default = ["custom-protocol"] # The interface served from the bundled build (../quantum-studio/dist), as a release does. diff --git a/wai/wai-quantum-desktop/src/main.rs b/wai/wai-quantum-desktop/src/main.rs index 992053657..5ab1c8ef6 100644 --- a/wai/wai-quantum-desktop/src/main.rs +++ b/wai/wai-quantum-desktop/src/main.rs @@ -796,10 +796,10 @@ fn update_key() -> String { /// Windows installer. `Err` says why this copy is not one an installer made: /// it was built from source, or unpacked by a package manager from the /// portable archive, and is updated the way it came. -fn installed_by(bundle: Option, exe: &Path) -> Result<&'static str, String> { +fn installed_by(bundle: Option, exe: &Path, read_only: bool) -> Result<&'static str, String> { match bundle { Some(BundleType::App) if !exe.parent().is_some_and(|d| d.ends_with("Contents/MacOS")) => Err("this copy was built from source, not installed as an app, so it does not update itself".into()), - Some(BundleType::App) if exe.starts_with("/Volumes") => Err("this copy runs from its disk image, which cannot be changed; move Quantum Studio to Applications and open it there".into()), + Some(BundleType::App) if read_only => Err("this copy runs from its disk image, or another place that cannot be changed; move Quantum Studio to Applications and open it there".into()), Some(BundleType::App) => Ok("app"), Some(BundleType::AppImage) => Ok("appimage"), Some(BundleType::Deb) => Ok("deb"), @@ -810,14 +810,27 @@ fn installed_by(bundle: Option, exe: &Path) -> Result<&'static str, } } +/// Whether `path` is on a volume that cannot be written: a mounted disk image, or where +/// Gatekeeper's App Translocation runs an app it has not seen moved. An app there cannot be +/// replaced, so it cannot update itself; on any other volume, an external drive among them, it can. +#[cfg(target_os = "macos")] +fn on_read_only_volume(path: &Path) -> bool { + rustix::fs::statvfs(path).is_ok_and(|v| v.f_flag.contains(rustix::fs::StatVfsMountFlags::RDONLY)) +} + +#[cfg(not(target_os = "macos"))] +fn on_read_only_volume(_path: &Path) -> bool { + false +} + /// Whether this copy updates itself: `Ok` names the installer it came from, /// `Err` says why it does not. It does only when this build carries the /// release's public key and the copy came from its own installer. -fn updates_itself(key: &str, bundle: Option, exe: &Path) -> Result<&'static str, String> { +fn updates_itself(key: &str, bundle: Option, exe: &Path, read_only: bool) -> Result<&'static str, String> { if key.trim().is_empty() { return Err("this build carries no update key, so it does not update itself; newer versions are on the release page".into()); } - installed_by(bundle, exe) + installed_by(bundle, exe, read_only) } /// `update-status`: the installer this copy came from (or null) and whether @@ -826,8 +839,9 @@ fn updates_itself(key: &str, bundle: Option, exe: &Path) -> Result<& fn update_status() -> Result { let exe = tauri::utils::platform::current_exe().map_err(|e| e.to_string())?; let bundle = tauri::utils::platform::bundle_type(); - let it = updates_itself(&update_key(), bundle.clone(), &exe); - Ok(json!({ "installed_by": installed_by(bundle, &exe).ok(), "updates_itself": it.is_ok(), "why_not": it.err() }).to_string()) + let read_only = on_read_only_volume(&exe); + let it = updates_itself(&update_key(), bundle.clone(), &exe, read_only); + Ok(json!({ "installed_by": installed_by(bundle, &exe, read_only).ok(), "updates_itself": it.is_ok(), "why_not": it.err() }).to_string()) } /// Text that the update manifest carries as base64, decoded. @@ -866,7 +880,7 @@ struct PendingUpdate(Mutex>); #[tauri::command] async fn update_check(app: tauri::AppHandle, pending: tauri::State<'_, PendingUpdate>) -> Result { let exe = tauri::utils::platform::current_exe().map_err(|e| e.to_string())?; - if let Err(reason) = updates_itself(&update_key(), tauri::utils::platform::bundle_type(), &exe) { + if let Err(reason) = updates_itself(&update_key(), tauri::utils::platform::bundle_type(), &exe, on_read_only_volume(&exe)) { return Ok(json!({ "state": "off", "reason": reason }).to_string()); } let current = app.package_info().version.to_string(); @@ -1185,25 +1199,38 @@ mod tests { fn only_a_copy_installed_by_its_own_installer_updates_itself() { let app = Path::new("/Applications/Quantum Studio.app/Contents/MacOS/wai-quantum-desktop"); let key = b64(TEST_KEY); - assert_eq!(updates_itself(&key, Some(BundleType::App), app), Ok("app")); - assert_eq!(updates_itself(&key, Some(BundleType::AppImage), Path::new("/home/a/Quantum Studio.AppImage")), Ok("appimage")); - assert_eq!(updates_itself(&key, Some(BundleType::Deb), Path::new("/usr/bin/wai-quantum-desktop")), Ok("deb")); - assert_eq!(updates_itself(&key, Some(BundleType::Rpm), Path::new("/usr/bin/wai-quantum-desktop")), Ok("rpm")); - assert_eq!(updates_itself(&key, Some(BundleType::Nsis), Path::new("C:/Users/a/AppData/Local/Quantum Studio/wai-quantum-desktop.exe")), Ok("nsis")); + assert_eq!(updates_itself(&key, Some(BundleType::App), app, false), Ok("app")); + // An app on an external drive is under /Volumes too, and it can update itself. + let external = Path::new("/Volumes/Scratch/Applications/Quantum Studio.app/Contents/MacOS/wai-quantum-desktop"); + assert_eq!(updates_itself(&key, Some(BundleType::App), external, false), Ok("app")); + assert_eq!(updates_itself(&key, Some(BundleType::AppImage), Path::new("/home/a/Quantum Studio.AppImage"), false), Ok("appimage")); + assert_eq!(updates_itself(&key, Some(BundleType::Deb), Path::new("/usr/bin/wai-quantum-desktop"), false), Ok("deb")); + assert_eq!(updates_itself(&key, Some(BundleType::Rpm), Path::new("/usr/bin/wai-quantum-desktop"), false), Ok("rpm")); + assert_eq!(updates_itself(&key, Some(BundleType::Nsis), Path::new("C:/Users/a/AppData/Local/Quantum Studio/wai-quantum-desktop.exe"), false), Ok("nsis")); let off = |r: Result<&str, String>, why: &str| assert!(r.as_ref().is_err_and(|e| e.contains(why)), "{r:?} should say {why:?}"); - off(updates_itself("", Some(BundleType::App), app), "carries no update key"); - off(updates_itself(&key, Some(BundleType::App), Path::new("/src/target/release/wai-quantum-desktop")), "built from source"); - off(updates_itself(&key, Some(BundleType::App), Path::new("/Volumes/Quantum Studio/Quantum Studio.app/Contents/MacOS/wai-quantum-desktop")), "disk image"); - off(updates_itself(&key, None, Path::new("C:/Users/a/scoop/apps/wai-quantum-desktop/current/wai-quantum-desktop.exe")), "updated the way it came"); + off(updates_itself("", Some(BundleType::App), app, false), "carries no update key"); + off(updates_itself(&key, Some(BundleType::App), Path::new("/src/target/release/wai-quantum-desktop"), false), "built from source"); + // A disk image can be mounted anywhere: what says so is that the volume cannot be written. + off(updates_itself(&key, Some(BundleType::App), Path::new("/private/tmp/m/Quantum Studio.app/Contents/MacOS/wai-quantum-desktop"), true), "disk image"); + off(updates_itself(&key, None, Path::new("C:/Users/a/scoop/apps/wai-quantum-desktop/current/wai-quantum-desktop.exe"), false), "updated the way it came"); // How a copy was installed does not depend on the key. - assert_eq!(installed_by(Some(BundleType::AppImage), Path::new("/a")), Ok("appimage")); - assert!(installed_by(None, Path::new("/a")).is_err()); + assert_eq!(installed_by(Some(BundleType::AppImage), Path::new("/a"), false), Ok("appimage")); + assert!(installed_by(None, Path::new("/a"), false).is_err()); // This test's own binary was built by cargo: no installer made it. let s: Value = serde_json::from_str(&update_status().unwrap()).unwrap(); assert_eq!((s["installed_by"].as_str(), s["updates_itself"].as_bool()), (None, Some(false)), "{s}"); assert!(s["why_not"].is_string(), "{s}"); } + /// The system volume is read-only on every Mac that runs this, and a temporary folder is not. + #[cfg(target_os = "macos")] + #[test] + fn a_read_only_volume_is_told_from_a_writable_one() { + assert!(on_read_only_volume(Path::new("/System/Library/CoreServices"))); + assert!(!on_read_only_volume(&std::env::temp_dir())); + assert!(!on_read_only_volume(Path::new("/no/such/path")), "not known to be read-only"); + } + /// The updater asks one address, the update manifest the website serves, /// over https, and installs only an update signed for the version it /// announces; the installers sign updates only when the release asks diff --git a/wai/wai-quantum-desktop/tauri.conf.json b/wai/wai-quantum-desktop/tauri.conf.json index 530d782f9..9381866aa 100644 --- a/wai/wai-quantum-desktop/tauri.conf.json +++ b/wai/wai-quantum-desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Quantum Studio", - "version": "0.1.0", + "version": "0.1.1", "identifier": "science.transaction.quantum-studio", "build": { "frontendDist": "../quantum-studio/dist"