diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 77cf85a8d1..92df05b39a 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -520,17 +520,19 @@ impl Platform for MacPlatform { } } - fn restart(&self, _binary_path: Option) { + fn restart(&self, binary_path: Option) { use std::os::unix::process::CommandExt as _; let app_pid = std::process::id().to_string(); - let app_path = self - .app_path() - .ok() - // When the app is not bundled, `app_path` returns the - // directory containing the executable. Disregard this - // and get the path to the executable itself. - .and_then(|path| (path.extension()?.to_str()? == "app").then_some(path)) + let app_path = binary_path + .or_else(|| { + self.app_path() + .ok() + // When the app is not bundled, `app_path` returns the + // directory containing the executable. Disregard this + // and get the path to the executable itself. + .and_then(|path| (path.extension()?.to_str()? == "app").then_some(path)) + }) .unwrap_or_else(|| std::env::current_exe().unwrap()); // Wait until this process has exited and then re-open this path. diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f8023224a3..8f417ee08a 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -576,6 +576,8 @@ fn main() { Client::set_global(client.clone(), cx); zed::init(cx); + #[cfg(target_os = "macos")] + zed::move_to_applications::init(cx); project::Project::init(&client, cx); debugger_ui::init(cx); debugger_tools::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index e9da593960..3afd117a01 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3,6 +3,8 @@ pub mod edit_prediction_registry; #[cfg(target_os = "macos")] pub(crate) mod mac_only_instance; mod migrate; +#[cfg(target_os = "macos")] +pub(crate) mod move_to_applications; mod open_listener; mod open_url_modal; mod quick_action_bar; diff --git a/crates/zed/src/zed/move_to_applications.rs b/crates/zed/src/zed/move_to_applications.rs new file mode 100644 index 0000000000..10bd4b8047 --- /dev/null +++ b/crates/zed/src/zed/move_to_applications.rs @@ -0,0 +1,320 @@ +use anyhow::{Context as _, Result}; +use db::kvp::KeyValueStore; +use gpui::{ + App, AsyncWindowContext, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, + PromptButton, PromptLevel, Render, WeakEntity, Window, +}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use ui::{ + ActiveTheme, Color, CommonAnimationExt, Icon, IconName, IconSize, IntoElement, Label, + LabelCommon, LabelSize, ParentElement, Styled, StyledExt, div, h_flex, v_flex, +}; +use util::ResultExt; +use util::command::new_command; +use workspace::{ModalView, MultiWorkspace}; + +const DONT_ASK_AGAIN_KEY: &str = "move_to_applications_dont_ask_again"; +static PROMPTED_THIS_SESSION: AtomicBool = AtomicBool::new(false); + +pub fn init(cx: &mut App) { + let kvp = KeyValueStore::global(cx); + if matches!(kvp.read_kvp(DONT_ASK_AGAIN_KEY), Ok(Some(value)) if value == "true") { + return; + } + + let Some(request) = MoveToApplicationsRequest::new(cx).log_err().flatten() else { + return; + }; + + cx.observe_new(move |_workspace: &mut MultiWorkspace, window, cx| { + let Some(window) = window else { + return; + }; + + if PROMPTED_THIS_SESSION.swap(true, Ordering::AcqRel) { + return; + } + + let request = request.clone(); + cx.spawn_in(window, async move |workspace, cx| { + request.prompt(workspace, cx).await.log_err(); + }) + .detach(); + }) + .detach(); +} + +#[derive(Clone)] +struct MoveToApplicationsRequest { + app_path: PathBuf, +} + +impl MoveToApplicationsRequest { + fn new(cx: &App) -> Result> { + let app_path = match cx.app_path() { + Ok(app_path) => app_path, + Err(_) => return Ok(None), + }; + + if !should_offer_to_move(&app_path) { + return Ok(None); + } + + Ok(Some(Self { app_path })) + } + + async fn prompt( + self, + workspace: WeakEntity, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let response = cx + .prompt( + PromptLevel::Info, + "Move Zed to Applications?", + Some( + "Zed is running from a temporary location. Move it to Applications to finish installing it.", + ), + &[ + PromptButton::ok("Yes"), + PromptButton::cancel("No"), + PromptButton::new("Don't ask me again"), + ], + ) + .await?; + + match response { + 0 => { + workspace + .update_in(cx, |workspace, window, cx| { + workspace + .toggle_modal(window, cx, |_window, cx| InstallingZedModal::new(cx)); + }) + .ok(); + if let Err(error) = move_to_applications(&self.app_path, cx).await { + workspace + .update_in(cx, |workspace, _window, cx| { + if let Some(modal) = workspace.active_modal::(cx) { + modal.update(cx, |modal, cx| modal.finished(cx)); + } + }) + .ok(); + cx.prompt( + PromptLevel::Critical, + "Failed to move Zed to Applications", + Some(&error.to_string()), + &["Ok"], + ) + .await + .log_err(); + } + } + 2 => { + let kvp = cx.update(|_window, cx| KeyValueStore::global(cx))?; + kvp.write_kvp(DONT_ASK_AGAIN_KEY.to_string(), "true".to_string()) + .await?; + } + _ => {} + } + + Ok(()) + } +} + +pub struct InstallingZedModal { + focus_handle: FocusHandle, + finished: bool, +} + +impl InstallingZedModal { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + finished: false, + } + } + + fn finished(&mut self, cx: &mut Context) { + self.finished = true; + cx.emit(DismissEvent); + } +} + +impl EventEmitter for InstallingZedModal {} + +impl ModalView for InstallingZedModal { + fn on_before_dismiss( + &mut self, + _window: &mut Window, + _: &mut Context, + ) -> workspace::DismissDecision { + workspace::DismissDecision::Dismiss(self.finished) + } + + fn fade_out_background(&self) -> bool { + true + } +} + +impl Focusable for InstallingZedModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InstallingZedModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + + v_flex() + .elevation_3(cx) + .w_80() + .overflow_hidden() + .child( + div() + .px_4() + .py_3() + .border_b_1() + .border_color(theme.colors().border_variant) + .child(Label::new("Installing Zed…")), + ) + .child( + h_flex() + .w_full() + .gap_3() + .px_4() + .py_3() + .bg(theme.colors().editor_background) + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Medium) + .color(Color::Accent) + .with_rotate_animation(3), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Moving Zed to Applications")) + .child( + Label::new("Zed will reopen when installation is complete.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ) + } +} + +fn should_offer_to_move(app_path: &Path) -> bool { + app_path.starts_with(Path::new("/Volumes")) + || app_path.to_string_lossy().contains("/AppTranslocation/") +} + +async fn move_to_applications(app_path: &Path, cx: &mut AsyncWindowContext) -> Result<()> { + let destination_path = install_destination(app_path).await?; + restart_into(destination_path, cx) +} + +async fn install_destination(app_path: &Path) -> Result { + let app_name = app_path + .file_name() + .context("invalid app path: missing app bundle name")?; + + let system_destination = Path::new("/Applications").join(app_name); + if system_destination.exists() { + copy_app_bundle(app_path, &system_destination) + .await + .with_context(|| { + format!( + "failed to replace existing app at {}", + system_destination.display() + ) + })?; + return Ok(system_destination); + } + + if let Some(user_destination) = user_applications_directory().map(|path| path.join(app_name)) + && user_destination.exists() + { + copy_app_bundle(app_path, &user_destination) + .await + .with_context(|| { + format!( + "failed to replace existing app at {}", + user_destination.display() + ) + })?; + return Ok(user_destination); + } + + match copy_app_bundle(app_path, &system_destination).await { + Ok(()) => Ok(system_destination), + Err(system_error) => { + let user_applications_directory = user_applications_directory() + .context("could not determine a writable Applications directory")?; + smol::fs::create_dir_all(&user_applications_directory) + .await + .with_context(|| { + format!("failed to create {}", user_applications_directory.display()) + })?; + let user_destination = user_applications_directory.join(app_name); + copy_app_bundle(app_path, &user_destination) + .await + .with_context(|| { + format!( + "failed to copy app to {} after system Applications copy failed: {system_error:#}", + user_destination.display() + ) + })?; + Ok(user_destination) + } + } +} + +async fn copy_app_bundle(source: &Path, destination: &Path) -> Result<()> { + let parent = destination + .parent() + .context("invalid destination path: missing parent directory")?; + smol::fs::create_dir_all(parent) + .await + .with_context(|| format!("failed to create {}", parent.display()))?; + + let mut source_with_contents: OsString = source.into(); + source_with_contents.push("/"); + let mut destination_with_contents: OsString = destination.into(); + destination_with_contents.push("/"); + + let mut command = new_command("rsync"); + command + .args(["-a", "--delete"]) + .arg(&source_with_contents) + .arg(&destination_with_contents); + let output = command + .output() + .await + .with_context(|| format!("failed to run rsync for {}", source.display()))?; + + anyhow::ensure!( + output.status.success(), + "failed to copy app bundle: {}", + String::from_utf8_lossy(&output.stderr) + ); + + Ok(()) +} + +fn restart_into(app_path: PathBuf, cx: &mut AsyncWindowContext) -> Result<()> { + cx.update(|_window, cx| { + cx.set_restart_path(app_path); + cx.restart(); + })?; + Ok(()) +} + +fn user_applications_directory() -> Option { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("Applications")) +}