758 lines
27 KiB
Rust
758 lines
27 KiB
Rust
#![allow(deprecated)]
|
|
|
|
use std::ffi::{CString, OsString};
|
|
use std::os::unix::ffi::OsStrExt as _;
|
|
use std::os::unix::fs::MetadataExt;
|
|
use std::os::unix::io::AsRawFd as _;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
use std::{env, fs, str};
|
|
|
|
use anyhow::{anyhow, bail, ensure, Context, Result};
|
|
use channel_versions::VersionInfo;
|
|
use command::blocking;
|
|
use command::r#async::Command;
|
|
use futures::{StreamExt, TryStreamExt as _};
|
|
use futures_lite::future;
|
|
use galaxy_core::macos::get_bundle_path;
|
|
use galaxy_core::safe_error;
|
|
use galaxyui::{AppContext, ModelContext, SingletonEntity};
|
|
use instant::Instant;
|
|
use nix::errno::Errno;
|
|
use nix::unistd::{fchown, getgid, getuid};
|
|
|
|
use super::{release_assets_directory_url, DownloadReady};
|
|
use crate::appearance::AppearanceManager;
|
|
use crate::autoupdate::{AutoupdateStage, AutoupdateState};
|
|
use crate::channel::{Channel, ChannelState};
|
|
use crate::safe_info;
|
|
|
|
// Relative path to the directory containing old executables from before an autoupdate.
|
|
//
|
|
// TODO(vorporeal): This and relevant code should be deleted after auto-updates have been
|
|
// storing the old executable in the user application data directory for a couple
|
|
// releases.
|
|
const OLD_EXECUTABLE_PATH: &str = "Contents/MacOS/old";
|
|
|
|
// Name of the old executable file that was kept around during an autoupdate.
|
|
const OLD_EXECUTABLE_FILE_NAME: &str = "old";
|
|
|
|
// Tmp file name used to check if the user has the correct permissions for autoupdate.
|
|
const PERMISSIONS_TMP_FILE_NAME: &str = "permission_test";
|
|
|
|
fn old_executable_file_path() -> PathBuf {
|
|
galaxy_core::paths::state_dir().join(OLD_EXECUTABLE_FILE_NAME)
|
|
}
|
|
|
|
/// Removes the old executable dir from the app bundle. This is necessary because after an
|
|
/// autoupdate deleting the running executable causes the pty to not start for a reason we don't
|
|
/// fully understand. This allows to clean up old executables when the app is first launched.
|
|
pub(super) fn remove_old_executable() -> Result<()> {
|
|
// TODO(vorporeal): This code should be deleted after auto-updates have been
|
|
// storing the old executable in the user application data directory for
|
|
// a couple releases.
|
|
log::info!("Removing old executable dir...");
|
|
let old_executable_path = PathBuf::from(get_bundle_path()?).join(OLD_EXECUTABLE_PATH);
|
|
if let Ok(metadata) = fs::metadata(&old_executable_path) {
|
|
if metadata.is_dir() {
|
|
fs::remove_dir_all(old_executable_path)?;
|
|
}
|
|
}
|
|
|
|
log::info!("Removing old executable file...");
|
|
let old_executable_file_path = old_executable_file_path();
|
|
if let Ok(metadata) = fs::metadata(&old_executable_file_path) {
|
|
if metadata.is_file() {
|
|
fs::remove_file(old_executable_file_path)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) fn manually_download_version(
|
|
channel: &Channel,
|
|
version_info: &VersionInfo,
|
|
ctx: &mut AppContext,
|
|
) {
|
|
let url = update_url(*channel, version_info.version.as_str());
|
|
ctx.open_url(&url);
|
|
}
|
|
|
|
/// If the autoupdate state is ready, asynchronously apply the update and cleanup the autoupdate artifacts.
|
|
///
|
|
/// The completion callback is invoked with `Ok(Some(version))` if an update was applied, and `Ok(None)` if there was no update.
|
|
/// If there was an update, but applying it failed, it's invoked with `Err(err)`.
|
|
pub(super) fn apply_update_async<F>(app: &mut AppContext, callback: F)
|
|
where
|
|
F: FnOnce(
|
|
&mut AutoupdateState,
|
|
Result<Option<VersionInfo>>,
|
|
&mut ModelContext<AutoupdateState>,
|
|
) + Send
|
|
+ 'static,
|
|
{
|
|
AutoupdateState::handle(app).update(app, |autoupdate_state, ctx| {
|
|
match autoupdate_state.stage.clone() {
|
|
AutoupdateStage::UpdateReady {
|
|
new_version,
|
|
update_id,
|
|
}
|
|
| AutoupdateStage::Updating {
|
|
new_version,
|
|
update_id,
|
|
} => {
|
|
let update_id_clone = update_id.clone();
|
|
// Apply the update in a background thread.
|
|
ctx.spawn(
|
|
async move {
|
|
let result =
|
|
apply_update(ChannelState::channel(), &new_version, &update_id)
|
|
.await
|
|
.map(|_| Some(new_version));
|
|
cleanup(&update_id).await;
|
|
result
|
|
},
|
|
move |autoupdate_state, result, ctx| {
|
|
if result.is_ok() {
|
|
// Reset app icon to previously selected app icon
|
|
AppearanceManager::as_ref(ctx).set_app_icon(ctx);
|
|
}
|
|
autoupdate_state.clear_downloaded_update(&update_id_clone, ctx);
|
|
callback(autoupdate_state, result, ctx);
|
|
},
|
|
);
|
|
}
|
|
_ => {
|
|
callback(autoupdate_state, Ok(None), ctx);
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
pub(super) fn relaunch() -> Result<()> {
|
|
let bundle_path = PathBuf::from(get_bundle_path()?);
|
|
// Set the -n option to open a new instance of the app even if one is
|
|
// running so we still launch the new version even if the user was running
|
|
// multiple instances of Warp.
|
|
let mut launch_command = OsString::from("/usr/bin/open -n ");
|
|
launch_command.push(bundle_path.as_os_str());
|
|
// Pass a flag to the app to let it know it was restarted as part of the
|
|
// autoupdate process.
|
|
launch_command.push(format!(" --args {}", galaxy_cli::finish_update_flag()));
|
|
// If we're testing with a local copy of channel_versions.json, have the
|
|
// newly-started binary also reference that same file (so we can test
|
|
// displaying an updated changelog after an autoupdate).
|
|
if let Ok(path) = env::var("WARP_CHANNEL_VERSIONS_PATH") {
|
|
launch_command.push(format!(" --env WARP_CHANNEL_VERSIONS_PATH={path}"));
|
|
}
|
|
|
|
// We need to make sure that the current Warp process is no longer running
|
|
// before we spawn the new one, otherwise we can end up showing multiple
|
|
// icons in the macOS dock. To do this, we use an intermediary /bin/sh
|
|
// process that watches for this process to terminate, and then spawns a
|
|
// new Warp process.
|
|
//
|
|
// Wait until the current process is no longer running, checking every
|
|
// 200ms. Once the current process has terminated, launch the new one.
|
|
let pid = std::process::id();
|
|
let mut relaunch_command = OsString::from(format!(
|
|
"while ps -p {pid} >/dev/null 2>&1; do sleep 0.2; done; "
|
|
));
|
|
relaunch_command.push(launch_command);
|
|
|
|
log::info!("Executing relaunch command {relaunch_command:?}");
|
|
blocking::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(relaunch_command)
|
|
.spawn()?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cleanup(update_id: &str) {
|
|
let download_dir = get_download_dir(update_id);
|
|
if download_dir.exists() {
|
|
log::info!("Cleaning up download dir {:?}", &download_dir);
|
|
if let Err(e) = async_fs::remove_dir_all(&download_dir).await {
|
|
safe_error!(
|
|
safe: ("Error cleaning up download dir: {e:?}"),
|
|
full: ("Error cleaning up download dir {:?}: {:?}", &download_dir, e)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clean up all autoupdate directories except the specified one.
|
|
/// This helps prevent accumulation of old update directories from failed downloads,
|
|
/// race conditions, or incomplete cleanups.
|
|
pub async fn cleanup_all_except(preserve_update_id: Option<&str>) {
|
|
let mut autoupdate_dir = galaxy_core::paths::cache_dir();
|
|
autoupdate_dir.push("autoupdate");
|
|
|
|
if !autoupdate_dir.exists() {
|
|
return;
|
|
}
|
|
|
|
log::debug!("Cleaning up all autoupdate directories except {preserve_update_id:?}");
|
|
|
|
let mut entries = match async_fs::read_dir(&autoupdate_dir).await {
|
|
Ok(entries) => entries,
|
|
Err(e) => {
|
|
log::warn!("Could not read autoupdate directory {autoupdate_dir:?}: {e:?}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
while let Some(entry) = entries.next().await {
|
|
let entry = match entry {
|
|
Ok(entry) => entry,
|
|
Err(e) => {
|
|
log::warn!("Error reading autoupdate directory entry: {e:?}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let path = entry.path();
|
|
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
|
Some(name) => name,
|
|
None => continue,
|
|
};
|
|
|
|
// Skip the directory we want to preserve
|
|
if let Some(preserve_id) = preserve_update_id {
|
|
if file_name == preserve_id {
|
|
log::debug!("Preserving autoupdate directory: {path:?}");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let metadata = match async_fs::metadata(&path).await {
|
|
Ok(metadata) => metadata,
|
|
Err(e) => {
|
|
log::warn!("Could not get metadata for {path:?}: {e:?}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if metadata.is_dir() {
|
|
log::debug!("Removing old autoupdate directory: {path:?}");
|
|
if let Err(e) = async_fs::remove_dir_all(&path).await {
|
|
log::warn!("Failed to remove autoupdate directory {path:?}: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Determines if the user needs authorization in order to update Warp.
|
|
async fn needs_authorization(bundle_path: &Path) -> Result<bool> {
|
|
// For the bundle path itself, check permissions without creating a test file so as to not
|
|
// interfere with code signing.
|
|
let bundle_dir_writable = permissions::is_writable(bundle_path)?;
|
|
if !bundle_dir_writable {
|
|
log::info!("App location is not writable, needs authorization");
|
|
return Ok(true);
|
|
} else {
|
|
log::info!("App location is writable");
|
|
}
|
|
|
|
if let Some(bundle_parent_path) = bundle_path.parent() {
|
|
if !is_directory_writable(bundle_parent_path).await? {
|
|
log::info!("App parent location is not writable, needs authorization");
|
|
return Ok(true);
|
|
} else {
|
|
log::info!("App parent location is writable");
|
|
}
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
/// Determines if a directory is writable as part of an update. This means:
|
|
/// * Warp can create files in the directory
|
|
/// * Warp can modify the permissions of created files
|
|
async fn is_directory_writable(directory: &Path) -> Result<bool> {
|
|
// Just because we have writability access does not mean we can set the correct owner/group.
|
|
// Test if we can set the owner/group on a temporarily created file. If we can, then we can
|
|
// probably perform an update without authorization.
|
|
let tmp_file_name = directory.join(PERMISSIONS_TMP_FILE_NAME);
|
|
|
|
safe_info!(
|
|
safe: ("Writing to a tmp file to determine if permissions are correct"),
|
|
full: ("Writing to a tmp file to determine if permissions are correct in {}", directory.display())
|
|
);
|
|
|
|
let needs_authorization = match async_fs::File::create(&tmp_file_name).await {
|
|
Ok(file) => {
|
|
let fchown_result = fchown(file.as_raw_fd(), Some(getuid()), Some(getgid()));
|
|
if let Err(err) = &fchown_result {
|
|
log::warn!("Could not set permissions on tmp file: {err:#}");
|
|
}
|
|
|
|
// Only remove the tmp file if it was created - otherwise, we'll mask permission
|
|
// errors.
|
|
async_fs::remove_file(&tmp_file_name).await?;
|
|
fchown_result.is_ok()
|
|
}
|
|
Err(e) => {
|
|
// Obvious indicator we may need authorization.
|
|
log::warn!("Could not create tmp file: {e:#}");
|
|
false
|
|
}
|
|
};
|
|
|
|
Ok(needs_authorization)
|
|
}
|
|
|
|
/// Verifies that the staged bundle path has a valid macOS code signature, and that its
|
|
/// team identifier matches Warp's team identifier.
|
|
async fn verify_code_signature(component: &str, path: &Path) -> Result<()> {
|
|
// Verify the signature of the staged update bundle with team identifier
|
|
let codesign_verify_output = Command::new("/usr/bin/codesign")
|
|
.arg("-v")
|
|
.arg(format!(
|
|
"-R=certificate leaf[subject.OU] = \"{}\"",
|
|
galaxy_core::macos::APPLE_TEAM_ID
|
|
))
|
|
.arg(path)
|
|
.output()
|
|
.await?;
|
|
ensure!(
|
|
codesign_verify_output.status.success(),
|
|
"Failed to verify code signature for {component} with team identifier: {codesign_verify_output:?}"
|
|
);
|
|
|
|
safe_info!(
|
|
safe: ("Code signature is valid for {component}"),
|
|
full: ("Code signature is valid for {}", path.display())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn download_update_and_cleanup(
|
|
version_info: &VersionInfo,
|
|
update_id: &str,
|
|
last_successful_update_id: Option<&str>,
|
|
client: &http_client::Client,
|
|
) -> Result<DownloadReady> {
|
|
let result =
|
|
download_and_extract_binary(ChannelState::channel(), version_info, update_id, client).await;
|
|
if result.is_err() {
|
|
cleanup_all_except(last_successful_update_id).await;
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Apply the downloaded update.
|
|
///
|
|
/// This is async and should be run in a background task.
|
|
async fn apply_update(channel: Channel, version_info: &VersionInfo, update_id: &str) -> Result<()> {
|
|
let update_start = Instant::now();
|
|
|
|
let bundle_path = PathBuf::from(get_bundle_path()?);
|
|
let bundle_parent_path = bundle_path
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("Could not get parent directory of application bundle"))?;
|
|
|
|
// Double-check that we have permissions to apply the update.
|
|
if !permissions::is_writable(&bundle_path)? {
|
|
bail!("App location is not writable, cannot apply update");
|
|
}
|
|
if !is_directory_writable(bundle_parent_path).await? {
|
|
bail!("App parent location is not writable, cannot apply update");
|
|
}
|
|
|
|
// Read a file out of the old bundle to ensure that we've triggered macOS' directory
|
|
// permissions checks.
|
|
let old_info_plist = bundle_path.join("Contents/Info.plist");
|
|
if async_fs::File::open(&old_info_plist).await.is_err() {
|
|
bail!("App location is not readable, cannot apply update");
|
|
}
|
|
|
|
let dmg_path = dmg_path(&channel, version_info, update_id);
|
|
let temp_app_path = temporary_target_path(channel, version_info, &dmg_path)?;
|
|
|
|
let staged_bundle =
|
|
StagedBundle::for_bundle_path(channel, version_info, temp_app_path, &bundle_path).await?;
|
|
|
|
// Copy permissions to new app
|
|
let bundle_metadata = async_fs::metadata(&bundle_path).await?;
|
|
async_fs::set_permissions(&staged_bundle.path, bundle_metadata.permissions()).await?;
|
|
|
|
// Verify that the new version actually exists before proceeding
|
|
let executable_path_buf = staged_bundle.path.join(executable_path(channel));
|
|
if !executable_path_buf.exists() {
|
|
bail!(
|
|
"New executable does not exist at path: {:?}",
|
|
executable_path_buf
|
|
);
|
|
}
|
|
|
|
// Atomically rename the new app to have the same name as the old one.
|
|
log::info!("Renaming new app to original app name");
|
|
let from = CString::new(staged_bundle.path.as_os_str().as_bytes())?;
|
|
let to = CString::new(bundle_path.as_os_str().as_bytes())?;
|
|
|
|
Errno::result(unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_SWAP) })
|
|
.context("Error swapping old and new app bundles")?;
|
|
|
|
// Move the current running executable into a temporary directory so we can delete the
|
|
// rest of the old bundle without removing the running executable (since removing it
|
|
// causes the `fork` syscall to fail).
|
|
let executable_temp_file = old_executable_file_path();
|
|
if async_fs::metadata(executable_temp_file.as_path())
|
|
.await
|
|
.is_ok()
|
|
{
|
|
// If we performed this process already but didn't relaunch Warp, the old executable will
|
|
// still be located in the user application data directory. In that case, leave it there.
|
|
log::info!("Already autoupdated without relaunching; ignoring executable from old bundle");
|
|
} else {
|
|
// Compute the location of the old executable (which, after the swap of the app contents,
|
|
// is located in the "new app" directory).
|
|
let new_app_executable_path = staged_bundle.path.join(executable_path(channel));
|
|
|
|
log::info!(
|
|
"Moving old executable at path {new_app_executable_path:?} into user application data dir at path {executable_temp_file:?}"
|
|
);
|
|
let mv_output = Command::new("mv")
|
|
.arg(new_app_executable_path)
|
|
.arg(executable_temp_file)
|
|
.output()
|
|
.await?;
|
|
|
|
ensure!(
|
|
mv_output.status.success(),
|
|
"Failed to move old executable: {mv_output:?}"
|
|
);
|
|
}
|
|
|
|
log::info!("Setting installed version to {:?}", &version_info);
|
|
log::info!("Applied update in {:?}", update_start.elapsed());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The staged app bundle that we're about to install. It's copied out of the `.dmg` file into a
|
|
/// temporary location.
|
|
struct StagedBundle {
|
|
/// Path to the on-disk temporary bundle.
|
|
path: PathBuf,
|
|
/// Whether or not the temporary bundle was copied into the same directory as the existing app.
|
|
/// This is only necessary if `$TMPDIR` and the app are on different filesystems.
|
|
in_app_directory: bool,
|
|
}
|
|
|
|
impl StagedBundle {
|
|
async fn for_bundle_path(
|
|
channel: Channel,
|
|
version_info: &VersionInfo,
|
|
temp_app_path: PathBuf,
|
|
bundle_path: &Path,
|
|
) -> Result<Self> {
|
|
let temp_device_id = async_fs::metadata(&temp_app_path)
|
|
.await
|
|
.context("Could not get metadata for temporary app bundle")?
|
|
.dev();
|
|
let bundle_device_id = async_fs::metadata(bundle_path)
|
|
.await
|
|
.context("Could not get metadata for app bundle")?
|
|
.dev();
|
|
|
|
if temp_device_id == bundle_device_id {
|
|
// The old and new app bundles are on the same filesystem (this is the expected case).
|
|
Ok(Self {
|
|
path: temp_app_path,
|
|
in_app_directory: false,
|
|
})
|
|
} else {
|
|
let bundle_parent_path = bundle_path
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("Could not get parent directory of application bundle"))?;
|
|
log::info!("Copying app contents from {temp_app_path:?} to {bundle_parent_path:?}");
|
|
|
|
let cp_output = Command::new("cp")
|
|
// Recursively copy the directory, preserving symlinks.
|
|
.arg("-R")
|
|
// Overwrite files at the destination.
|
|
.arg("-f")
|
|
.arg(&temp_app_path)
|
|
.arg(bundle_parent_path)
|
|
.output()
|
|
.await?;
|
|
|
|
ensure!(
|
|
cp_output.status.success(),
|
|
"Failed to copy app contents from temporary directory into bundle directory: {cp_output:?}"
|
|
);
|
|
|
|
Ok(Self {
|
|
path: bundle_parent_path.join(versioned_app_name(channel, &version_info.version)),
|
|
in_app_directory: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for StagedBundle {
|
|
fn drop(&mut self) {
|
|
// Clean up in the destructor so that it happens even if the installation errors.
|
|
// If we used the original temporary app bundle, it'll get removed by the final cleanup
|
|
// step, along with the dmg.
|
|
if self.in_app_directory {
|
|
log::info!("Removing temporary app bundle");
|
|
if let Err(err) = fs::remove_dir_all(&self.path) {
|
|
log::error!("Failed to remove temporary bundle: {err:#}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn download_and_extract_binary(
|
|
channel: Channel,
|
|
version_info: &VersionInfo,
|
|
update_id: &str,
|
|
client: &http_client::Client,
|
|
) -> Result<DownloadReady> {
|
|
let bundle_path = PathBuf::from(get_bundle_path()?);
|
|
let needs_authorization = needs_authorization(bundle_path.as_path())
|
|
.await
|
|
.unwrap_or(true);
|
|
if needs_authorization {
|
|
return Ok(DownloadReady::NeedsAuthorization);
|
|
}
|
|
|
|
log::info!(
|
|
"Downloading update, version {} on channel {channel}",
|
|
&version_info.version,
|
|
);
|
|
|
|
let download_dir = get_download_dir(update_id);
|
|
log::info!("Creating download dir {:?}", &download_dir);
|
|
async_fs::create_dir_all(&download_dir).await?;
|
|
|
|
let dmg_path = download_dmg(&channel, version_info, update_id, client).await?;
|
|
|
|
// Mount the downloaded dmg so we can copy out the binary.
|
|
let mountpoint = mount_dmg(&dmg_path, update_id).await?;
|
|
|
|
let target = temporary_target_path(channel, version_info, &dmg_path)?;
|
|
// Copy the binary into the temporary directory where we downloaded the dmg.
|
|
copy_app_from_dmg(&channel, &mountpoint, &target).await?;
|
|
|
|
// Unmount the dmg once we no longer need it. This prevents lingering images from unapplied
|
|
// updates.
|
|
if let Err(err) = unmount_dmg(mountpoint).await {
|
|
let err = err.context("Error unmounting dmg for update");
|
|
crate::report_error!(&err);
|
|
}
|
|
|
|
// Ensure that the new app we just downloaded has both integrity (e.g. no corrupted files)
|
|
// and validity (it was signed by us).
|
|
// Store the executable path in a variable to prevent temporary value issues.
|
|
let executable_path_buf = target.join(executable_path(channel));
|
|
let verification_start = Instant::now();
|
|
future::try_zip(
|
|
verify_code_signature("bundle", &target),
|
|
verify_code_signature("executable", executable_path_buf.as_path()),
|
|
)
|
|
.await?;
|
|
|
|
log::info!(
|
|
"Verified new app code signature in {:?}",
|
|
verification_start.elapsed()
|
|
);
|
|
|
|
Ok(DownloadReady::Yes)
|
|
}
|
|
|
|
async fn unmount_dmg(mountpoint: PathBuf) -> Result<()> {
|
|
let mut hdiutil_cmd = Command::new("/usr/bin/hdiutil");
|
|
hdiutil_cmd.arg("detach");
|
|
hdiutil_cmd.arg(&mountpoint);
|
|
hdiutil_cmd.arg("-force");
|
|
|
|
log::info!("Attempting to detach dmg with command \"{hdiutil_cmd:?}\"");
|
|
|
|
let output = hdiutil_cmd.output().await?;
|
|
|
|
ensure!(output.status.success(), "Failed to detach dmg: {output:?}");
|
|
log::info!("hdiutil detach succeeded: {output:?}");
|
|
Ok(())
|
|
}
|
|
|
|
async fn copy_app_from_dmg(channel: &Channel, mountpoint: &Path, target: &Path) -> Result<()> {
|
|
let mounted_app_path = mountpoint.join(app_name(*channel));
|
|
|
|
log::info!("Copying dmg contents from {mounted_app_path:?} to {target:?}");
|
|
|
|
let cp_output = Command::new("cp")
|
|
// Recursively copy the directory, preserving symlinks.
|
|
.arg("-R")
|
|
.arg(mounted_app_path)
|
|
.arg(target)
|
|
.output()
|
|
.await?;
|
|
|
|
ensure!(
|
|
cp_output.status.success(),
|
|
"Failed to copy app out of mounted dmg: {cp_output:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// 10 minutes
|
|
const DMG_TIMEOUT_S: u64 = 600;
|
|
|
|
/// The temporary path for downloading the new dmg into.
|
|
fn dmg_path(channel: &Channel, version_info: &VersionInfo, update_id: &str) -> PathBuf {
|
|
let mut dir = get_download_dir(update_id);
|
|
let file_name = format!(
|
|
"{}.{}.dmg",
|
|
&version_info.version,
|
|
app_name_prefix(*channel)
|
|
);
|
|
dir.push(file_name);
|
|
dir
|
|
}
|
|
|
|
/// The temporary path for placing our downloaded app binary.
|
|
fn temporary_target_path(
|
|
channel: Channel,
|
|
version_info: &VersionInfo,
|
|
dmg_path: &Path,
|
|
) -> Result<PathBuf> {
|
|
Ok(dmg_path
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("Could not get parent directory of downloaded DMG"))?
|
|
.join(versioned_app_name(channel, &version_info.version)))
|
|
}
|
|
|
|
async fn download_dmg(
|
|
channel: &Channel,
|
|
version_info: &VersionInfo,
|
|
update_id: &str,
|
|
client: &http_client::Client,
|
|
) -> Result<PathBuf> {
|
|
// TODO: Use a streaming fetch and and provide an api for tracking progress
|
|
let update_url = update_url(*channel, &version_info.version);
|
|
log::info!("Fetching new dmg at {update_url}");
|
|
let res = client
|
|
.get(&update_url)
|
|
.timeout(Duration::from_secs(DMG_TIMEOUT_S))
|
|
.send()
|
|
.await?;
|
|
let dmg_file = dmg_path(channel, version_info, update_id);
|
|
|
|
let mut file = async_fs::File::create(&dmg_file).await?;
|
|
futures_lite::io::copy(
|
|
res.bytes_stream()
|
|
.map_err(std::io::Error::other)
|
|
.into_async_read(),
|
|
&mut file,
|
|
)
|
|
.await?;
|
|
file.sync_data().await?;
|
|
|
|
log::info!("Wrote DMG to tempfile at {:?}", &dmg_file);
|
|
Ok(dmg_file)
|
|
}
|
|
|
|
fn get_download_dir(update_id: &str) -> PathBuf {
|
|
let mut dir = galaxy_core::paths::cache_dir();
|
|
dir.push("autoupdate");
|
|
dir.push(update_id);
|
|
dir
|
|
}
|
|
|
|
fn get_mountpoint(update_id: &str) -> PathBuf {
|
|
let mut volume = PathBuf::from("/Volumes");
|
|
volume.push(update_id);
|
|
volume
|
|
}
|
|
|
|
async fn mount_dmg(dmg_dir: &Path, update_id: &str) -> Result<PathBuf> {
|
|
let volume = get_mountpoint(update_id);
|
|
let mut hdiutil_cmd = Command::new("/usr/bin/hdiutil");
|
|
hdiutil_cmd.args(["attach", "-mountpoint"]);
|
|
hdiutil_cmd.arg(&volume);
|
|
// Explanation of flags:
|
|
// -nobrowse: Do not show the Warp DMG in Finder or similar apps.
|
|
// -noautoopen: Do not open the Warp DMG in Finder.
|
|
// -readonly: For safety, we mount read-only since there's no need to modify the new app version.
|
|
// -autofsck: Ensure that the DMG contents are verified. This is on by default for quarantined images, but macOS
|
|
// doesn't necessarily recognize our download as such.
|
|
hdiutil_cmd.args(["-nobrowse", "-noautoopen", "-readonly", "-autofsck"]);
|
|
hdiutil_cmd.arg(dmg_dir);
|
|
|
|
log::info!("Attempting to mount dmg with command \"{hdiutil_cmd:?}\"");
|
|
|
|
let output = hdiutil_cmd.output().await?;
|
|
|
|
ensure!(output.status.success(), "Failed to mount dmg: {output:?}");
|
|
|
|
log::info!("hdiutil mount succeeded");
|
|
Ok(volume)
|
|
}
|
|
|
|
fn update_url(channel: Channel, version: &str) -> String {
|
|
format!(
|
|
"{}/{}",
|
|
release_assets_directory_url(channel, version),
|
|
dmg_name(channel)
|
|
)
|
|
}
|
|
|
|
fn app_name(channel: Channel) -> String {
|
|
format!("{}.app", app_name_prefix(channel))
|
|
}
|
|
|
|
fn versioned_app_name(channel: Channel, version: &str) -> String {
|
|
format!("{}({}).app", app_name_prefix(channel), version)
|
|
}
|
|
|
|
fn dmg_name(channel: Channel) -> String {
|
|
// If the user is on an Apple Silicon Mac, download an arm64-only bundle.
|
|
let is_arm64 = command::blocking::Command::new("uname")
|
|
.arg("-m")
|
|
.output()
|
|
.is_ok_and(|output| output.stdout.starts_with(b"arm64"));
|
|
if is_arm64 {
|
|
return format!("{}-arm64.dmg", app_name_prefix(channel));
|
|
}
|
|
|
|
// Otherwise, download a universal bundle.
|
|
format!("{}.dmg", app_name_prefix(channel))
|
|
}
|
|
|
|
fn app_name_prefix(channel: Channel) -> &'static str {
|
|
match channel {
|
|
Channel::Stable => "Galaxy",
|
|
Channel::Preview => "GalaxyPreview",
|
|
Channel::Local => "galaxy-ai",
|
|
Channel::Integration => "integration",
|
|
Channel::Dev => "GalaxyDev",
|
|
Channel::Oss => "galaxy-ai-oss",
|
|
}
|
|
}
|
|
|
|
fn executable_name(channel: Channel) -> &'static str {
|
|
match channel {
|
|
Channel::Stable => "stable",
|
|
Channel::Preview => "preview",
|
|
Channel::Local => "warp",
|
|
Channel::Integration => "integration",
|
|
Channel::Dev => "dev",
|
|
Channel::Oss => "warp-oss",
|
|
}
|
|
}
|
|
|
|
fn executable_path(channel: Channel) -> String {
|
|
if ChannelState::is_release_bundle() {
|
|
format!("Contents/MacOS/{}", executable_name(channel))
|
|
} else {
|
|
executable_name(channel).to_owned()
|
|
}
|
|
}
|