From 903eed964ab3941b359a3c299a1d34106ead70cd Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 16 May 2023 14:45:50 +0300 Subject: [PATCH] Allow CLI to start Zed from local sources Zed now is able to behave as if it's being started from CLI (`ZED_FORCE_CLI_MODE` env var) Zed CLI accepts regular binary file path into `-b` parameter (only *.app before), and tries to start it as Zed editor with `ZED_FORCE_CLI_MODE` env var and other params needed. --- crates/cli/src/cli.rs | 4 + crates/cli/src/main.rs | 197 ++++++++++++++++++++++++++++++----------- crates/zed/src/main.rs | 85 ++++++++++++------ 3 files changed, 211 insertions(+), 75 deletions(-) diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 7cad42b534..de7b14e142 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -20,3 +20,7 @@ pub enum CliResponse { Stderr { message: String }, Exit { status: i32 }, } + +/// When Zed started not as an *.app but as a binary (e.g. local development), +/// there's a possibility to tell it to behave "regularly". +pub const FORCE_CLI_MODE_ENV_VAR_NAME: &str = "ZED_FORCE_CLI_MODE"; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a31e59587f..0ae4d2477e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,6 +1,6 @@ -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use clap::Parser; -use cli::{CliRequest, CliResponse, IpcHandshake}; +use cli::{CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME}; use core_foundation::{ array::{CFArray, CFIndex}, string::kCFStringEncodingUTF8, @@ -43,20 +43,10 @@ struct InfoPlist { fn main() -> Result<()> { let args = Args::parse(); - let bundle_path = if let Some(bundle_path) = args.bundle_path { - bundle_path.canonicalize()? - } else { - locate_bundle()? - }; + let bundle = Bundle::detect(args.bundle_path.as_deref()).context("Bundle detection")?; if args.version { - let plist_path = bundle_path.join("Contents/Info.plist"); - let plist = plist::from_file::<_, InfoPlist>(plist_path)?; - println!( - "Zed {} – {}", - plist.bundle_short_version_string, - bundle_path.to_string_lossy() - ); + println!("{}", bundle.zed_version_string()); return Ok(()); } @@ -66,7 +56,7 @@ fn main() -> Result<()> { } } - let (tx, rx) = launch_app(bundle_path)?; + let (tx, rx) = bundle.launch()?; tx.send(CliRequest::Open { paths: args @@ -89,6 +79,148 @@ fn main() -> Result<()> { Ok(()) } +enum Bundle { + App { + app_bundle: PathBuf, + plist: InfoPlist, + }, + LocalPath { + executable: PathBuf, + plist: InfoPlist, + }, +} + +impl Bundle { + fn detect(args_bundle_path: Option<&Path>) -> anyhow::Result { + let bundle_path = if let Some(bundle_path) = args_bundle_path { + bundle_path + .canonicalize() + .with_context(|| format!("Args bundle path {bundle_path:?} canonicalization"))? + } else { + locate_bundle().context("bundle autodiscovery")? + }; + + match bundle_path.extension().and_then(|ext| ext.to_str()) { + Some("app") => { + let plist_path = bundle_path.join("Contents/Info.plist"); + let plist = plist::from_file::<_, InfoPlist>(&plist_path).with_context(|| { + format!("Reading *.app bundle plist file at {plist_path:?}") + })?; + Ok(Self::App { + app_bundle: bundle_path, + plist, + }) + } + _ => { + println!("Bundle path {bundle_path:?} has no *.app extension, attempting to locate a dev build"); + let plist_path = bundle_path + .parent() + .with_context(|| format!("Bundle path {bundle_path:?} has no parent"))? + .join("WebRTC.framework/Resources/Info.plist"); + let plist = plist::from_file::<_, InfoPlist>(&plist_path) + .with_context(|| format!("Reading dev bundle plist file at {plist_path:?}"))?; + Ok(Self::LocalPath { + executable: bundle_path, + plist, + }) + } + } + } + + fn plist(&self) -> &InfoPlist { + match self { + Self::App { plist, .. } => plist, + Self::LocalPath { plist, .. } => plist, + } + } + + fn path(&self) -> &Path { + match self { + Self::App { app_bundle, .. } => app_bundle, + Self::LocalPath { + executable: excutable, + .. + } => excutable, + } + } + + fn launch(&self) -> anyhow::Result<(IpcSender, IpcReceiver)> { + let (server, server_name) = + IpcOneShotServer::::new().context("Handshake before Zed spawn")?; + let url = format!("zed-cli://{server_name}"); + + match self { + Self::App { app_bundle, .. } => { + let app_path = app_bundle; + + let status = unsafe { + let app_url = CFURL::from_path(app_path, true) + .with_context(|| format!("invalid app path {app_path:?}"))?; + let url_to_open = CFURL::wrap_under_create_rule(CFURLCreateWithBytes( + ptr::null(), + url.as_ptr(), + url.len() as CFIndex, + kCFStringEncodingUTF8, + ptr::null(), + )); + let urls_to_open = CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]); + LSOpenFromURLSpec( + &LSLaunchURLSpec { + appURL: app_url.as_concrete_TypeRef(), + itemURLs: urls_to_open.as_concrete_TypeRef(), + passThruParams: ptr::null(), + launchFlags: kLSLaunchDefaults, + asyncRefCon: ptr::null_mut(), + }, + ptr::null_mut(), + ) + }; + + anyhow::ensure!( + status == 0, + "cannot start app bundle {}", + self.zed_version_string() + ); + } + Self::LocalPath { executable, .. } => { + let executable_parent = executable + .parent() + .with_context(|| format!("Executable {executable:?} path has no parent"))?; + let subprocess_stdout_file = + fs::File::create(executable_parent.join("zed_dev.log")) + .with_context(|| format!("Log file creation in {executable_parent:?}"))?; + let subprocess_stdin_file = + subprocess_stdout_file.try_clone().with_context(|| { + format!("Cloning descriptor for file {subprocess_stdout_file:?}") + })?; + let mut command = std::process::Command::new(executable); + let command = command + .env(FORCE_CLI_MODE_ENV_VAR_NAME, "") + .stderr(subprocess_stdout_file) + .stdout(subprocess_stdin_file) + .arg(url); + + command + .spawn() + .with_context(|| format!("Spawning {command:?}"))?; + } + } + + let (_, handshake) = server.accept().context("Handshake after Zed spawn")?; + Ok((handshake.requests, handshake.responses)) + } + + fn zed_version_string(&self) -> String { + let is_dev = matches!(self, Self::LocalPath { .. }); + format!( + "Zed {}{} – {}", + self.plist().bundle_short_version_string, + if is_dev { " (dev)" } else { "" }, + self.path().display(), + ) + } +} + fn touch(path: &Path) -> io::Result<()> { match OpenOptions::new().create(true).write(true).open(path) { Ok(_) => Ok(()), @@ -106,38 +238,3 @@ fn locate_bundle() -> Result { } Ok(app_path) } - -fn launch_app(app_path: PathBuf) -> Result<(IpcSender, IpcReceiver)> { - let (server, server_name) = IpcOneShotServer::::new()?; - let url = format!("zed-cli://{server_name}"); - - let status = unsafe { - let app_url = - CFURL::from_path(&app_path, true).ok_or_else(|| anyhow!("invalid app path"))?; - let url_to_open = CFURL::wrap_under_create_rule(CFURLCreateWithBytes( - ptr::null(), - url.as_ptr(), - url.len() as CFIndex, - kCFStringEncodingUTF8, - ptr::null(), - )); - let urls_to_open = CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]); - LSOpenFromURLSpec( - &LSLaunchURLSpec { - appURL: app_url.as_concrete_TypeRef(), - itemURLs: urls_to_open.as_concrete_TypeRef(), - passThruParams: ptr::null(), - launchFlags: kLSLaunchDefaults, - asyncRefCon: ptr::null_mut(), - }, - ptr::null_mut(), - ) - }; - - if status == 0 { - let (_, handshake) = server.accept()?; - Ok((handshake.requests, handshake.responses)) - } else { - Err(anyhow!("cannot start {:?}", app_path)) - } -} diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f498078b52..60a2fc66be 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -6,7 +6,7 @@ use assets::Assets; use backtrace::Backtrace; use cli::{ ipc::{self, IpcSender}, - CliRequest, CliResponse, IpcHandshake, + CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME, }; use client::{self, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN}; use db::kvp::KEY_VALUE_STORE; @@ -37,7 +37,10 @@ use std::{ os::unix::prelude::OsStrExt, panic, path::PathBuf, - sync::{Arc, Weak}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Weak, + }, thread, time::Duration, }; @@ -89,29 +92,17 @@ fn main() { }; let (cli_connections_tx, mut cli_connections_rx) = mpsc::unbounded(); + let cli_connections_tx = Arc::new(cli_connections_tx); let (open_paths_tx, mut open_paths_rx) = mpsc::unbounded(); + let open_paths_tx = Arc::new(open_paths_tx); + let urls_callback_triggered = Arc::new(AtomicBool::new(false)); + + let callback_cli_connections_tx = Arc::clone(&cli_connections_tx); + let callback_open_paths_tx = Arc::clone(&open_paths_tx); + let callback_urls_callback_triggered = Arc::clone(&urls_callback_triggered); app.on_open_urls(move |urls, _| { - if let Some(server_name) = urls.first().and_then(|url| url.strip_prefix("zed-cli://")) { - if let Some(cli_connection) = connect_to_cli(server_name).log_err() { - cli_connections_tx - .unbounded_send(cli_connection) - .map_err(|_| anyhow!("no listener for cli connections")) - .log_err(); - }; - } else { - let paths: Vec<_> = urls - .iter() - .flat_map(|url| url.strip_prefix("file://")) - .map(|url| { - let decoded = urlencoding::decode_binary(url.as_bytes()); - PathBuf::from(OsStr::from_bytes(decoded.as_ref())) - }) - .collect(); - open_paths_tx - .unbounded_send(paths) - .map_err(|_| anyhow!("no listener for open urls requests")) - .log_err(); - } + callback_urls_callback_triggered.store(true, Ordering::Release); + open_urls(urls, &callback_cli_connections_tx, &callback_open_paths_tx); }) .on_reopen(move |cx| { if cx.has_global::>() { @@ -234,6 +225,14 @@ fn main() { workspace::open_paths(&paths, &app_state, None, cx).detach_and_log_err(cx); } } else { + // TODO Development mode that forces the CLI mode usually runs Zed binary as is instead + // of an *app, hence gets no specific callbacks run. Emulate them here, if needed. + if std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_some() + && !urls_callback_triggered.load(Ordering::Acquire) + { + open_urls(collect_url_args(), &cli_connections_tx, &open_paths_tx) + } + if let Ok(Some(connection)) = cli_connections_rx.try_next() { cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx)) .detach(); @@ -284,6 +283,37 @@ fn main() { }); } +fn open_urls( + urls: Vec, + cli_connections_tx: &mpsc::UnboundedSender<( + mpsc::Receiver, + IpcSender, + )>, + open_paths_tx: &mpsc::UnboundedSender>, +) { + if let Some(server_name) = urls.first().and_then(|url| url.strip_prefix("zed-cli://")) { + if let Some(cli_connection) = connect_to_cli(server_name).log_err() { + cli_connections_tx + .unbounded_send(cli_connection) + .map_err(|_| anyhow!("no listener for cli connections")) + .log_err(); + }; + } else { + let paths: Vec<_> = urls + .iter() + .flat_map(|url| url.strip_prefix("file://")) + .map(|url| { + let decoded = urlencoding::decode_binary(url.as_bytes()); + PathBuf::from(OsStr::from_bytes(decoded.as_ref())) + }) + .collect(); + open_paths_tx + .unbounded_send(paths) + .map_err(|_| anyhow!("no listener for open urls requests")) + .log_err(); + } +} + async fn restore_or_create_workspace(app_state: &Arc, mut cx: AsyncAppContext) { if let Some(location) = workspace::last_opened_workspace_paths().await { cx.update(|cx| workspace::open_paths(location.paths().as_ref(), app_state, None, cx)) @@ -514,7 +544,8 @@ async fn load_login_shell_environment() -> Result<()> { } fn stdout_is_a_pty() -> bool { - unsafe { libc::isatty(libc::STDOUT_FILENO as i32) != 0 } + std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none() + && unsafe { libc::isatty(libc::STDOUT_FILENO as i32) != 0 } } fn collect_path_args() -> Vec { @@ -527,7 +558,11 @@ fn collect_path_args() -> Vec { None } }) - .collect::>() + .collect() +} + +fn collect_url_args() -> Vec { + env::args().skip(1).collect() } fn load_embedded_fonts(app: &App) {