mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-10 22:38:44 +02:00
* fix(updater): restore the current app when a macOS install fails On macOS `install_inner` moved the installed app into a `TempDir` backup and then renamed the new bundle into place. Neither of the two steps after that move restored the app on failure, and the `TempDir` deleted the backup on every exit path, so a failed final rename left the user with no app at the install path and no way back. The privileged fallback had the same shape: `rm -rf` the app, then `mv` the new one in. Both moves are now `replace_bundle`, which renames the current app to the backup, renames the staged bundle into place, and renames the backup back if that second step fails. The privileged script moves the current app aside, moves the new one in, and restores on failure; it deletes the previous bundle only after the new one is in place. The temp and install locations are compared with `st_dev` before anything moves, returning the existing `TempDirNotOnSameMountPoint` rather than failing after the app has already been moved, and the staged bundle root is set to 0755 because `tempfile` creates it 0700 and that mode followed it into `/Applications`. Two unit tests cover `replace_bundle`: the staged bundle ends up in place with the previous one in the backup, and a failed second rename leaves the current bundle where it was and returns that error. Closes #3505, closes #3506. * fix(updater): exchange the current and new bundles atomically on APFS Even with the restore in place there was a moment between the two renames where the install path held nothing, and a process killed in that moment left the previous app in the temp dir rather than where it belonged. `swap_bundle` calls `renamex_np` with `RENAME_SWAP`, which exchanges the two directories in one step so the install path always holds one of the two. The previous app ends up in the extraction temp dir and is removed with it. Where the file system does not support the swap (`ENOTSUP`, or `EINVAL` on older systems) the two-rename path with the restore is used as before, and a `PermissionDenied` from either still goes to the privileged fallback. `libc` is added for the macOS target only; it was already in the lockfile through tauri. One macOS test checks the exchange and skips on a file system that reports `ENOTSUP`. * fix(updater): keep the previous app when it cannot be restored If the restore rename also failed, the previous app was left only in the backup TempDir and deleted with it. Keep the backup in that case and return Error::PreviousAppNotRestored with its path. * fix(updater): quote the paths in the privileged macOS install script The paths went into single-quoted sh words inside an AppleScript string unescaped, so a quote in the app path broke the command (run as root) or panicked on the compile expect. Quote them for sh and AppleScript, and report script failures instead of panicking. * fix(updater): fall back to moving the bundles on any swap error Only ENOTSUP and EINVAL got the two-rename fallback, so volumes reporting EOPNOTSUPP or ENOSYS for RENAME_SWAP failed every update. Fall back on anything but a permission error, and have the swap test skip only off APFS. * fix(updater): stage the macOS update on the app's volume When the system temp dir was on another volume than the app, every update was refused. Create the temp dirs next to the app in that case, and compare the install path itself rather than what a symlink there points to. * test(updater): update a macOS app on another volume than the temp dir Runs the app from APFS and HFS+ disk images, covering the atomic swap and the two-rename fallback with the update staged next to the app. * fix(updater): swap the bundles atomically in the privileged macOS install The admin path ran two mv calls, leaving the install path empty between them. Run renamex_np with RENAME_SWAP as root through osascript's JavaScript bridge, and only fall back to the moves where the swap fails. * add test * cleanup --------- Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
1 parent
d4835d0e94
commit
2a9c29e004
7 files changed
+779
-63
No files matched your search
@@ -440,6 +440,46 @@ fn target_to_platforms(
|
||||
platforms
|
||||
}
|
||||
|
||||
/// Serves a 1.0.0 update of `updater_path` with `signature` under `update_platform` (none for a
|
||||
/// manifest without an update) on `UPDATE_PORT`.
|
||||
fn serve_update(
|
||||
update_platform: Option<String>,
|
||||
signature: String,
|
||||
updater_path: PathBuf,
|
||||
) -> UpdaterServer {
|
||||
UpdaterServer::spawn(UPDATE_PORT, move |request| match request.url() {
|
||||
"/" => {
|
||||
let platforms = target_to_platforms(update_platform.clone(), signature.clone());
|
||||
|
||||
let body = serde_json::to_vec(&Update {
|
||||
version: "1.0.0".into(),
|
||||
date: time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap(),
|
||||
platforms,
|
||||
})
|
||||
.unwrap();
|
||||
let len = body.len();
|
||||
let response = tiny_http::Response::new(
|
||||
tiny_http::StatusCode(200),
|
||||
Vec::new(),
|
||||
std::io::Cursor::new(body),
|
||||
Some(len),
|
||||
None,
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
"/download" => {
|
||||
let _ = request.respond(tiny_http::Response::from_file(
|
||||
File::open(&updater_path).unwrap_or_else(|_| {
|
||||
panic!("failed to open updater bundle {}", updater_path.display())
|
||||
}),
|
||||
));
|
||||
}
|
||||
_ => (),
|
||||
})
|
||||
}
|
||||
|
||||
/// A bundle to build, the update platform key the server announces it under (none for a manifest
|
||||
/// without an update) and the exit codes expected from running the app once per entry.
|
||||
type TestCase = (BundleTarget, PathBuf, Option<String>, Vec<i32>);
|
||||
@@ -749,37 +789,7 @@ fn run_update_cases(
|
||||
std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle");
|
||||
|
||||
// start the updater server, shut down at the end of the iteration even if a case panics
|
||||
let _server = UpdaterServer::spawn(UPDATE_PORT, move |request| match request.url() {
|
||||
"/" => {
|
||||
let platforms = target_to_platforms(update_platform.clone(), signature.clone());
|
||||
|
||||
let body = serde_json::to_vec(&Update {
|
||||
version: "1.0.0".into(),
|
||||
date: time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap(),
|
||||
platforms,
|
||||
})
|
||||
.unwrap();
|
||||
let len = body.len();
|
||||
let response = tiny_http::Response::new(
|
||||
tiny_http::StatusCode(200),
|
||||
Vec::new(),
|
||||
std::io::Cursor::new(body),
|
||||
Some(len),
|
||||
None,
|
||||
);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
"/download" => {
|
||||
let _ = request.respond(tiny_http::Response::from_file(
|
||||
File::open(&updater_path).unwrap_or_else(|_| {
|
||||
panic!("failed to open updater bundle {}", updater_path.display())
|
||||
}),
|
||||
));
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
let _server = serve_update(update_platform, signature, updater_path);
|
||||
|
||||
config.version = "0.1.0";
|
||||
|
||||
@@ -837,6 +847,252 @@ fn run_update_cases(
|
||||
}
|
||||
}
|
||||
|
||||
/// A disk image attached at `mount_point`, detached when it goes out of scope.
|
||||
#[cfg(target_os = "macos")]
|
||||
struct DiskImage {
|
||||
mount_point: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl DiskImage {
|
||||
/// Creates a `file_system` image named `name` in `dir` and attaches it at `dir/name`.
|
||||
fn attach(dir: &Path, name: &str, file_system: &str) -> Self {
|
||||
let image = dir.join(format!("{name}.dmg"));
|
||||
let mount_point = dir.join(name);
|
||||
// whatever an earlier run left attached
|
||||
Self::detach(&mount_point);
|
||||
std::fs::create_dir_all(&mount_point).expect("failed to create the mount point");
|
||||
|
||||
let hdiutil = |args: &[&std::ffi::OsStr]| {
|
||||
let status = Command::new("hdiutil")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("failed to run hdiutil");
|
||||
assert!(status.success(), "hdiutil {args:?} failed");
|
||||
};
|
||||
hdiutil(&[
|
||||
"create".as_ref(),
|
||||
"-ov".as_ref(),
|
||||
"-quiet".as_ref(),
|
||||
"-size".as_ref(),
|
||||
"64m".as_ref(),
|
||||
"-fs".as_ref(),
|
||||
file_system.as_ref(),
|
||||
"-volname".as_ref(),
|
||||
name.as_ref(),
|
||||
image.as_os_str(),
|
||||
]);
|
||||
hdiutil(&[
|
||||
"attach".as_ref(),
|
||||
"-quiet".as_ref(),
|
||||
"-nobrowse".as_ref(),
|
||||
"-mountpoint".as_ref(),
|
||||
mount_point.as_os_str(),
|
||||
image.as_os_str(),
|
||||
]);
|
||||
|
||||
Self { mount_point }
|
||||
}
|
||||
|
||||
fn detach(mount_point: &Path) {
|
||||
let _ = Command::new("hdiutil")
|
||||
.args(["detach", "-force", "-quiet"])
|
||||
.arg(mount_point)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl Drop for DiskImage {
|
||||
fn drop(&mut self) {
|
||||
Self::detach(&self.mount_point);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundles 1.0.0 and serves it as the update, then bundles 0.1.0 and returns the server along with
|
||||
/// where the 0.1.0 `.app` was left, to copy wherever a test runs it from.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_and_serve_macos_update(manifest_dir: &Path, root_dir: &Path) -> (UpdaterServer, PathBuf) {
|
||||
let target =
|
||||
tauri_plugin_updater::target().expect("running updater test in an unsupported platform");
|
||||
|
||||
let mut config = Config {
|
||||
version: "1.0.0",
|
||||
bundle: BundleConfig {
|
||||
create_updater_artifacts: Updater::Bool(true),
|
||||
linux: None,
|
||||
},
|
||||
plugins: None,
|
||||
};
|
||||
build_app(manifest_dir, &config, Some(BundleTarget::App));
|
||||
|
||||
let bundle_path = root_dir.join("target/release/bundle/macos/app-updater.app");
|
||||
let signature = std::fs::read_to_string(bundle_path.with_extension("app.tar.gz.sig"))
|
||||
.expect("failed to read signature file");
|
||||
// move it aside so building the running app cannot clobber it
|
||||
let updater_path = root_dir.join("target/release/app-updater.app.tar.gz");
|
||||
std::fs::rename(bundle_path.with_extension("app.tar.gz"), &updater_path)
|
||||
.expect("failed to rename bundle");
|
||||
let server = serve_update(Some(target), signature, updater_path);
|
||||
|
||||
config.version = "0.1.0";
|
||||
build_app(manifest_dir, &config, Some(BundleTarget::App));
|
||||
|
||||
(server, bundle_path)
|
||||
}
|
||||
|
||||
/// Runs the app at `app_path` twice, expecting it to install the update and then to find itself
|
||||
/// up to date.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn update_and_check(app_path: &Path, envs: &[(&str, &Path)], context: &str) {
|
||||
for expected_exit_code in [UPDATED_EXIT_CODE, UP_TO_DATE_EXIT_CODE] {
|
||||
let output = Command::new(app_path.join("Contents/MacOS/app-updater"))
|
||||
.env("TARGET", BundleTarget::App.name())
|
||||
.envs(envs.iter().copied())
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("failed to run {}: {e}", app_path.display()));
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
assert_eq!(
|
||||
code,
|
||||
expected_exit_code,
|
||||
"unexpected exit code running the app {context}: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries of `dir` an update staged there and failed to clean up.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn update_leftovers(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name.starts_with("tauri_"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The updater moves the `.app` with renames, which only work within one volume, so it has to
|
||||
/// stage the update on the volume the app is on. This runs the app from disk images, which are
|
||||
/// never the volume the system temp dir is on: an APFS one, where the two bundles are swapped in
|
||||
/// one step, and an HFS+ one, which does not support the swap and moves them one at a time.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore = "needs the tauri CLI, a display and minutes of build time; the integration tests workflow runs it with --ignored"]
|
||||
fn update_app_on_another_volume() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let root_dir = manifest_dir.join("../../../..");
|
||||
let (_server, bundle_path) = build_and_serve_macos_update(&manifest_dir, &root_dir);
|
||||
|
||||
let temp_dir_dev = std::env::temp_dir().metadata().unwrap().dev();
|
||||
let volumes_dir = root_dir.join("target/release/app-under-test-volumes");
|
||||
for (name, file_system) in [("apfs", "APFS"), ("hfs", "HFS+")] {
|
||||
let volume = DiskImage::attach(&volumes_dir, name, file_system);
|
||||
assert_ne!(
|
||||
volume.mount_point.metadata().unwrap().dev(),
|
||||
temp_dir_dev,
|
||||
"the {file_system} image is on the same volume as the temp dir"
|
||||
);
|
||||
|
||||
let app_path = volume.mount_point.join("app-updater.app");
|
||||
copy_recursively(&bundle_path, &app_path)
|
||||
.unwrap_or_else(|e| panic!("failed to copy the app to {}: {e}", app_path.display()));
|
||||
|
||||
update_and_check(&app_path, &[], &format!("from an {file_system} volume"));
|
||||
|
||||
// the update is staged next to the app, and nothing of it may be left behind
|
||||
let leftovers = update_leftovers(&volume.mount_point);
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"the update left {leftovers:?} on the {file_system} volume"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes a directory read-only, and writable again when it goes out of scope so it can be removed.
|
||||
#[cfg(target_os = "macos")]
|
||||
struct ReadOnlyDir(PathBuf);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl ReadOnlyDir {
|
||||
fn new(dir: PathBuf) -> Self {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555))
|
||||
.expect("failed to make the directory read-only");
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl Drop for ReadOnlyDir {
|
||||
fn drop(&mut self) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
}
|
||||
|
||||
/// When the user cannot write to the folder the app is in, the updater asks for an admin password
|
||||
/// and installs as root. Someone has to type that password, so this only runs with
|
||||
/// `TAURI_UPDATER_ADMIN_PROMPT_TEST` set, and asks twice: for a folder on the temp dir's APFS
|
||||
/// volume, where root swaps the bundles in one step, and for one on an HFS+ disk image, where the
|
||||
/// swap is not supported and root moves them one at a time.
|
||||
///
|
||||
/// Each run points `TMPDIR` at a directory of its own on the app's volume, so what the install
|
||||
/// leaves behind there can be checked.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore = "needs the tauri CLI, a display, someone to type an admin password and minutes of build time"]
|
||||
fn update_app_with_admin_prompt() {
|
||||
if std::env::var_os("TAURI_UPDATER_ADMIN_PROMPT_TEST").is_none() {
|
||||
eprintln!("skipping the admin prompt test: TAURI_UPDATER_ADMIN_PROMPT_TEST is not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let root_dir = manifest_dir.join("../../../..");
|
||||
let (_server, bundle_path) = build_and_serve_macos_update(&manifest_dir, &root_dir);
|
||||
|
||||
let work_dir = root_dir.join("target/release/app-under-test-admin");
|
||||
let run = |dir: &Path, file_system: &str| {
|
||||
let app_dir = dir.join("read-only");
|
||||
let tmp_dir = dir.join("tmp");
|
||||
// whatever an earlier run left
|
||||
drop(ReadOnlyDir(app_dir.clone()));
|
||||
let _ = std::fs::remove_dir_all(&app_dir);
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
std::fs::create_dir_all(&app_dir).expect("failed to create the app directory");
|
||||
std::fs::create_dir_all(&tmp_dir).expect("failed to create the temp directory");
|
||||
|
||||
let app_path = app_dir.join("app-updater.app");
|
||||
copy_recursively(&bundle_path, &app_path)
|
||||
.unwrap_or_else(|e| panic!("failed to copy the app to {}: {e}", app_path.display()));
|
||||
let _read_only = ReadOnlyDir::new(app_dir);
|
||||
|
||||
update_and_check(
|
||||
&app_path,
|
||||
&[("TMPDIR", &tmp_dir)],
|
||||
&format!("from a read-only folder on an {file_system} volume"),
|
||||
);
|
||||
|
||||
let leftovers = update_leftovers(&tmp_dir);
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"the admin install left {leftovers:?} on the {file_system} volume"
|
||||
);
|
||||
};
|
||||
|
||||
run(&work_dir.join("apfs"), "APFS");
|
||||
let volume = DiskImage::attach(&work_dir, "hfs", "HFS+");
|
||||
run(&volume.mount_point, "HFS+");
|
||||
}
|
||||
|
||||
const SIGNED_VERSION_PORT: u16 = 3008;
|
||||
/// Fragment of `Error::SignedVersionMismatch`. The app prints the updater error before exiting,
|
||||
/// and a rejected update exits with the same code as a failed install, so the message is what
|
||||
|
||||
Reference in new issue
Block a user