Merge pull request #2476 from zed-industries/kb/faster-dev-cli

Kirill Bulatov created

Allow CLI to start Zed from local sources

Change summary

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(-)

Detailed changes

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";

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<Self> {
+        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<CliRequest>, IpcReceiver<CliResponse>)> {
+        let (server, server_name) =
+            IpcOneShotServer::<IpcHandshake>::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<PathBuf> {
     }
     Ok(app_path)
 }
-
-fn launch_app(app_path: PathBuf) -> Result<(IpcSender<CliRequest>, IpcReceiver<CliResponse>)> {
-    let (server, server_name) = IpcOneShotServer::<IpcHandshake>::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))
-    }
-}

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::<Weak<AppState>>() {
@@ -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<String>,
+    cli_connections_tx: &mpsc::UnboundedSender<(
+        mpsc::Receiver<CliRequest>,
+        IpcSender<CliResponse>,
+    )>,
+    open_paths_tx: &mpsc::UnboundedSender<Vec<PathBuf>>,
+) {
+    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<AppState>, 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<PathBuf> {
@@ -527,7 +558,11 @@ fn collect_path_args() -> Vec<PathBuf> {
                 None
             }
         })
-        .collect::<Vec<_>>()
+        .collect()
+}
+
+fn collect_url_args() -> Vec<String> {
+    env::args().skip(1).collect()
 }
 
 fn load_embedded_fonts(app: &App) {