wai-quantum-desktop 0.1.1: an app on an external drive updates itself, and one on a disk image does not #573
6 changed files with 64 additions and 22 deletions
2
.github/workflows/wai-quantum-studio.yml
vendored
2
.github/workflows/wai-quantum-studio.yml
vendored
|
|
@ -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::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::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; }
|
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; }
|
grep -q "^test tests::$t \.\.\. ok\$" "$RUNNER_TEMP/test.log" || { echo "::error::the update test $t did not run and pass"; exit 1; }
|
||||||
done
|
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; }
|
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; }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,17 @@ wire format may change and such changes are recorded here.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### 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, and nothing else in the app changes. 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.
|
||||||
|
|
||||||
### The route record states what a hop delivered by its count and root, so a hop may deliver any number of objects
|
### The route record states what a hop delivered by its count and root, so a hop may deliver any number of objects
|
||||||
|
|
||||||
- **Route record version 3** ([extensions/route-record.md](extensions/route-record.md) §3.4, §5.2, §6, §6.1), on a
|
- **Route record version 3** ([extensions/route-record.md](extensions/route-record.md) §3.4, §5.2, §6, §6.1), on a
|
||||||
|
|
|
||||||
3
wai/wai-quantum-desktop/Cargo.lock
generated
3
wai/wai-quantum-desktop/Cargo.lock
generated
|
|
@ -4477,10 +4477,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wai-quantum-desktop"
|
name = "wai-quantum-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
|
"rustix",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "wai-quantum-desktop"
|
name = "wai-quantum-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.97"
|
rust-version = "1.97"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
@ -29,6 +29,9 @@ tauri-plugin-updater = "2.14.0"
|
||||||
minisign-verify = "0.3.0"
|
minisign-verify = "0.3.0"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
rustix = { version = "1.1.5", features = ["fs"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["custom-protocol"]
|
default = ["custom-protocol"]
|
||||||
# The interface served from the bundled build (../quantum-studio/dist), as a release does.
|
# The interface served from the bundled build (../quantum-studio/dist), as a release does.
|
||||||
|
|
|
||||||
|
|
@ -796,10 +796,10 @@ fn update_key() -> String {
|
||||||
/// Windows installer. `Err` says why this copy is not one an installer made:
|
/// 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
|
/// it was built from source, or unpacked by a package manager from the
|
||||||
/// portable archive, and is updated the way it came.
|
/// 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 {
|
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.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::App) => Ok("app"),
|
||||||
Some(BundleType::AppImage) => Ok("appimage"),
|
Some(BundleType::AppImage) => Ok("appimage"),
|
||||||
Some(BundleType::Deb) => Ok("deb"),
|
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,
|
/// 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
|
/// `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.
|
/// 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() {
|
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());
|
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
|
/// `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> {
|
fn update_status() -> Result<String, String> {
|
||||||
let exe = tauri::utils::platform::current_exe().map_err(|e| e.to_string())?;
|
let exe = tauri::utils::platform::current_exe().map_err(|e| e.to_string())?;
|
||||||
let bundle = tauri::utils::platform::bundle_type();
|
let bundle = tauri::utils::platform::bundle_type();
|
||||||
let it = updates_itself(&update_key(), bundle.clone(), &exe);
|
let read_only = on_read_only_volume(&exe);
|
||||||
Ok(json!({ "installed_by": installed_by(bundle, &exe).ok(), "updates_itself": it.is_ok(), "why_not": it.err() }).to_string())
|
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.
|
/// Text that the update manifest carries as base64, decoded.
|
||||||
|
|
@ -866,7 +880,7 @@ struct PendingUpdate(Mutex<Option<tauri_plugin_updater::Update>>);
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn update_check(app: tauri::AppHandle, pending: tauri::State<'_, PendingUpdate>) -> Result<String, String> {
|
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())?;
|
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());
|
return Ok(json!({ "state": "off", "reason": reason }).to_string());
|
||||||
}
|
}
|
||||||
let current = app.package_info().version.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() {
|
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 app = Path::new("/Applications/Quantum Studio.app/Contents/MacOS/wai-quantum-desktop");
|
||||||
let key = b64(TEST_KEY);
|
let key = b64(TEST_KEY);
|
||||||
assert_eq!(updates_itself(&key, Some(BundleType::App), app), Ok("app"));
|
assert_eq!(updates_itself(&key, Some(BundleType::App), app, false), Ok("app"));
|
||||||
assert_eq!(updates_itself(&key, Some(BundleType::AppImage), Path::new("/home/a/Quantum Studio.AppImage")), Ok("appimage"));
|
// An app on an external drive is under /Volumes too, and it can update itself.
|
||||||
assert_eq!(updates_itself(&key, Some(BundleType::Deb), Path::new("/usr/bin/wai-quantum-desktop")), Ok("deb"));
|
let external = Path::new("/Volumes/Scratch/Applications/Quantum Studio.app/Contents/MacOS/wai-quantum-desktop");
|
||||||
assert_eq!(updates_itself(&key, Some(BundleType::Rpm), Path::new("/usr/bin/wai-quantum-desktop")), Ok("rpm"));
|
assert_eq!(updates_itself(&key, Some(BundleType::App), external, false), Ok("app"));
|
||||||
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::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:?}");
|
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("", Some(BundleType::App), app, false), "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("/src/target/release/wai-quantum-desktop"), false), "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");
|
// A disk image can be mounted anywhere: what says so is that the volume cannot be written.
|
||||||
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(&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.
|
// How a copy was installed does not depend on the key.
|
||||||
assert_eq!(installed_by(Some(BundleType::AppImage), Path::new("/a")), Ok("appimage"));
|
assert_eq!(installed_by(Some(BundleType::AppImage), Path::new("/a"), false), Ok("appimage"));
|
||||||
assert!(installed_by(None, Path::new("/a")).is_err());
|
assert!(installed_by(None, Path::new("/a"), false).is_err());
|
||||||
// This test's own binary was built by cargo: no installer made it.
|
// This test's own binary was built by cargo: no installer made it.
|
||||||
let s: Value = serde_json::from_str(&update_status().unwrap()).unwrap();
|
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_eq!((s["installed_by"].as_str(), s["updates_itself"].as_bool()), (None, Some(false)), "{s}");
|
||||||
assert!(s["why_not"].is_string(), "{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,
|
/// The updater asks one address, the update manifest the website serves,
|
||||||
/// over https, and installs only an update signed for the version it
|
/// over https, and installs only an update signed for the version it
|
||||||
/// announces; the installers sign updates only when the release asks
|
/// announces; the installers sign updates only when the release asks
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Quantum Studio",
|
"productName": "Quantum Studio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"identifier": "science.transaction.quantum-studio",
|
"identifier": "science.transaction.quantum-studio",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../quantum-studio/dist"
|
"frontendDist": "../quantum-studio/dist"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue