wai-quantum-desktop 0.1.1: an app on an external drive updates itself, and one on a disk image does not #580

Merged
dcharlot merged 1 commit from wai/quantum-desktop-0.1.1-b into main 2026-11-02 18:27:38 -05:00
6 changed files with 64 additions and 22 deletions

View file

@ -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; }

View file

@ -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`

View file

@ -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",

View file

@ -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.

View file

@ -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<BundleType>, exe: &Path) -> Result<&'static str, String> {
fn installed_by(bundle: Option<BundleType>, 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<BundleType>, 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<BundleType>, exe: &Path) -> Result<&'static str, String> {
fn updates_itself(key: &str, bundle: Option<BundleType>, 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<BundleType>, exe: &Path) -> Result<&
fn update_status() -> Result<String, String> {
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<Option<tauri_plugin_updater::Update>>);
#[tauri::command]
async fn update_check(app: tauri::AppHandle, pending: tauri::State<'_, PendingUpdate>) -> Result<String, String> {
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

View file

@ -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"