Skip to repository content1560 lines · 55.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:32:22.503Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
main.rs
1#![allow(
2 clippy::disallowed_methods,
3 reason = "We are not in an async environment, so std::process::Command is fine"
4)]
5#![cfg_attr(
6 any(target_os = "linux", target_os = "freebsd", target_os = "windows"),
7 allow(dead_code)
8)]
9
10mod completions;
11
12use crate::completions::Shell;
13
14use anyhow::{Context as _, Result};
15use clap::{CommandFactory, Parser};
16use cli::{CliRequest, CliResponse, IpcHandshake, ipc::IpcOneShotServer};
17use parking_lot::Mutex;
18use std::{
19 collections::{BTreeMap, BTreeSet},
20 env,
21 ffi::OsStr,
22 fs, io,
23 path::{Path, PathBuf},
24 process::ExitStatus,
25 sync::Arc,
26 thread::{self, JoinHandle},
27};
28use tempfile::{NamedTempFile, TempDir};
29use util::paths::PathWithPosition;
30use walkdir::WalkDir;
31
32use std::io::IsTerminal;
33
34const URL_PREFIX: [&'static str; 5] = ["zed://", "http://", "https://", "file://", "ssh://"];
35
36struct Detect;
37
38trait InstalledApp {
39 fn app_version_string(&self) -> String;
40 fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()>;
41 fn run_foreground(
42 &self,
43 ipc_url: String,
44 user_data_dir: Option<&str>,
45 ) -> io::Result<ExitStatus>;
46 /// The executable to run — one file.
47 fn path(&self) -> PathBuf;
48 /// The whole installation — the directory `--uninstall` has to remove.
49 ///
50 /// Distinct from `path` because on macOS they are never the same thing:
51 /// `path` is `Omega.app/Contents/MacOS/omega`, and the installation is
52 /// `Omega.app`, which also holds `cli`, `omega-identity-proof`, and a
53 /// bundled Node runtime. `0.2.0-rc16` passed `path` to the uninstaller, so
54 /// a full uninstall left 130.9 MB and five executables in `/Applications`,
55 /// among them a `cli` that still carried `--uninstall` (omega#92).
56 fn installation_root(&self) -> PathBuf;
57}
58
59#[derive(Parser, Debug)]
60#[command(
61 name = "omega",
62 disable_version_flag = true,
63 before_help = "The Omega CLI binary.
64This CLI is a separate binary that invokes Omega.
65
66Examples:
67 `omega`
68 Simply opens Omega
69 `omega --foreground`
70 Runs in foreground (shows all logs)
71 `omega path-to-your-project`
72 Open your project in Omega
73 `omega -n path-to-file `
74 Open file/folder in a new window",
75 after_help = "To read from stdin, append '-', e.g. 'ps axf | omega -'"
76)]
77struct Args {
78 /// Wait for all of the given paths to be opened/closed before exiting.
79 ///
80 /// When opening a directory, waits until the created window is closed.
81 #[arg(short, long)]
82 wait: bool,
83 /// Add files to the currently open workspace
84 #[arg(short, long, overrides_with_all = ["new", "reuse", "existing", "classic"])]
85 add: bool,
86 /// Create a new workspace
87 #[arg(short, long, overrides_with_all = ["add", "reuse", "existing", "classic"])]
88 new: bool,
89 /// Reuse an existing window, replacing its workspace
90 #[arg(short, long, overrides_with_all = ["add", "new", "existing", "classic"], hide = true)]
91 reuse: bool,
92 /// Open in existing Omega window
93 #[arg(short = 'e', long = "existing", overrides_with_all = ["add", "new", "reuse", "classic"])]
94 existing: bool,
95 /// Use the classic open behavior: new window for directories, reuse for files
96 #[arg(long, hide = true, overrides_with_all = ["add", "new", "reuse", "existing"])]
97 classic: bool,
98 /// Sets a custom directory for all user data (e.g., database, extensions, logs).
99 /// This overrides the default platform-specific data directory location:
100 #[cfg_attr(
101 target_os = "macos",
102 doc = "`~/Library/Application Support/<Omega channel>`."
103 )]
104 #[cfg_attr(target_os = "windows", doc = "`%LOCALAPPDATA%\\<Omega channel>`.")]
105 #[cfg_attr(
106 not(any(target_os = "windows", target_os = "macos")),
107 doc = "`$XDG_DATA_HOME/<omega-channel>`."
108 )]
109 #[arg(long, value_name = "DIR", value_hint = clap::ValueHint::DirPath)]
110 user_data_dir: Option<String>,
111 /// The paths to open in Omega (space-separated).
112 ///
113 /// Use `path:line:column` syntax to open a file at the given line and column.
114 #[arg(trailing_var_arg = true, value_hint = clap::ValueHint::AnyPath)]
115 paths_with_position: Vec<String>,
116 /// Print Omega's version and the app path.
117 #[arg(short, long)]
118 version: bool,
119 /// Run Omega in the foreground (useful for debugging)
120 #[arg(long)]
121 foreground: bool,
122 /// Custom path to Omega.app or the omega binary
123 #[arg(long = "omega", alias = "zed")]
124 omega: Option<PathBuf>,
125 /// Run Omega in dev-server mode
126 #[arg(long)]
127 dev_server_token: Option<String>,
128 /// The username and WSL distribution to use when opening paths. If not specified,
129 /// Omega will attempt to open the paths directly.
130 ///
131 /// The username is optional, and if not specified, the default user for the distribution
132 /// will be used.
133 ///
134 /// Example: `me@Ubuntu` or `Ubuntu`.
135 ///
136 /// WARN: You should not fill in this field by hand.
137 #[cfg(target_os = "windows")]
138 #[arg(long, value_name = "USER@DISTRO")]
139 wsl: Option<String>,
140 /// Not supported in Omega CLI, only supported on Omega binary
141 /// Will attempt to give the correct command to run
142 #[arg(long)]
143 system_specs: bool,
144 /// Open the project in a dev container.
145 ///
146 /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/`
147 /// configuration is found in the project directory.
148 #[arg(long)]
149 dev_container: bool,
150 /// Pairs of file paths to diff. Can be specified multiple times.
151 /// When directories are provided, recurses into them and shows all changed files in a single multi-diff view.
152 #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"], value_hint = clap::ValueHint::AnyPath)]
153 diff: Vec<String>,
154 /// Generate shell completions for Omega
155 #[arg(long, value_names = ["SHELL"])]
156 completions: Option<Shell>,
157 /// Uninstall Omega from user system
158 #[cfg(all(
159 any(target_os = "linux", target_os = "macos"),
160 not(feature = "no-bundled-uninstall")
161 ))]
162 #[arg(long)]
163 uninstall: bool,
164
165 /// Used for SSH/Git password authentication, to remove the need for netcat as a dependency,
166 /// by having Omega act like netcat communicating over a Unix socket.
167 #[arg(long, hide = true)]
168 askpass: Option<String>,
169}
170
171/// Parses a path containing a position (e.g. `path:line:column`)
172/// and returns its canonicalized string representation.
173///
174/// If a part of path doesn't exist, it will canonicalize the
175/// existing part and append the non-existing part.
176///
177/// This method must return an absolute path, as many Omega
178/// crates assume absolute paths.
179fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
180 match Path::new(argument_str).canonicalize() {
181 Ok(existing_path) => Ok(PathWithPosition::from_path(existing_path)),
182 Err(_) => PathWithPosition::parse_str(argument_str).map_path(|mut path| {
183 let curdir = env::current_dir().context("retrieving current directory")?;
184 let mut children = Vec::new();
185 let root;
186 loop {
187 // canonicalize handles './', and '/'.
188 if let Ok(canonicalized) = fs::canonicalize(&path) {
189 root = canonicalized;
190 break;
191 }
192 // The comparison to `curdir` is just a shortcut
193 // since we know it is canonical. The other one
194 // is if `argument_str` is a string that starts
195 // with a name (e.g. "foo/bar").
196 if path == curdir || path == Path::new("") {
197 root = curdir;
198 break;
199 }
200 children.push(
201 path.file_name()
202 .with_context(|| format!("parsing as path with position {argument_str}"))?
203 .to_owned(),
204 );
205 if !path.pop() {
206 unreachable!("parsing as path with position {argument_str}");
207 }
208 }
209 Ok(children.iter().rev().fold(root, |mut path, child| {
210 path.push(child);
211 path
212 }))
213 }),
214 }
215 .map(|path_with_pos| path_with_pos.to_string(&|path| path.to_string_lossy().into_owned()))
216}
217
218/// Returns whether a `--diff` argument refers to an existing path, allowing a
219/// trailing `:line:column` suffix (parsed later by the Omega side, matching how
220/// regular `omega path:line:column` arguments are handled).
221fn diff_path_exists(diff_path: &str) -> bool {
222 Path::new(diff_path).exists() || PathWithPosition::parse_str(diff_path).path.exists()
223}
224
225fn expand_directory_diff_pairs(
226 diff_pairs: Vec<[String; 2]>,
227) -> anyhow::Result<(Vec<[String; 2]>, Vec<TempDir>)> {
228 let mut expanded = Vec::new();
229 let mut temp_dirs = Vec::new();
230
231 for pair in diff_pairs {
232 let left = PathBuf::from(&pair[0]);
233 let right = PathBuf::from(&pair[1]);
234
235 if left.is_dir() && right.is_dir() {
236 let (mut pairs, temp_dir) = expand_directory_pair(&left, &right)?;
237 expanded.append(&mut pairs);
238 if let Some(temp_dir) = temp_dir {
239 temp_dirs.push(temp_dir);
240 }
241 } else {
242 expanded.push(pair);
243 }
244 }
245
246 Ok((expanded, temp_dirs))
247}
248
249fn expand_directory_pair(
250 left: &Path,
251 right: &Path,
252) -> anyhow::Result<(Vec<[String; 2]>, Option<TempDir>)> {
253 let left_files = collect_files(left)?;
254 let right_files = collect_files(right)?;
255
256 let mut rel_paths = BTreeSet::new();
257 rel_paths.extend(left_files.keys().cloned());
258 rel_paths.extend(right_files.keys().cloned());
259
260 let mut temp_dir = TempDir::new()?;
261 let mut temp_dir_used = false;
262 let mut pairs = Vec::new();
263
264 for rel in rel_paths {
265 match (left_files.get(&rel), right_files.get(&rel)) {
266 (Some(left_path), Some(right_path)) => {
267 pairs.push([
268 left_path.to_string_lossy().into_owned(),
269 right_path.to_string_lossy().into_owned(),
270 ]);
271 }
272 (Some(left_path), None) => {
273 let stub = create_empty_stub(&mut temp_dir, &rel)?;
274 temp_dir_used = true;
275 pairs.push([
276 left_path.to_string_lossy().into_owned(),
277 stub.to_string_lossy().into_owned(),
278 ]);
279 }
280 (None, Some(right_path)) => {
281 let stub = create_empty_stub(&mut temp_dir, &rel)?;
282 temp_dir_used = true;
283 pairs.push([
284 stub.to_string_lossy().into_owned(),
285 right_path.to_string_lossy().into_owned(),
286 ]);
287 }
288 (None, None) => {}
289 }
290 }
291
292 let temp_dir = if temp_dir_used { Some(temp_dir) } else { None };
293 Ok((pairs, temp_dir))
294}
295
296fn collect_files(root: &Path) -> anyhow::Result<BTreeMap<PathBuf, PathBuf>> {
297 let mut files = BTreeMap::new();
298
299 for entry in WalkDir::new(root) {
300 let entry = entry?;
301 if entry.file_type().is_file() {
302 let rel = entry
303 .path()
304 .strip_prefix(root)
305 .context("stripping directory prefix")?
306 .to_path_buf();
307 files.insert(rel, entry.into_path());
308 }
309 }
310
311 Ok(files)
312}
313
314fn create_empty_stub(temp_dir: &mut TempDir, rel: &Path) -> anyhow::Result<PathBuf> {
315 let stub_path = temp_dir.path().join(rel);
316 if let Some(parent) = stub_path.parent() {
317 fs::create_dir_all(parent)?;
318 }
319 fs::File::create(&stub_path)?;
320 Ok(stub_path)
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use serde_json::json;
327 use util::path;
328 use util::paths::SanitizedPath;
329 use util::test::TempTree;
330
331 macro_rules! assert_path_eq {
332 ($left:expr, $right:expr) => {
333 assert_eq!(
334 SanitizedPath::new(Path::new(&$left)),
335 SanitizedPath::new(Path::new(&$right))
336 )
337 };
338 }
339
340 fn cwd() -> PathBuf {
341 env::current_dir().unwrap()
342 }
343
344 static CWD_LOCK: Mutex<()> = Mutex::new(());
345
346 fn with_cwd<T>(path: &Path, f: impl FnOnce() -> anyhow::Result<T>) -> anyhow::Result<T> {
347 let _lock = CWD_LOCK.lock();
348 let old_cwd = cwd();
349 env::set_current_dir(path)?;
350 let result = f();
351 env::set_current_dir(old_cwd)?;
352 result
353 }
354
355 #[test]
356 fn test_parse_non_existing_path() {
357 // Absolute path
358 let result = parse_path_with_position(path!("/non/existing/path.txt")).unwrap();
359 assert_path_eq!(result, path!("/non/existing/path.txt"));
360
361 // Absolute path in cwd
362 let path = cwd().join(path!("non/existing/path.txt"));
363 let expected = path.to_string_lossy().to_string();
364 let result = parse_path_with_position(&expected).unwrap();
365 assert_path_eq!(result, expected);
366
367 // Relative path
368 let result = parse_path_with_position(path!("non/existing/path.txt")).unwrap();
369 assert_path_eq!(result, expected)
370 }
371
372 #[test]
373 fn test_parse_existing_path() {
374 let temp_tree = TempTree::new(json!({
375 "file.txt": "",
376 }));
377 let file_path = temp_tree.path().join("file.txt");
378 let expected = file_path.to_string_lossy().to_string();
379
380 // Absolute path
381 let result = parse_path_with_position(file_path.to_str().unwrap()).unwrap();
382 assert_path_eq!(result, expected);
383
384 // Relative path
385 let result = with_cwd(temp_tree.path(), || parse_path_with_position("file.txt")).unwrap();
386 assert_path_eq!(result, expected);
387 }
388
389 // NOTE:
390 // While POSIX symbolic links are somewhat supported on Windows, they are an opt in by the user, and thus
391 // we assume that they are not supported out of the box.
392 #[cfg(not(windows))]
393 #[test]
394 fn test_parse_symlink_file() {
395 let temp_tree = TempTree::new(json!({
396 "target.txt": "",
397 }));
398 let target_path = temp_tree.path().join("target.txt");
399 let symlink_path = temp_tree.path().join("symlink.txt");
400 std::os::unix::fs::symlink(&target_path, &symlink_path).unwrap();
401
402 // Absolute path
403 let result = parse_path_with_position(symlink_path.to_str().unwrap()).unwrap();
404 assert_eq!(result, target_path.to_string_lossy());
405
406 // Relative path
407 let result =
408 with_cwd(temp_tree.path(), || parse_path_with_position("symlink.txt")).unwrap();
409 assert_eq!(result, target_path.to_string_lossy());
410 }
411
412 #[cfg(not(windows))]
413 #[test]
414 fn test_parse_symlink_dir() {
415 let temp_tree = TempTree::new(json!({
416 "some": {
417 "dir": { // symlink target
418 "ec": {
419 "tory": {
420 "file.txt": "",
421 }}}}}));
422
423 let target_file_path = temp_tree.path().join("some/dir/ec/tory/file.txt");
424 let expected = target_file_path.to_string_lossy();
425
426 let dir_path = temp_tree.path().join("some/dir");
427 let symlink_path = temp_tree.path().join("symlink");
428 std::os::unix::fs::symlink(&dir_path, &symlink_path).unwrap();
429
430 // Absolute path
431 let result =
432 parse_path_with_position(symlink_path.join("ec/tory/file.txt").to_str().unwrap())
433 .unwrap();
434 assert_eq!(result, expected);
435
436 // Relative path
437 let result = with_cwd(temp_tree.path(), || {
438 parse_path_with_position("symlink/ec/tory/file.txt")
439 })
440 .unwrap();
441 assert_eq!(result, expected);
442 }
443}
444
445fn parse_path_in_wsl(source: &str, wsl: &str) -> Result<String> {
446 let mut source = PathWithPosition::parse_str(source);
447
448 let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') {
449 if user.is_empty() {
450 anyhow::bail!("user is empty in wsl argument");
451 }
452 (Some(user), distro)
453 } else {
454 (None, wsl)
455 };
456
457 let mut args = vec!["--distribution", distro_name];
458 if let Some(user) = user {
459 args.push("--user");
460 args.push(user);
461 }
462
463 let command = [
464 OsStr::new("realpath"),
465 OsStr::new("-s"),
466 source.path.as_ref(),
467 ];
468
469 let output = util::command::new_std_command("wsl.exe")
470 .args(&args)
471 .arg("--exec")
472 .args(&command)
473 .output()?;
474 let result = if output.status.success() {
475 String::from_utf8_lossy(&output.stdout).to_string()
476 } else {
477 let fallback = util::command::new_std_command("wsl.exe")
478 .args(&args)
479 .arg("--")
480 .args(&command)
481 .output()?;
482 String::from_utf8_lossy(&fallback.stdout).to_string()
483 };
484
485 source.path = Path::new(result.trim()).to_owned();
486
487 Ok(source.to_string(&|path| path.to_string_lossy().into_owned()))
488}
489
490fn main() {
491 if let Err(error) = run() {
492 eprintln!("error: {error:#}");
493 std::process::exit(1);
494 }
495}
496
497fn run() -> Result<()> {
498 #[cfg(unix)]
499 util::prevent_root_execution();
500
501 // Exit flatpak sandbox if needed
502 #[cfg(target_os = "linux")]
503 {
504 flatpak::try_restart_to_host();
505 flatpak::ld_extra_libs();
506 }
507
508 // Intercept version designators
509 #[cfg(target_os = "macos")]
510 if let Some(channel) = std::env::args().nth(1).filter(|arg| arg.starts_with("--")) {
511 // 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.
512 use std::str::FromStr as _;
513
514 if let Ok(channel) = release_channel::ReleaseChannel::from_str(&channel[2..]) {
515 return mac_os::spawn_channel_cli(channel, std::env::args().skip(2).collect());
516 }
517 }
518
519 // Must happen before clap — SSH invokes cli.exe directly as SSH_ASKPASS
520 // and passes the socket path via env var to avoid argument parsing.
521 if let Ok(socket) = std::env::var("ZED_ASKPASS_SOCKET") {
522 askpass::main_from_args(&socket, std::env::args().skip(1));
523 return Ok(());
524 }
525
526 let args = Args::parse();
527
528 // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass
529 if let Some(socket) = &args.askpass {
530 askpass::main(socket);
531 return Ok(());
532 }
533
534 // Set custom data directory before any path operations
535 let user_data_dir = args.user_data_dir.clone();
536 if let Some(dir) = &user_data_dir {
537 paths::set_custom_data_dir(dir);
538 }
539
540 #[cfg(target_os = "linux")]
541 let args = flatpak::set_bin_if_no_escape(args);
542
543 let app = Detect::detect(args.omega.as_deref()).context("Bundle detection")?;
544
545 if let Some(shell) = &args.completions {
546 let file_path = std::env::current_exe()?;
547 let file_name = file_path
548 .file_name()
549 .and_then(OsStr::to_str)
550 .ok_or("--completions expects a UTF-8 name for cli bin")
551 .map_err(anyhow::Error::msg)?;
552 let mut cmd = Args::command();
553 cmd.set_bin_name(file_name);
554 cmd.build();
555 crate::completions::main(&cmd, shell);
556 return Ok(());
557 }
558
559 if args.version {
560 println!("{}", app.app_version_string());
561 return Ok(());
562 }
563
564 if args.system_specs {
565 let path = app.path();
566 let msg = [
567 "The `--system-specs` argument is not supported in the Omega CLI, only on Omega binary.",
568 "To retrieve the system specs on the command line, run the following command:",
569 &format!("{} --system-specs", path.display()),
570 ];
571 anyhow::bail!(msg.join("\n"));
572 }
573
574 #[cfg(all(
575 any(target_os = "linux", target_os = "macos"),
576 not(feature = "no-bundled-uninstall")
577 ))]
578 if args.uninstall {
579 // OMEGA-DELTA-0036. The plan is built here, from the same `paths::`
580 // functions the running application writes those directories with, and
581 // handed to the script. The script has no paths of its own, because a
582 // hand-written path table disconnected from the code that creates the
583 // directories is exactly how `0.2.0-rc14` shipped an "Uninstall Omega"
584 // that kept Omega and deleted another editor instead.
585 static UNINSTALL_SCRIPT: &[u8] = include_bytes!("../../../script/uninstall.sh");
586
587 let tmp_dir = tempfile::tempdir()?;
588 let script_path = tmp_dir.path().join("uninstall.sh");
589 fs::write(&script_path, UNINSTALL_SCRIPT)?;
590
591 use std::os::unix::fs::PermissionsExt as _;
592 fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
593
594 // `installation_root()`, never `path()`: on macOS the executable is one
595 // file inside `Omega.app`, and removing it leaves the bundle, four more
596 // executables and a bundled Node runtime behind (omega#92).
597 let plan =
598 cli::uninstall::UninstallRoots::from_installed_paths(Some(app.installation_root()))
599 .plan(paths::app_name());
600
601 let status = std::process::Command::new("sh")
602 .arg(&script_path)
603 .env("OMEGA_UNINSTALL_PRODUCT", &plan.product)
604 .env("OMEGA_UNINSTALL_PATHS", plan.paths_env())
605 .env("OMEGA_UNINSTALL_CONFIG_DIR", &plan.config_dir)
606 .status()
607 .context("Failed to execute uninstall script")?;
608
609 std::process::exit(status.code().unwrap_or(1));
610 }
611
612 let (server, server_name) =
613 IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Omega spawn")?;
614 let url = format!("zed-cli://{server_name}");
615
616 let open_behavior = if args.new {
617 cli::OpenBehavior::AlwaysNew
618 } else if args.add {
619 cli::OpenBehavior::Add
620 } else if args.existing {
621 cli::OpenBehavior::ExistingWindow
622 } else if args.classic {
623 cli::OpenBehavior::Classic
624 } else if args.reuse {
625 cli::OpenBehavior::Reuse
626 } else {
627 cli::OpenBehavior::Default
628 };
629
630 let env = {
631 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
632 {
633 use collections::HashMap;
634
635 // On Linux, the desktop entry uses `cli` to spawn `zed`.
636 // We need to handle env vars correctly since std::env::vars() may not contain
637 // project-specific vars (e.g. those set by direnv).
638 // By setting env to None here, the LSP will use worktree env vars instead,
639 // which is what we want.
640 if !std::io::stdout().is_terminal() {
641 None
642 } else {
643 Some(std::env::vars().collect::<HashMap<_, _>>())
644 }
645 }
646
647 #[cfg(target_os = "windows")]
648 {
649 // On Windows, by default, a child process inherits a copy of the environment block of the parent process.
650 // So we don't need to pass env vars explicitly.
651 None
652 }
653
654 #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "windows")))]
655 {
656 use collections::HashMap;
657
658 Some(std::env::vars().collect::<HashMap<_, _>>())
659 }
660 };
661
662 let exit_status = Arc::new(Mutex::new(None));
663 let mut paths = vec![];
664 let mut urls = vec![];
665 let mut diff_paths = vec![];
666 let mut stdin_tmp_file: Option<fs::File> = None;
667 let mut anonymous_fd_tmp_files = vec![];
668
669 // Check if any diff paths are directories to determine diff_all mode
670 let diff_all_mode = args
671 .diff
672 .chunks(2)
673 .any(|pair| Path::new(&pair[0]).is_dir() || Path::new(&pair[1]).is_dir());
674
675 for path in args.diff.chunks(2) {
676 let left = parse_path_with_position(&path[0])?;
677 let right = parse_path_with_position(&path[1])?;
678 for diff_path in [&left, &right] {
679 anyhow::ensure!(
680 diff_path_exists(diff_path),
681 "--diff path does not exist: {diff_path}"
682 );
683 }
684 diff_paths.push([left, right]);
685 }
686
687 let (expanded_diff_paths, temp_dirs) = expand_directory_diff_pairs(diff_paths)?;
688 diff_paths = expanded_diff_paths;
689 // Prevent automatic cleanup of temp directories containing empty stub files
690 // for directory diffs. The CLI process may exit before Zed has read these
691 // files (e.g., when RPC-ing into an already-running instance). The files
692 // live in the OS temp directory and will be cleaned up on reboot.
693 for temp_dir in temp_dirs {
694 let _ = temp_dir.keep();
695 }
696
697 #[cfg(target_os = "windows")]
698 let wsl = args.wsl.as_ref();
699 #[cfg(not(target_os = "windows"))]
700 let wsl = None;
701
702 for path in args.paths_with_position.iter() {
703 if URL_PREFIX.iter().any(|&prefix| path.starts_with(prefix)) {
704 urls.push(path.to_string());
705 } else if path == "-" && args.paths_with_position.len() == 1 {
706 let file = NamedTempFile::new()?;
707 paths.push(file.path().to_string_lossy().into_owned());
708 let (file, _) = file.keep()?;
709 stdin_tmp_file = Some(file);
710 } else if let Some(file) = anonymous_fd(path) {
711 let tmp_file = NamedTempFile::new()?;
712 paths.push(tmp_file.path().to_string_lossy().into_owned());
713 let (tmp_file, _) = tmp_file.keep()?;
714 anonymous_fd_tmp_files.push((file, tmp_file));
715 } else if let Some(wsl) = wsl {
716 urls.push(format!("file://{}", parse_path_in_wsl(path, wsl)?));
717 } else {
718 paths.push(parse_path_with_position(path)?);
719 }
720 }
721
722 anyhow::ensure!(
723 args.dev_server_token.is_none(),
724 "Dev servers were removed in v0.157.x please upgrade to SSH remoting: https://zed.dev/docs/remote-development"
725 );
726
727 rayon::ThreadPoolBuilder::new()
728 .num_threads(4)
729 .stack_size(10 * 1024 * 1024)
730 .thread_name(|ix| format!("RayonWorker{}", ix))
731 .build_global()
732 .unwrap();
733
734 let sender: JoinHandle<anyhow::Result<()>> = thread::Builder::new()
735 .name("CliReceiver".to_string())
736 .spawn({
737 let exit_status = exit_status.clone();
738 let user_data_dir_for_thread = user_data_dir.clone();
739 move || {
740 let (_, handshake) = server.accept().context("Handshake after Omega spawn")?;
741 let (tx, rx) = (handshake.requests, handshake.responses);
742
743 #[cfg(target_os = "windows")]
744 let wsl = args.wsl;
745 #[cfg(not(target_os = "windows"))]
746 let wsl = None;
747
748 let open_request = CliRequest::Open {
749 paths,
750 urls,
751 diff_paths,
752 diff_all: diff_all_mode,
753 wsl,
754 wait: args.wait,
755 open_behavior,
756 env,
757 user_data_dir: user_data_dir_for_thread,
758 dev_container: args.dev_container,
759 cwd: env::current_dir().ok(),
760 };
761
762 tx.send(open_request)?;
763
764 while let Ok(response) = rx.recv() {
765 match response {
766 CliResponse::Ping => {}
767 CliResponse::Stdout { message } => println!("{message}"),
768 CliResponse::Stderr { message } => eprintln!("{message}"),
769 CliResponse::Exit { status } => {
770 exit_status.lock().replace(status);
771 return Ok(());
772 }
773 CliResponse::PromptOpenBehavior => {
774 let behavior = prompt_open_behavior()
775 .unwrap_or(cli::CliBehaviorSetting::ExistingWindow);
776 tx.send(CliRequest::SetOpenBehavior { behavior })?;
777 }
778 }
779 }
780
781 Ok(())
782 }
783 })
784 .unwrap();
785
786 let stdin_pipe_handle: Option<JoinHandle<anyhow::Result<()>>> =
787 stdin_tmp_file.map(|mut tmp_file| {
788 thread::Builder::new()
789 .name("CliStdin".to_string())
790 .spawn(move || {
791 let mut stdin = std::io::stdin().lock();
792 if !io::IsTerminal::is_terminal(&stdin) {
793 io::copy(&mut stdin, &mut tmp_file)?;
794 }
795 Ok(())
796 })
797 .unwrap()
798 });
799
800 let anonymous_fd_pipe_handles: Vec<_> = anonymous_fd_tmp_files
801 .into_iter()
802 .map(|(mut file, mut tmp_file)| {
803 thread::Builder::new()
804 .name("CliAnonymousFd".to_string())
805 .spawn(move || io::copy(&mut file, &mut tmp_file))
806 .unwrap()
807 })
808 .collect();
809
810 if args.foreground {
811 app.run_foreground(url, user_data_dir.as_deref())?;
812 } else {
813 app.launch(url, user_data_dir.as_deref())?;
814 sender.join().unwrap()?;
815 if let Some(handle) = stdin_pipe_handle {
816 handle.join().unwrap()?;
817 }
818 for handle in anonymous_fd_pipe_handles {
819 handle.join().unwrap()?;
820 }
821 }
822
823 if let Some(exit_status) = exit_status.lock().take() {
824 std::process::exit(exit_status);
825 }
826 Ok(())
827}
828
829fn anonymous_fd(path: &str) -> Option<fs::File> {
830 #[cfg(target_os = "linux")]
831 {
832 use std::os::fd::{self, FromRawFd};
833
834 let fd_str = path.strip_prefix("/proc/self/fd/")?;
835
836 let link = fs::read_link(path).ok()?;
837 if !link.starts_with("memfd:") {
838 return None;
839 }
840
841 let fd: fd::RawFd = fd_str.parse().ok()?;
842 let file = unsafe { fs::File::from_raw_fd(fd) };
843 Some(file)
844 }
845 #[cfg(any(target_os = "macos", target_os = "freebsd"))]
846 {
847 use std::os::{
848 fd::{self, FromRawFd},
849 unix::fs::FileTypeExt,
850 };
851
852 let fd_str = path.strip_prefix("/dev/fd/")?;
853
854 let metadata = fs::metadata(path).ok()?;
855 let file_type = metadata.file_type();
856 if !file_type.is_fifo() && !file_type.is_socket() {
857 return None;
858 }
859 let fd: fd::RawFd = fd_str.parse().ok()?;
860 let file = unsafe { fs::File::from_raw_fd(fd) };
861 Some(file)
862 }
863 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
864 {
865 _ = path;
866 // not implemented for bsd, windows. Could be, but isn't yet
867 None
868 }
869}
870
871/// Shows an interactive prompt asking the user to choose the default open
872/// behavior for `omega <path>`. Returns `None` if the prompt cannot be shown
873/// (e.g. stdin is not a terminal) or the user cancels.
874fn prompt_open_behavior() -> Option<cli::CliBehaviorSetting> {
875 if !std::io::stdin().is_terminal() {
876 return None;
877 }
878
879 let blue = console::Style::new().blue();
880 // Every command form here is built from `paths::BINARY_NAME`, never
881 // written out. The literals `zed --existing`, `zed --classic` and
882 // `zed <path>` shipped in the signed `cli` of `0.2.0-rc16`, in a panel
883 // whose surrounding copy said "Omega window" and "Omega settings", so it
884 // named our product twice and somebody else's binary three times
885 // (omega#93). A command form cannot drift from the binary it describes if
886 // it is spelled by the binary's own name.
887 let items = [
888 format!(
889 "Add to existing Omega window ({})",
890 blue.apply_to(format!("{} --existing", paths::BINARY_NAME))
891 ),
892 format!(
893 "Open a new window ({})",
894 blue.apply_to(format!("{} --classic", paths::BINARY_NAME))
895 ),
896 ];
897
898 let prompt = format!(
899 "Configure default behavior for {}\n{}",
900 blue.apply_to(format!("{} <path>", paths::BINARY_NAME)),
901 console::style("You can change this later in Omega settings"),
902 );
903
904 let selection = dialoguer::Select::new()
905 .with_prompt(&prompt)
906 .items(&items)
907 .default(0)
908 .interact()
909 .ok()?;
910
911 Some(if selection == 0 {
912 cli::CliBehaviorSetting::ExistingWindow
913 } else {
914 cli::CliBehaviorSetting::NewWindow
915 })
916}
917
918#[cfg(any(target_os = "linux", target_os = "freebsd"))]
919mod linux {
920 use std::{
921 env,
922 ffi::OsString,
923 io,
924 os::unix::net::{SocketAddr, UnixDatagram},
925 path::{Path, PathBuf},
926 process::{self, ExitStatus},
927 thread,
928 time::Duration,
929 };
930
931 use anyhow::{Context as _, anyhow};
932 use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
933 use fork::Fork;
934
935 use crate::{Detect, InstalledApp};
936
937 struct App(PathBuf);
938
939 impl Detect {
940 pub fn detect(path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
941 let path = if let Some(path) = path {
942 path.to_path_buf().canonicalize()?
943 } else {
944 let cli = env::current_exe()?;
945 let dir = cli.parent().context("no parent path for cli")?;
946
947 // libexec is the standard, lib/omega is for distributions that avoid libexec,
948 // and ./omega is for the target directory in development builds.
949 let possible_locations = [
950 "../libexec/omega-editor",
951 "../lib/omega/omega-editor",
952 "./omega",
953 ];
954 possible_locations
955 .iter()
956 .find_map(|p| dir.join(p).canonicalize().ok().filter(|path| path != &cli))
957 .with_context(|| {
958 format!("could not find any of: {}", possible_locations.join(", "))
959 })?
960 };
961
962 Ok(App(path))
963 }
964 }
965
966 impl InstalledApp for App {
967 fn app_version_string(&self) -> String {
968 format!(
969 "Omega {}{}{} – {}",
970 if *release_channel::RELEASE_CHANNEL_NAME == "stable" {
971 "".to_string()
972 } else {
973 format!("{} ", *release_channel::RELEASE_CHANNEL_NAME)
974 },
975 option_env!("RELEASE_VERSION").unwrap_or_default(),
976 match option_env!("ZED_COMMIT_SHA") {
977 Some(commit_sha) => format!(" {commit_sha} "),
978 None => "".to_string(),
979 },
980 self.0.display(),
981 )
982 }
983
984 fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> {
985 let data_dir = user_data_dir
986 .map(PathBuf::from)
987 .unwrap_or_else(|| paths::data_dir().clone());
988
989 let sock_path = data_dir.join(format!(
990 "zed-{}.sock",
991 *release_channel::RELEASE_CHANNEL_NAME
992 ));
993 let sock = UnixDatagram::unbound()?;
994 if sock.connect(&sock_path).is_err() {
995 self.boot_background(ipc_url, user_data_dir)?;
996 } else {
997 sock.send(ipc_url.as_bytes())?;
998 }
999 Ok(())
1000 }
1001
1002 fn run_foreground(
1003 &self,
1004 ipc_url: String,
1005 user_data_dir: Option<&str>,
1006 ) -> io::Result<ExitStatus> {
1007 let mut cmd = std::process::Command::new(self.0.clone());
1008 cmd.arg(ipc_url);
1009 if let Some(dir) = user_data_dir {
1010 cmd.arg("--user-data-dir").arg(dir);
1011 }
1012 cmd.status()
1013 }
1014
1015 fn path(&self) -> PathBuf {
1016 self.0.clone()
1017 }
1018
1019 fn installation_root(&self) -> PathBuf {
1020 // A Linux install is loose files under a prefix — `libexec`,
1021 // `bin`, `share` — with no single directory that is only Omega, so
1022 // the editor binary is the honest answer here. macOS is where the
1023 // installation really is one directory.
1024 self.0.clone()
1025 }
1026 }
1027
1028 impl App {
1029 fn boot_background(
1030 &self,
1031 ipc_url: String,
1032 user_data_dir: Option<&str>,
1033 ) -> anyhow::Result<()> {
1034 let path = &self.0;
1035
1036 match fork::fork() {
1037 Ok(Fork::Parent(_)) => Ok(()),
1038 Ok(Fork::Child) => {
1039 unsafe { std::env::set_var(FORCE_CLI_MODE_ENV_VAR_NAME, "") };
1040 if fork::setsid().is_err() {
1041 eprintln!("failed to setsid: {}", std::io::Error::last_os_error());
1042 process::exit(1);
1043 }
1044 if fork::close_fd().is_err() {
1045 eprintln!("failed to close_fd: {}", std::io::Error::last_os_error());
1046 }
1047 let mut args: Vec<OsString> =
1048 vec![path.as_os_str().to_owned(), OsString::from(ipc_url)];
1049 if let Some(dir) = user_data_dir {
1050 args.push(OsString::from("--user-data-dir"));
1051 args.push(OsString::from(dir));
1052 }
1053 let error = exec::execvp(path.clone(), &args);
1054 // if exec succeeded, we never get here.
1055 eprintln!("failed to exec {:?}: {}", path, error);
1056 process::exit(1)
1057 }
1058 Err(_) => Err(anyhow!(io::Error::last_os_error())),
1059 }
1060 }
1061
1062 fn wait_for_socket(
1063 &self,
1064 sock_addr: &SocketAddr,
1065 sock: &mut UnixDatagram,
1066 ) -> Result<(), std::io::Error> {
1067 for _ in 0..100 {
1068 thread::sleep(Duration::from_millis(10));
1069 if sock.connect_addr(sock_addr).is_ok() {
1070 return Ok(());
1071 }
1072 }
1073 sock.connect_addr(sock_addr)
1074 }
1075 }
1076}
1077
1078#[cfg(target_os = "linux")]
1079mod flatpak {
1080 use std::ffi::OsString;
1081 use std::path::PathBuf;
1082 use std::process::Command;
1083 use std::{env, process};
1084
1085 const EXTRA_LIB_ENV_NAME: &str = "ZED_FLATPAK_LIB_PATH";
1086 const NO_ESCAPE_ENV_NAME: &str = "ZED_FLATPAK_NO_ESCAPE";
1087
1088 /// Adds bundled libraries to LD_LIBRARY_PATH if running under flatpak
1089 pub fn ld_extra_libs() {
1090 let mut paths = if let Ok(paths) = env::var("LD_LIBRARY_PATH") {
1091 env::split_paths(&paths).collect()
1092 } else {
1093 Vec::new()
1094 };
1095
1096 if let Ok(extra_path) = env::var(EXTRA_LIB_ENV_NAME) {
1097 paths.push(extra_path.into());
1098 }
1099
1100 unsafe { env::set_var("LD_LIBRARY_PATH", env::join_paths(paths).unwrap()) };
1101 }
1102
1103 /// Restarts outside of the sandbox if currently running within it
1104 pub fn try_restart_to_host() {
1105 if let Some(flatpak_dir) = get_flatpak_dir() {
1106 let mut args = vec!["/usr/bin/flatpak-spawn".into(), "--host".into()];
1107 args.append(&mut get_xdg_env_args());
1108 args.push("--env=ZED_UPDATE_EXPLANATION=Please use flatpak to update omega".into());
1109 args.push(
1110 format!(
1111 "--env={EXTRA_LIB_ENV_NAME}={}",
1112 flatpak_dir.join("lib").to_str().unwrap()
1113 )
1114 .into(),
1115 );
1116 args.push(flatpak_dir.join("bin").join("zed").into());
1117
1118 let mut is_app_location_set = false;
1119 for arg in &env::args_os().collect::<Vec<_>>()[1..] {
1120 args.push(arg.clone());
1121 is_app_location_set |= arg == "--zed";
1122 }
1123
1124 if !is_app_location_set {
1125 args.push("--zed".into());
1126 args.push(flatpak_dir.join("libexec").join("omega-editor").into());
1127 }
1128
1129 let error = exec::execvp("/usr/bin/flatpak-spawn", args);
1130 eprintln!("failed restart cli on host: {:?}", error);
1131 process::exit(1);
1132 }
1133 }
1134
1135 pub fn set_bin_if_no_escape(mut args: super::Args) -> super::Args {
1136 if env::var(NO_ESCAPE_ENV_NAME).is_ok()
1137 && env::var("FLATPAK_ID").is_ok_and(|id| id.starts_with("com.openagents.omega"))
1138 && args.omega.is_none()
1139 {
1140 args.omega = Some("/app/libexec/omega-editor".into());
1141 unsafe {
1142 env::set_var(
1143 "ZED_UPDATE_EXPLANATION",
1144 "Please use flatpak to update Omega",
1145 )
1146 };
1147 }
1148 args
1149 }
1150
1151 fn get_flatpak_dir() -> Option<PathBuf> {
1152 if env::var(NO_ESCAPE_ENV_NAME).is_ok() {
1153 return None;
1154 }
1155
1156 if let Ok(flatpak_id) = env::var("FLATPAK_ID") {
1157 if !flatpak_id.starts_with("com.openagents.omega") {
1158 return None;
1159 }
1160
1161 let install_dir = Command::new("/usr/bin/flatpak-spawn")
1162 .arg("--host")
1163 .arg("flatpak")
1164 .arg("info")
1165 .arg("--show-location")
1166 .arg(flatpak_id)
1167 .output()
1168 .unwrap();
1169 let install_dir = PathBuf::from(String::from_utf8(install_dir.stdout).unwrap().trim());
1170 Some(install_dir.join("files"))
1171 } else {
1172 None
1173 }
1174 }
1175
1176 fn get_xdg_env_args() -> Vec<OsString> {
1177 let xdg_keys = [
1178 "XDG_DATA_HOME",
1179 "XDG_CONFIG_HOME",
1180 "XDG_CACHE_HOME",
1181 "XDG_STATE_HOME",
1182 ];
1183 env::vars()
1184 .filter(|(key, _)| xdg_keys.contains(&key.as_str()))
1185 .map(|(key, val)| format!("--env=FLATPAK_{}={}", key, val).into())
1186 .collect()
1187 }
1188}
1189
1190#[cfg(target_os = "windows")]
1191mod windows {
1192 use anyhow::Context;
1193 use release_channel::app_identifier;
1194 use windows::{
1195 Win32::{
1196 Foundation::{CloseHandle, ERROR_ALREADY_EXISTS, GENERIC_WRITE, GetLastError},
1197 Storage::FileSystem::{
1198 CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_MODE, OPEN_EXISTING, WriteFile,
1199 },
1200 System::Threading::CreateMutexW,
1201 },
1202 core::HSTRING,
1203 };
1204
1205 use crate::{Detect, InstalledApp};
1206 use std::io;
1207 use std::path::{Path, PathBuf};
1208 use std::process::{ExitStatus, Stdio};
1209
1210 fn check_single_instance() -> bool {
1211 let mutex = unsafe {
1212 CreateMutexW(
1213 None,
1214 false,
1215 &HSTRING::from(format!("{}-Instance-Mutex", app_identifier())),
1216 )
1217 .expect("Unable to create instance sync event")
1218 };
1219 let last_err = unsafe { GetLastError() };
1220 let _ = unsafe { CloseHandle(mutex) };
1221 last_err != ERROR_ALREADY_EXISTS
1222 }
1223
1224 struct App(PathBuf);
1225
1226 impl InstalledApp for App {
1227 fn app_version_string(&self) -> String {
1228 format!(
1229 "Omega {}{}{} – {}",
1230 if *release_channel::RELEASE_CHANNEL_NAME == "stable" {
1231 "".to_string()
1232 } else {
1233 format!("{} ", *release_channel::RELEASE_CHANNEL_NAME)
1234 },
1235 option_env!("RELEASE_VERSION").unwrap_or_default(),
1236 match option_env!("ZED_COMMIT_SHA") {
1237 Some(commit_sha) => format!(" {commit_sha} "),
1238 None => "".to_string(),
1239 },
1240 self.0.display(),
1241 )
1242 }
1243
1244 fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> {
1245 if check_single_instance() {
1246 let mut cmd = std::process::Command::new(self.0.clone());
1247 cmd.arg(ipc_url);
1248 if let Some(dir) = user_data_dir {
1249 cmd.arg("--user-data-dir").arg(dir);
1250 }
1251 cmd.stdin(Stdio::null())
1252 .stdout(Stdio::null())
1253 .stderr(Stdio::null());
1254 cmd.spawn()?;
1255 } else {
1256 unsafe {
1257 let pipe = CreateFileW(
1258 &HSTRING::from(format!("\\\\.\\pipe\\{}-Named-Pipe", app_identifier())),
1259 GENERIC_WRITE.0,
1260 FILE_SHARE_MODE::default(),
1261 None,
1262 OPEN_EXISTING,
1263 FILE_FLAGS_AND_ATTRIBUTES::default(),
1264 None,
1265 )?;
1266 let message = ipc_url.as_bytes();
1267 let mut bytes_written = 0;
1268 WriteFile(pipe, Some(message), Some(&mut bytes_written), None)?;
1269 CloseHandle(pipe)?;
1270 }
1271 }
1272 Ok(())
1273 }
1274
1275 fn run_foreground(
1276 &self,
1277 ipc_url: String,
1278 user_data_dir: Option<&str>,
1279 ) -> io::Result<ExitStatus> {
1280 let mut cmd = std::process::Command::new(self.0.clone());
1281 cmd.arg(ipc_url).arg("--foreground");
1282 if let Some(dir) = user_data_dir {
1283 cmd.arg("--user-data-dir").arg(dir);
1284 }
1285 cmd.spawn()?.wait()
1286 }
1287
1288 fn path(&self) -> PathBuf {
1289 self.0.clone()
1290 }
1291
1292 fn installation_root(&self) -> PathBuf {
1293 // Windows has no `--uninstall`; the installer owns removal there.
1294 self.0.clone()
1295 }
1296 }
1297
1298 impl Detect {
1299 pub fn detect(path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
1300 let path = if let Some(path) = path {
1301 path.to_path_buf().canonicalize()?
1302 } else {
1303 let cli = std::env::current_exe()?;
1304 let dir = cli.parent().context("no parent path for cli")?;
1305
1306 // ../Omega.exe is the standard, lib/omega is for MSYS2, ./omega.exe is for the target
1307 // directory in development builds.
1308 let possible_locations = [
1309 "../Omega.exe",
1310 "../lib/omega/omega-editor.exe",
1311 "./omega.exe",
1312 ];
1313 possible_locations
1314 .iter()
1315 .find_map(|p| dir.join(p).canonicalize().ok().filter(|path| path != &cli))
1316 .context(format!(
1317 "could not find any of: {}",
1318 possible_locations.join(", ")
1319 ))?
1320 };
1321
1322 Ok(App(path))
1323 }
1324 }
1325}
1326
1327#[cfg(target_os = "macos")]
1328mod mac_os {
1329 use anyhow::{Context as _, Result};
1330 use core_foundation::{
1331 array::{CFArray, CFIndex},
1332 base::TCFType as _,
1333 string::kCFStringEncodingUTF8,
1334 url::{CFURL, CFURLCreateWithBytes},
1335 };
1336 use core_services::{
1337 LSLaunchURLSpec, LSOpenFromURLSpec, kLSLaunchDefaults, kLSLaunchDontSwitch,
1338 };
1339 use serde::Deserialize;
1340 use std::{
1341 ffi::OsStr,
1342 fs, io,
1343 path::{Path, PathBuf},
1344 process::{Command, ExitStatus},
1345 ptr,
1346 };
1347
1348 use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
1349
1350 use crate::{Detect, InstalledApp};
1351
1352 #[derive(Debug, Deserialize)]
1353 struct InfoPlist {
1354 #[serde(rename = "CFBundleShortVersionString")]
1355 bundle_short_version_string: String,
1356 }
1357
1358 enum Bundle {
1359 App {
1360 app_bundle: PathBuf,
1361 plist: InfoPlist,
1362 },
1363 LocalPath {
1364 executable: PathBuf,
1365 },
1366 }
1367
1368 fn locate_bundle() -> Result<PathBuf> {
1369 let cli_path = std::env::current_exe()?.canonicalize()?;
1370 let mut app_path = cli_path.clone();
1371 while app_path.extension() != Some(OsStr::new("app")) {
1372 anyhow::ensure!(
1373 app_path.pop(),
1374 "cannot find app bundle containing {cli_path:?}"
1375 );
1376 }
1377 Ok(app_path)
1378 }
1379
1380 impl Detect {
1381 pub fn detect(path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
1382 let bundle_path = if let Some(bundle_path) = path {
1383 bundle_path
1384 .canonicalize()
1385 .with_context(|| format!("Args bundle path {bundle_path:?} canonicalization"))?
1386 } else {
1387 locate_bundle().context("bundle autodiscovery")?
1388 };
1389
1390 match bundle_path.extension().and_then(|ext| ext.to_str()) {
1391 Some("app") => {
1392 let plist_path = bundle_path.join("Contents/Info.plist");
1393 let plist =
1394 plist::from_file::<_, InfoPlist>(&plist_path).with_context(|| {
1395 format!("Reading *.app bundle plist file at {plist_path:?}")
1396 })?;
1397 Ok(Bundle::App {
1398 app_bundle: bundle_path,
1399 plist,
1400 })
1401 }
1402 _ => Ok(Bundle::LocalPath {
1403 executable: bundle_path,
1404 }),
1405 }
1406 }
1407 }
1408
1409 impl InstalledApp for Bundle {
1410 fn app_version_string(&self) -> String {
1411 // The installation, which is what a person can point at in Finder.
1412 // This used to read `self.path()`, resolved to an inherent method
1413 // that returned the bundle while the trait method of the same name
1414 // returned the executable. That ambiguity is what omega#92 was.
1415 format!(
1416 "Omega {} – {}",
1417 self.version(),
1418 self.installation_root().display(),
1419 )
1420 }
1421
1422 fn launch(&self, url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> {
1423 match self {
1424 Self::App { app_bundle, .. } => {
1425 let app_path = app_bundle;
1426
1427 let status = unsafe {
1428 let app_url = CFURL::from_path(app_path, true)
1429 .with_context(|| format!("invalid app path {app_path:?}"))?;
1430 let url_to_open = CFURL::wrap_under_create_rule(CFURLCreateWithBytes(
1431 ptr::null(),
1432 url.as_ptr(),
1433 url.len() as CFIndex,
1434 kCFStringEncodingUTF8,
1435 ptr::null(),
1436 ));
1437 // equivalent to: open zed-cli:... -a /Applications/Zed\ Preview.app
1438 let urls_to_open =
1439 CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]);
1440 LSOpenFromURLSpec(
1441 &LSLaunchURLSpec {
1442 appURL: app_url.as_concrete_TypeRef(),
1443 itemURLs: urls_to_open.as_concrete_TypeRef(),
1444 passThruParams: ptr::null(),
1445 launchFlags: kLSLaunchDefaults | kLSLaunchDontSwitch,
1446 asyncRefCon: ptr::null_mut(),
1447 },
1448 ptr::null_mut(),
1449 )
1450 };
1451
1452 anyhow::ensure!(
1453 status == 0,
1454 "cannot start app bundle {}",
1455 self.app_version_string()
1456 );
1457 }
1458
1459 Self::LocalPath { executable, .. } => {
1460 let executable_parent = executable
1461 .parent()
1462 .with_context(|| format!("Executable {executable:?} path has no parent"))?;
1463 let subprocess_stdout_file = fs::File::create(
1464 executable_parent.join("zed_dev.log"),
1465 )
1466 .with_context(|| format!("Log file creation in {executable_parent:?}"))?;
1467 let subprocess_stdin_file =
1468 subprocess_stdout_file.try_clone().with_context(|| {
1469 format!("Cloning descriptor for file {subprocess_stdout_file:?}")
1470 })?;
1471 let mut command = std::process::Command::new(executable);
1472 command.env(FORCE_CLI_MODE_ENV_VAR_NAME, "");
1473 if let Some(dir) = user_data_dir {
1474 command.arg("--user-data-dir").arg(dir);
1475 }
1476 command
1477 .stderr(subprocess_stdout_file)
1478 .stdout(subprocess_stdin_file)
1479 .arg(url);
1480
1481 command
1482 .spawn()
1483 .with_context(|| format!("Spawning {command:?}"))?;
1484 }
1485 }
1486
1487 Ok(())
1488 }
1489
1490 fn run_foreground(
1491 &self,
1492 ipc_url: String,
1493 user_data_dir: Option<&str>,
1494 ) -> io::Result<ExitStatus> {
1495 let path = match self {
1496 Bundle::App { app_bundle, .. } => app_bundle.join("Contents/MacOS/omega"),
1497 Bundle::LocalPath { executable, .. } => executable.clone(),
1498 };
1499
1500 let mut cmd = std::process::Command::new(path);
1501 cmd.arg(ipc_url);
1502 if let Some(dir) = user_data_dir {
1503 cmd.arg("--user-data-dir").arg(dir);
1504 }
1505 cmd.status()
1506 }
1507
1508 fn path(&self) -> PathBuf {
1509 match self {
1510 Bundle::App { app_bundle, .. } => app_bundle.join("Contents/MacOS/omega"),
1511 Bundle::LocalPath { executable, .. } => executable.clone(),
1512 }
1513 }
1514
1515 fn installation_root(&self) -> PathBuf {
1516 match self {
1517 // The bundle, not the executable inside it. Everything Omega
1518 // installs on macOS lives under this directory.
1519 Bundle::App { app_bundle, .. } => app_bundle.clone(),
1520 Bundle::LocalPath { executable, .. } => executable.clone(),
1521 }
1522 }
1523 }
1524
1525 impl Bundle {
1526 fn version(&self) -> String {
1527 match self {
1528 Self::App { plist, .. } => plist.bundle_short_version_string.clone(),
1529 Self::LocalPath { .. } => "<development>".to_string(),
1530 }
1531 }
1532 }
1533
1534 pub(super) fn spawn_channel_cli(
1535 channel: release_channel::ReleaseChannel,
1536 leftover_args: Vec<String>,
1537 ) -> Result<()> {
1538 use anyhow::bail;
1539
1540 let app_path_prompt = format!(
1541 "POSIX path of (path to application \"{}\")",
1542 channel.display_name()
1543 );
1544 let app_path_output = Command::new("osascript")
1545 .arg("-e")
1546 .arg(&app_path_prompt)
1547 .output()?;
1548 if !app_path_output.status.success() {
1549 bail!(
1550 "Could not determine app path for {}",
1551 channel.display_name()
1552 );
1553 }
1554 let app_path = String::from_utf8(app_path_output.stdout)?.trim().to_owned();
1555 let cli_path = format!("{app_path}/Contents/MacOS/cli");
1556 Command::new(cli_path).args(leftover_args).spawn()?;
1557 Ok(())
1558 }
1559}
1560