ZIm/crates/cli/src/main.rs
Kirill Bulatov 16366cf9f2
Use anyhow more idiomatically (#31052)
https://github.com/zed-industries/zed/issues/30972 brought up another
case where our context is not enough to track the actual source of the
issue: we get a general top-level error without inner error.

The reason for this was `.ok_or_else(|| anyhow!("failed to read HEAD
SHA"))?; ` on the top level.

The PR finally reworks the way we use anyhow to reduce such issues (or
at least make it simpler to bubble them up later in a fix).
On top of that, uses a few more anyhow methods for better readability.

* `.ok_or_else(|| anyhow!("..."))`, `map_err` and other similar error
conversion/option reporting cases are replaced with `context` and
`with_context` calls
* in addition to that, various `anyhow!("failed to do ...")` are
stripped with `.context("Doing ...")` messages instead to remove the
parasitic `failed to` text
* `anyhow::ensure!` is used instead of `if ... { return Err(...); }`
calls
* `anyhow::bail!` is used instead of `return Err(anyhow!(...));`

Release Notes:

- N/A
2025-05-20 23:06:07 +00:00

977 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![cfg_attr(
any(target_os = "linux", target_os = "freebsd", target_os = "windows"),
allow(dead_code)
)]
use anyhow::{Context as _, Result};
use clap::Parser;
use cli::{CliRequest, CliResponse, IpcHandshake, ipc::IpcOneShotServer};
use collections::HashMap;
use parking_lot::Mutex;
use std::{
env, fs, io,
path::{Path, PathBuf},
process::ExitStatus,
sync::Arc,
thread::{self, JoinHandle},
};
use tempfile::NamedTempFile;
use util::paths::PathWithPosition;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use std::io::IsTerminal;
struct Detect;
trait InstalledApp {
fn zed_version_string(&self) -> String;
fn launch(&self, ipc_url: String) -> anyhow::Result<()>;
fn run_foreground(
&self,
ipc_url: String,
user_data_dir: Option<&str>,
) -> io::Result<ExitStatus>;
fn path(&self) -> PathBuf;
}
#[derive(Parser, Debug)]
#[command(
name = "zed",
disable_version_flag = true,
before_help = "The Zed CLI binary.
This CLI is a separate binary that invokes Zed.
Examples:
`zed`
Simply opens Zed
`zed --foreground`
Runs in foreground (shows all logs)
`zed path-to-your-project`
Open your project in Zed
`zed -n path-to-file `
Open file/folder in a new window",
after_help = "To read from stdin, append '-', e.g. 'ps axf | zed -'"
)]
struct Args {
/// Wait for all of the given paths to be opened/closed before exiting.
#[arg(short, long)]
wait: bool,
/// Add files to the currently open workspace
#[arg(short, long, overrides_with = "new")]
add: bool,
/// Create a new workspace
#[arg(short, long, overrides_with = "add")]
new: bool,
/// Sets a custom directory for all user data (e.g., database, extensions, logs).
/// This overrides the default platform-specific data directory location.
/// On macOS, the default is `~/Library/Application Support/Zed`.
/// On Linux/FreeBSD, the default is `$XDG_DATA_HOME/zed`.
/// On Windows, the default is `%LOCALAPPDATA%\Zed`.
#[arg(long, value_name = "DIR")]
user_data_dir: Option<String>,
/// The paths to open in Zed (space-separated).
///
/// Use `path:line:column` syntax to open a file at the given line and column.
paths_with_position: Vec<String>,
/// Print Zed's version and the app path.
#[arg(short, long)]
version: bool,
/// Run zed in the foreground (useful for debugging)
#[arg(long)]
foreground: bool,
/// Custom path to Zed.app or the zed binary
#[arg(long)]
zed: Option<PathBuf>,
/// Run zed in dev-server mode
#[arg(long)]
dev_server_token: Option<String>,
/// Not supported in Zed CLI, only supported on Zed binary
/// Will attempt to give the correct command to run
#[arg(long)]
system_specs: bool,
/// Uninstall Zed from user system
#[cfg(all(
any(target_os = "linux", target_os = "macos"),
not(feature = "no-bundled-uninstall")
))]
#[arg(long)]
uninstall: bool,
}
fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
let canonicalized = match Path::new(argument_str).canonicalize() {
Ok(existing_path) => PathWithPosition::from_path(existing_path),
Err(_) => {
let path = PathWithPosition::parse_str(argument_str);
let curdir = env::current_dir().context("retrieving current directory")?;
path.map_path(|path| match fs::canonicalize(&path) {
Ok(path) => Ok(path),
Err(e) => {
if let Some(mut parent) = path.parent() {
if parent == Path::new("") {
parent = &curdir
}
match fs::canonicalize(parent) {
Ok(parent) => Ok(parent.join(path.file_name().unwrap())),
Err(_) => Err(e),
}
} else {
Err(e)
}
}
})
}
.with_context(|| format!("parsing as path with position {argument_str}"))?,
};
Ok(canonicalized.to_string(|path| path.to_string_lossy().to_string()))
}
fn main() -> Result<()> {
// Exit flatpak sandbox if needed
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
flatpak::try_restart_to_host();
flatpak::ld_extra_libs();
}
// Intercept version designators
#[cfg(target_os = "macos")]
if let Some(channel) = std::env::args().nth(1).filter(|arg| arg.starts_with("--")) {
// When the first argument is a name of a release channel, we're going to spawn off the CLI of that version, with trailing args passed along.
use std::str::FromStr as _;
if let Ok(channel) = release_channel::ReleaseChannel::from_str(&channel[2..]) {
return mac_os::spawn_channel_cli(channel, std::env::args().skip(2).collect());
}
}
let args = Args::parse();
// Set custom data directory before any path operations
let user_data_dir = args.user_data_dir.clone();
if let Some(dir) = &user_data_dir {
paths::set_custom_data_dir(dir);
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let args = flatpak::set_bin_if_no_escape(args);
let app = Detect::detect(args.zed.as_deref()).context("Bundle detection")?;
if args.version {
println!("{}", app.zed_version_string());
return Ok(());
}
if args.system_specs {
let path = app.path();
let msg = [
"The `--system-specs` argument is not supported in the Zed CLI, only on Zed binary.",
"To retrieve the system specs on the command line, run the following command:",
&format!("{} --system-specs", path.display()),
];
anyhow::bail!(msg.join("\n"));
}
#[cfg(all(
any(target_os = "linux", target_os = "macos"),
not(feature = "no-bundled-uninstall")
))]
if args.uninstall {
static UNINSTALL_SCRIPT: &[u8] = include_bytes!("../../../script/uninstall.sh");
let tmp_dir = tempfile::tempdir()?;
let script_path = tmp_dir.path().join("uninstall.sh");
fs::write(&script_path, UNINSTALL_SCRIPT)?;
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
let status = std::process::Command::new("sh")
.arg(&script_path)
.env("ZED_CHANNEL", &*release_channel::RELEASE_CHANNEL_NAME)
.status()
.context("Failed to execute uninstall script")?;
std::process::exit(status.code().unwrap_or(1));
}
let (server, server_name) =
IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Zed spawn")?;
let url = format!("zed-cli://{server_name}");
let open_new_workspace = if args.new {
Some(true)
} else if args.add {
Some(false)
} else {
None
};
let env = {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
// On Linux, the desktop entry uses `cli` to spawn `zed`.
// We need to handle env vars correctly since std::env::vars() may not contain
// project-specific vars (e.g. those set by direnv).
// By setting env to None here, the LSP will use worktree env vars instead,
// which is what we want.
if !std::io::stdout().is_terminal() {
None
} else {
Some(std::env::vars().collect::<HashMap<_, _>>())
}
}
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
Some(std::env::vars().collect::<HashMap<_, _>>())
};
let exit_status = Arc::new(Mutex::new(None));
let mut paths = vec![];
let mut urls = vec![];
let mut stdin_tmp_file: Option<fs::File> = None;
let mut anonymous_fd_tmp_files = vec![];
for path in args.paths_with_position.iter() {
if path.starts_with("zed://")
|| path.starts_with("http://")
|| path.starts_with("https://")
|| path.starts_with("file://")
|| path.starts_with("ssh://")
{
urls.push(path.to_string());
} else if path == "-" && args.paths_with_position.len() == 1 {
let file = NamedTempFile::new()?;
paths.push(file.path().to_string_lossy().to_string());
let (file, _) = file.keep()?;
stdin_tmp_file = Some(file);
} else if let Some(file) = anonymous_fd(path) {
let tmp_file = NamedTempFile::new()?;
paths.push(tmp_file.path().to_string_lossy().to_string());
let (tmp_file, _) = tmp_file.keep()?;
anonymous_fd_tmp_files.push((file, tmp_file));
} else {
paths.push(parse_path_with_position(path)?)
}
}
anyhow::ensure!(
args.dev_server_token.is_none(),
"Dev servers were removed in v0.157.x please upgrade to SSH remoting: https://zed.dev/docs/remote-development"
);
let sender: JoinHandle<anyhow::Result<()>> = thread::spawn({
let exit_status = exit_status.clone();
let user_data_dir_for_thread = user_data_dir.clone();
move || {
let (_, handshake) = server.accept().context("Handshake after Zed spawn")?;
let (tx, rx) = (handshake.requests, handshake.responses);
tx.send(CliRequest::Open {
paths,
urls,
wait: args.wait,
open_new_workspace,
env,
user_data_dir: user_data_dir_for_thread,
})?;
while let Ok(response) = rx.recv() {
match response {
CliResponse::Ping => {}
CliResponse::Stdout { message } => println!("{message}"),
CliResponse::Stderr { message } => eprintln!("{message}"),
CliResponse::Exit { status } => {
exit_status.lock().replace(status);
return Ok(());
}
}
}
Ok(())
}
});
let stdin_pipe_handle: Option<JoinHandle<anyhow::Result<()>>> =
stdin_tmp_file.map(|tmp_file| {
thread::spawn(move || {
let stdin = std::io::stdin().lock();
if io::IsTerminal::is_terminal(&stdin) {
return Ok(());
}
return pipe_to_tmp(stdin, tmp_file);
})
});
let anonymous_fd_pipe_handles: Vec<JoinHandle<anyhow::Result<()>>> = anonymous_fd_tmp_files
.into_iter()
.map(|(file, tmp_file)| thread::spawn(move || pipe_to_tmp(file, tmp_file)))
.collect();
if args.foreground {
app.run_foreground(url, user_data_dir.as_deref())?;
} else {
app.launch(url)?;
sender.join().unwrap()?;
if let Some(handle) = stdin_pipe_handle {
handle.join().unwrap()?;
}
for handle in anonymous_fd_pipe_handles {
handle.join().unwrap()?;
}
}
if let Some(exit_status) = exit_status.lock().take() {
std::process::exit(exit_status);
}
Ok(())
}
fn pipe_to_tmp(mut src: impl io::Read, mut dest: fs::File) -> Result<()> {
let mut buffer = [0; 8 * 1024];
loop {
let bytes_read = match src.read(&mut buffer) {
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
res => res?,
};
if bytes_read == 0 {
break;
}
io::Write::write_all(&mut dest, &buffer[..bytes_read])?;
}
io::Write::flush(&mut dest)?;
Ok(())
}
fn anonymous_fd(path: &str) -> Option<fs::File> {
#[cfg(target_os = "linux")]
{
use std::os::fd::{self, FromRawFd};
let fd_str = path.strip_prefix("/proc/self/fd/")?;
let link = fs::read_link(path).ok()?;
if !link.starts_with("memfd:") {
return None;
}
let fd: fd::RawFd = fd_str.parse().ok()?;
let file = unsafe { fs::File::from_raw_fd(fd) };
return Some(file);
}
#[cfg(target_os = "macos")]
{
use std::os::{
fd::{self, FromRawFd},
unix::fs::FileTypeExt,
};
let fd_str = path.strip_prefix("/dev/fd/")?;
let metadata = fs::metadata(path).ok()?;
let file_type = metadata.file_type();
if !file_type.is_fifo() && !file_type.is_socket() {
return None;
}
let fd: fd::RawFd = fd_str.parse().ok()?;
let file = unsafe { fs::File::from_raw_fd(fd) };
return Some(file);
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
_ = path;
// not implemented for bsd, windows. Could be, but isn't yet
return None;
}
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
mod linux {
use std::{
env,
ffi::OsString,
io,
os::unix::net::{SocketAddr, UnixDatagram},
path::{Path, PathBuf},
process::{self, ExitStatus},
sync::LazyLock,
thread,
time::Duration,
};
use anyhow::{Context as _, anyhow};
use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
use fork::Fork;
use crate::{Detect, InstalledApp};
static RELEASE_CHANNEL: LazyLock<String> =
LazyLock::new(|| include_str!("../../zed/RELEASE_CHANNEL").trim().to_string());
struct App(PathBuf);
impl Detect {
pub fn detect(path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
let path = if let Some(path) = path {
path.to_path_buf().canonicalize()?
} else {
let cli = env::current_exe()?;
let dir = cli.parent().context("no parent path for cli")?;
// libexec is the standard, lib/zed is for Arch (and other non-libexec distros),
// ./zed is for the target directory in development builds.
let possible_locations =
["../libexec/zed-editor", "../lib/zed/zed-editor", "./zed"];
possible_locations
.iter()
.find_map(|p| dir.join(p).canonicalize().ok().filter(|path| path != &cli))
.with_context(|| {
format!("could not find any of: {}", possible_locations.join(", "))
})?
};
Ok(App(path))
}
}
impl InstalledApp for App {
fn zed_version_string(&self) -> String {
format!(
"Zed {}{}{} {}",
if *RELEASE_CHANNEL == "stable" {
"".to_string()
} else {
format!("{} ", *RELEASE_CHANNEL)
},
option_env!("RELEASE_VERSION").unwrap_or_default(),
match option_env!("ZED_COMMIT_SHA") {
Some(commit_sha) => format!(" {commit_sha} "),
None => "".to_string(),
},
self.0.display(),
)
}
fn launch(&self, ipc_url: String) -> anyhow::Result<()> {
let sock_path = paths::data_dir().join(format!("zed-{}.sock", *RELEASE_CHANNEL));
let sock = UnixDatagram::unbound()?;
if sock.connect(&sock_path).is_err() {
self.boot_background(ipc_url)?;
} else {
sock.send(ipc_url.as_bytes())?;
}
Ok(())
}
fn run_foreground(
&self,
ipc_url: String,
user_data_dir: Option<&str>,
) -> io::Result<ExitStatus> {
let mut cmd = std::process::Command::new(self.0.clone());
cmd.arg(ipc_url);
if let Some(dir) = user_data_dir {
cmd.arg("--user-data-dir").arg(dir);
}
cmd.status()
}
fn path(&self) -> PathBuf {
self.0.clone()
}
}
impl App {
fn boot_background(&self, ipc_url: String) -> anyhow::Result<()> {
let path = &self.0;
match fork::fork() {
Ok(Fork::Parent(_)) => Ok(()),
Ok(Fork::Child) => {
unsafe { std::env::set_var(FORCE_CLI_MODE_ENV_VAR_NAME, "") };
if let Err(_) = fork::setsid() {
eprintln!("failed to setsid: {}", std::io::Error::last_os_error());
process::exit(1);
}
if let Err(_) = fork::close_fd() {
eprintln!("failed to close_fd: {}", std::io::Error::last_os_error());
}
let error =
exec::execvp(path.clone(), &[path.as_os_str(), &OsString::from(ipc_url)]);
// if exec succeeded, we never get here.
eprintln!("failed to exec {:?}: {}", path, error);
process::exit(1)
}
Err(_) => Err(anyhow!(io::Error::last_os_error())),
}
}
fn wait_for_socket(
&self,
sock_addr: &SocketAddr,
sock: &mut UnixDatagram,
) -> Result<(), std::io::Error> {
for _ in 0..100 {
thread::sleep(Duration::from_millis(10));
if sock.connect_addr(&sock_addr).is_ok() {
return Ok(());
}
}
sock.connect_addr(&sock_addr)
}
}
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
mod flatpak {
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command;
use std::{env, process};
const EXTRA_LIB_ENV_NAME: &'static str = "ZED_FLATPAK_LIB_PATH";
const NO_ESCAPE_ENV_NAME: &'static str = "ZED_FLATPAK_NO_ESCAPE";
/// Adds bundled libraries to LD_LIBRARY_PATH if running under flatpak
pub fn ld_extra_libs() {
let mut paths = if let Ok(paths) = env::var("LD_LIBRARY_PATH") {
env::split_paths(&paths).collect()
} else {
Vec::new()
};
if let Ok(extra_path) = env::var(EXTRA_LIB_ENV_NAME) {
paths.push(extra_path.into());
}
unsafe { env::set_var("LD_LIBRARY_PATH", env::join_paths(paths).unwrap()) };
}
/// Restarts outside of the sandbox if currently running within it
pub fn try_restart_to_host() {
if let Some(flatpak_dir) = get_flatpak_dir() {
let mut args = vec!["/usr/bin/flatpak-spawn".into(), "--host".into()];
args.append(&mut get_xdg_env_args());
args.push("--env=ZED_UPDATE_EXPLANATION=Please use flatpak to update zed".into());
args.push(
format!(
"--env={EXTRA_LIB_ENV_NAME}={}",
flatpak_dir.join("lib").to_str().unwrap()
)
.into(),
);
args.push(flatpak_dir.join("bin").join("zed").into());
let mut is_app_location_set = false;
for arg in &env::args_os().collect::<Vec<_>>()[1..] {
args.push(arg.clone());
is_app_location_set |= arg == "--zed";
}
if !is_app_location_set {
args.push("--zed".into());
args.push(flatpak_dir.join("libexec").join("zed-editor").into());
}
let error = exec::execvp("/usr/bin/flatpak-spawn", args);
eprintln!("failed restart cli on host: {:?}", error);
process::exit(1);
}
}
pub fn set_bin_if_no_escape(mut args: super::Args) -> super::Args {
if env::var(NO_ESCAPE_ENV_NAME).is_ok()
&& env::var("FLATPAK_ID").map_or(false, |id| id.starts_with("dev.zed.Zed"))
{
if args.zed.is_none() {
args.zed = Some("/app/libexec/zed-editor".into());
unsafe {
env::set_var("ZED_UPDATE_EXPLANATION", "Please use flatpak to update zed")
};
}
}
args
}
fn get_flatpak_dir() -> Option<PathBuf> {
if env::var(NO_ESCAPE_ENV_NAME).is_ok() {
return None;
}
if let Ok(flatpak_id) = env::var("FLATPAK_ID") {
if !flatpak_id.starts_with("dev.zed.Zed") {
return None;
}
let install_dir = Command::new("/usr/bin/flatpak-spawn")
.arg("--host")
.arg("flatpak")
.arg("info")
.arg("--show-location")
.arg(flatpak_id)
.output()
.unwrap();
let install_dir = PathBuf::from(String::from_utf8(install_dir.stdout).unwrap().trim());
Some(install_dir.join("files"))
} else {
None
}
}
fn get_xdg_env_args() -> Vec<OsString> {
let xdg_keys = [
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_STATE_HOME",
];
env::vars()
.filter(|(key, _)| xdg_keys.contains(&key.as_str()))
.map(|(key, val)| format!("--env=FLATPAK_{}={}", key, val).into())
.collect()
}
}
#[cfg(target_os = "windows")]
mod windows {
use anyhow::Context;
use release_channel::app_identifier;
use windows::{
Win32::{
Foundation::{CloseHandle, ERROR_ALREADY_EXISTS, GENERIC_WRITE, GetLastError},
Storage::FileSystem::{
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_MODE, OPEN_EXISTING, WriteFile,
},
System::Threading::CreateMutexW,
},
core::HSTRING,
};
use crate::{Detect, InstalledApp};
use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
fn check_single_instance() -> bool {
let mutex = unsafe {
CreateMutexW(
None,
false,
&HSTRING::from(format!("{}-Instance-Mutex", app_identifier())),
)
.expect("Unable to create instance sync event")
};
let last_err = unsafe { GetLastError() };
let _ = unsafe { CloseHandle(mutex) };
last_err != ERROR_ALREADY_EXISTS
}
struct App(PathBuf);
impl InstalledApp for App {
fn zed_version_string(&self) -> String {
format!(
"Zed {}{}{} {}",
if *release_channel::RELEASE_CHANNEL_NAME == "stable" {
"".to_string()
} else {
format!("{} ", *release_channel::RELEASE_CHANNEL_NAME)
},
option_env!("RELEASE_VERSION").unwrap_or_default(),
match option_env!("ZED_COMMIT_SHA") {
Some(commit_sha) => format!(" {commit_sha} "),
None => "".to_string(),
},
self.0.display(),
)
}
fn launch(&self, ipc_url: String) -> anyhow::Result<()> {
if check_single_instance() {
std::process::Command::new(self.0.clone())
.arg(ipc_url)
.spawn()?;
} else {
unsafe {
let pipe = CreateFileW(
&HSTRING::from(format!("\\\\.\\pipe\\{}-Named-Pipe", app_identifier())),
GENERIC_WRITE.0,
FILE_SHARE_MODE::default(),
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES::default(),
None,
)?;
let message = ipc_url.as_bytes();
let mut bytes_written = 0;
WriteFile(pipe, Some(message), Some(&mut bytes_written), None)?;
CloseHandle(pipe)?;
}
}
Ok(())
}
fn run_foreground(
&self,
ipc_url: String,
user_data_dir: Option<&str>,
) -> io::Result<ExitStatus> {
let mut cmd = std::process::Command::new(self.0.clone());
cmd.arg(ipc_url).arg("--foreground");
if let Some(dir) = user_data_dir {
cmd.arg("--user-data-dir").arg(dir);
}
cmd.spawn()?.wait()
}
fn path(&self) -> PathBuf {
self.0.clone()
}
}
impl Detect {
pub fn detect(path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
let path = if let Some(path) = path {
path.to_path_buf().canonicalize()?
} else {
let cli = std::env::current_exe()?;
let dir = cli.parent().context("no parent path for cli")?;
// ../Zed.exe is the standard, lib/zed is for MSYS2, ./zed.exe is for the target
// directory in development builds.
let possible_locations = ["../Zed.exe", "../lib/zed/zed-editor.exe", "./zed.exe"];
possible_locations
.iter()
.find_map(|p| dir.join(p).canonicalize().ok().filter(|path| path != &cli))
.context(format!(
"could not find any of: {}",
possible_locations.join(", ")
))?
};
Ok(App(path))
}
}
}
#[cfg(target_os = "macos")]
mod mac_os {
use anyhow::{Context as _, Result};
use core_foundation::{
array::{CFArray, CFIndex},
base::TCFType as _,
string::kCFStringEncodingUTF8,
url::{CFURL, CFURLCreateWithBytes},
};
use core_services::{LSLaunchURLSpec, LSOpenFromURLSpec, kLSLaunchDefaults};
use serde::Deserialize;
use std::{
ffi::OsStr,
fs, io,
path::{Path, PathBuf},
process::{Command, ExitStatus},
ptr,
};
use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
use crate::{Detect, InstalledApp};
#[derive(Debug, Deserialize)]
struct InfoPlist {
#[serde(rename = "CFBundleShortVersionString")]
bundle_short_version_string: String,
}
enum Bundle {
App {
app_bundle: PathBuf,
plist: InfoPlist,
},
LocalPath {
executable: PathBuf,
},
}
fn locate_bundle() -> Result<PathBuf> {
let cli_path = std::env::current_exe()?.canonicalize()?;
let mut app_path = cli_path.clone();
while app_path.extension() != Some(OsStr::new("app")) {
anyhow::ensure!(
app_path.pop(),
"cannot find app bundle containing {cli_path:?}"
);
}
Ok(app_path)
}
impl Detect {
pub fn detect(path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
let bundle_path = if let Some(bundle_path) = 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(Bundle::App {
app_bundle: bundle_path,
plist,
})
}
_ => Ok(Bundle::LocalPath {
executable: bundle_path,
}),
}
}
}
impl InstalledApp for Bundle {
fn zed_version_string(&self) -> String {
format!("Zed {} {}", self.version(), self.path().display(),)
}
fn launch(&self, url: String) -> anyhow::Result<()> {
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(),
));
// equivalent to: open zed-cli:... -a /Applications/Zed\ Preview.app
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:?}"))?;
}
}
Ok(())
}
fn run_foreground(
&self,
ipc_url: String,
user_data_dir: Option<&str>,
) -> io::Result<ExitStatus> {
let path = match self {
Bundle::App { app_bundle, .. } => app_bundle.join("Contents/MacOS/zed"),
Bundle::LocalPath { executable, .. } => executable.clone(),
};
let mut cmd = std::process::Command::new(path);
cmd.arg(ipc_url);
if let Some(dir) = user_data_dir {
cmd.arg("--user-data-dir").arg(dir);
}
cmd.status()
}
fn path(&self) -> PathBuf {
match self {
Bundle::App { app_bundle, .. } => app_bundle.join("Contents/MacOS/zed").clone(),
Bundle::LocalPath { executable, .. } => executable.clone(),
}
}
}
impl Bundle {
fn version(&self) -> String {
match self {
Self::App { plist, .. } => plist.bundle_short_version_string.clone(),
Self::LocalPath { .. } => "<development>".to_string(),
}
}
fn path(&self) -> &Path {
match self {
Self::App { app_bundle, .. } => app_bundle,
Self::LocalPath { executable, .. } => executable,
}
}
}
pub(super) fn spawn_channel_cli(
channel: release_channel::ReleaseChannel,
leftover_args: Vec<String>,
) -> Result<()> {
use anyhow::bail;
let app_id_prompt = format!("id of app \"{}\"", channel.display_name());
let app_id_output = Command::new("osascript")
.arg("-e")
.arg(&app_id_prompt)
.output()?;
if !app_id_output.status.success() {
bail!("Could not determine app id for {}", channel.display_name());
}
let app_name = String::from_utf8(app_id_output.stdout)?.trim().to_owned();
let app_path_prompt = format!("kMDItemCFBundleIdentifier == '{app_name}'");
let app_path_output = Command::new("mdfind").arg(app_path_prompt).output()?;
if !app_path_output.status.success() {
bail!(
"Could not determine app path for {}",
channel.display_name()
);
}
let app_path = String::from_utf8(app_path_output.stdout)?.trim().to_owned();
let cli_path = format!("{app_path}/Contents/MacOS/cli");
Command::new(cli_path).args(leftover_args).spawn()?;
Ok(())
}
}