main.rs

  1#![cfg_attr(
  2    any(target_os = "linux", target_os = "freebsd", target_os = "windows"),
  3    allow(dead_code)
  4)]
  5
  6use anyhow::{Context, Result};
  7use clap::Parser;
  8use cli::{ipc::IpcOneShotServer, CliRequest, CliResponse, IpcHandshake};
  9use collections::HashMap;
 10use parking_lot::Mutex;
 11use std::{
 12    env, fs, io,
 13    path::{Path, PathBuf},
 14    process::ExitStatus,
 15    sync::Arc,
 16    thread::{self, JoinHandle},
 17};
 18use tempfile::NamedTempFile;
 19use util::paths::PathWithPosition;
 20
 21struct Detect;
 22
 23trait InstalledApp {
 24    fn zed_version_string(&self) -> String;
 25    fn launch(&self, ipc_url: String) -> anyhow::Result<()>;
 26    fn run_foreground(&self, ipc_url: String) -> io::Result<ExitStatus>;
 27}
 28
 29#[derive(Parser, Debug)]
 30#[command(
 31    name = "zed",
 32    disable_version_flag = true,
 33    after_help = "To read from stdin, append '-' (e.g. 'ps axf | zed -')"
 34)]
 35struct Args {
 36    /// Wait for all of the given paths to be opened/closed before exiting.
 37    #[arg(short, long)]
 38    wait: bool,
 39    /// Add files to the currently open workspace
 40    #[arg(short, long, overrides_with = "new")]
 41    add: bool,
 42    /// Create a new workspace
 43    #[arg(short, long, overrides_with = "add")]
 44    new: bool,
 45    /// A sequence of space-separated paths that you want to open.
 46    ///
 47    /// Use `path:line:row` syntax to open a file at a specific location.
 48    /// Non-existing paths and directories will ignore `:line:row` suffix.
 49    paths_with_position: Vec<String>,
 50    /// Print Zed's version and the app path.
 51    #[arg(short, long)]
 52    version: bool,
 53    /// Run zed in the foreground (useful for debugging)
 54    #[arg(long)]
 55    foreground: bool,
 56    /// Custom path to Zed.app or the zed binary
 57    #[arg(long)]
 58    zed: Option<PathBuf>,
 59    /// Run zed in dev-server mode
 60    #[arg(long)]
 61    dev_server_token: Option<String>,
 62    /// Uninstall Zed from user system
 63    #[cfg(all(
 64        any(target_os = "linux", target_os = "macos"),
 65        not(feature = "no-bundled-uninstall")
 66    ))]
 67    #[arg(long)]
 68    uninstall: bool,
 69}
 70
 71fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
 72    let canonicalized = match Path::new(argument_str).canonicalize() {
 73        Ok(existing_path) => PathWithPosition::from_path(existing_path),
 74        Err(_) => {
 75            let path = PathWithPosition::parse_str(argument_str);
 76            let curdir = env::current_dir().context("reteiving current directory")?;
 77            path.map_path(|path| match fs::canonicalize(&path) {
 78                Ok(path) => Ok(path),
 79                Err(e) => {
 80                    if let Some(mut parent) = path.parent() {
 81                        if parent == Path::new("") {
 82                            parent = &curdir
 83                        }
 84                        match fs::canonicalize(parent) {
 85                            Ok(parent) => Ok(parent.join(path.file_name().unwrap())),
 86                            Err(_) => Err(e),
 87                        }
 88                    } else {
 89                        Err(e)
 90                    }
 91                }
 92            })
 93        }
 94        .with_context(|| format!("parsing as path with position {argument_str}"))?,
 95    };
 96    Ok(canonicalized.to_string(|path| path.to_string_lossy().to_string()))
 97}
 98
 99fn main() -> Result<()> {
100    // Exit flatpak sandbox if needed
101    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
102    {
103        flatpak::try_restart_to_host();
104        flatpak::ld_extra_libs();
105    }
106
107    // Intercept version designators
108    #[cfg(target_os = "macos")]
109    if let Some(channel) = std::env::args().nth(1).filter(|arg| arg.starts_with("--")) {
110        // When the first argument is a name of a release channel, we're gonna spawn off a cli of that version, with trailing args passed along.
111        use std::str::FromStr as _;
112
113        if let Ok(channel) = release_channel::ReleaseChannel::from_str(&channel[2..]) {
114            return mac_os::spawn_channel_cli(channel, std::env::args().skip(2).collect());
115        }
116    }
117    let args = Args::parse();
118
119    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
120    let args = flatpak::set_bin_if_no_escape(args);
121
122    let app = Detect::detect(args.zed.as_deref()).context("Bundle detection")?;
123
124    if args.version {
125        println!("{}", app.zed_version_string());
126        return Ok(());
127    }
128
129    #[cfg(all(
130        any(target_os = "linux", target_os = "macos"),
131        not(feature = "no-bundled-uninstall")
132    ))]
133    if args.uninstall {
134        static UNINSTALL_SCRIPT: &[u8] = include_bytes!("../../../script/uninstall.sh");
135
136        let tmp_dir = tempfile::tempdir()?;
137        let script_path = tmp_dir.path().join("uninstall.sh");
138        fs::write(&script_path, UNINSTALL_SCRIPT)?;
139
140        use std::os::unix::fs::PermissionsExt as _;
141        fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
142
143        let status = std::process::Command::new("sh")
144            .arg(&script_path)
145            .env("ZED_CHANNEL", &*release_channel::RELEASE_CHANNEL_NAME)
146            .status()
147            .context("Failed to execute uninstall script")?;
148
149        std::process::exit(status.code().unwrap_or(1));
150    }
151
152    let (server, server_name) =
153        IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Zed spawn")?;
154    let url = format!("zed-cli://{server_name}");
155
156    let open_new_workspace = if args.new {
157        Some(true)
158    } else if args.add {
159        Some(false)
160    } else {
161        None
162    };
163
164    let env = Some(std::env::vars().collect::<HashMap<_, _>>());
165    let exit_status = Arc::new(Mutex::new(None));
166    let mut paths = vec![];
167    let mut urls = vec![];
168    let mut stdin_tmp_file: Option<fs::File> = None;
169    for path in args.paths_with_position.iter() {
170        if path.starts_with("zed://")
171            || path.starts_with("http://")
172            || path.starts_with("https://")
173            || path.starts_with("file://")
174            || path.starts_with("ssh://")
175        {
176            urls.push(path.to_string());
177        } else if path == "-" && args.paths_with_position.len() == 1 {
178            let file = NamedTempFile::new()?;
179            paths.push(file.path().to_string_lossy().to_string());
180            let (file, _) = file.keep()?;
181            stdin_tmp_file = Some(file);
182        } else {
183            paths.push(parse_path_with_position(path)?)
184        }
185    }
186
187    if let Some(_) = args.dev_server_token {
188        return Err(anyhow::anyhow!(
189            "Dev servers were removed in v0.157.x please upgrade to SSH remoting: https://zed.dev/docs/remote-development"
190        ))?;
191    }
192
193    let sender: JoinHandle<anyhow::Result<()>> = thread::spawn({
194        let exit_status = exit_status.clone();
195        move || {
196            let (_, handshake) = server.accept().context("Handshake after Zed spawn")?;
197            let (tx, rx) = (handshake.requests, handshake.responses);
198
199            tx.send(CliRequest::Open {
200                paths,
201                urls,
202                wait: args.wait,
203                open_new_workspace,
204                env,
205            })?;
206
207            while let Ok(response) = rx.recv() {
208                match response {
209                    CliResponse::Ping => {}
210                    CliResponse::Stdout { message } => println!("{message}"),
211                    CliResponse::Stderr { message } => eprintln!("{message}"),
212                    CliResponse::Exit { status } => {
213                        exit_status.lock().replace(status);
214                        return Ok(());
215                    }
216                }
217            }
218
219            Ok(())
220        }
221    });
222
223    let pipe_handle: JoinHandle<anyhow::Result<()>> = thread::spawn(move || {
224        if let Some(mut tmp_file) = stdin_tmp_file {
225            let mut stdin = std::io::stdin().lock();
226            if io::IsTerminal::is_terminal(&stdin) {
227                return Ok(());
228            }
229            let mut buffer = [0; 8 * 1024];
230            loop {
231                let bytes_read = io::Read::read(&mut stdin, &mut buffer)?;
232                if bytes_read == 0 {
233                    break;
234                }
235                io::Write::write(&mut tmp_file, &buffer[..bytes_read])?;
236            }
237            io::Write::flush(&mut tmp_file)?;
238        }
239        Ok(())
240    });
241
242    if args.foreground {
243        app.run_foreground(url)?;
244    } else {
245        app.launch(url)?;
246        sender.join().unwrap()?;
247        pipe_handle.join().unwrap()?;
248    }
249
250    if let Some(exit_status) = exit_status.lock().take() {
251        std::process::exit(exit_status);
252    }
253    Ok(())
254}
255
256#[cfg(any(target_os = "linux", target_os = "freebsd"))]
257mod linux {
258    use std::{
259        env,
260        ffi::OsString,
261        io,
262        os::unix::net::{SocketAddr, UnixDatagram},
263        path::{Path, PathBuf},
264        process::{self, ExitStatus},
265        thread,
266        time::Duration,
267    };
268
269    use anyhow::anyhow;
270    use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
271    use fork::Fork;
272    use once_cell::sync::Lazy;
273
274    use crate::{Detect, InstalledApp};
275
276    static RELEASE_CHANNEL: Lazy<String> =
277        Lazy::new(|| include_str!("../../zed/RELEASE_CHANNEL").trim().to_string());
278
279    struct App(PathBuf);
280
281    impl Detect {
282        pub fn detect(path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
283            let path = if let Some(path) = path {
284                path.to_path_buf().canonicalize()?
285            } else {
286                let cli = env::current_exe()?;
287                let dir = cli
288                    .parent()
289                    .ok_or_else(|| anyhow!("no parent path for cli"))?;
290
291                // libexec is the standard, lib/zed is for Arch (and other non-libexec distros),
292                // ./zed is for the target directory in development builds.
293                let possible_locations =
294                    ["../libexec/zed-editor", "../lib/zed/zed-editor", "./zed"];
295                possible_locations
296                    .iter()
297                    .find_map(|p| dir.join(p).canonicalize().ok().filter(|path| path != &cli))
298                    .ok_or_else(|| {
299                        anyhow!("could not find any of: {}", possible_locations.join(", "))
300                    })?
301            };
302
303            Ok(App(path))
304        }
305    }
306
307    impl InstalledApp for App {
308        fn zed_version_string(&self) -> String {
309            format!(
310                "Zed {}{}{}",
311                if *RELEASE_CHANNEL == "stable" {
312                    "".to_string()
313                } else {
314                    format!(" {} ", *RELEASE_CHANNEL)
315                },
316                option_env!("RELEASE_VERSION").unwrap_or_default(),
317                self.0.display(),
318            )
319        }
320
321        fn launch(&self, ipc_url: String) -> anyhow::Result<()> {
322            let sock_path = paths::support_dir().join(format!("zed-{}.sock", *RELEASE_CHANNEL));
323            let sock = UnixDatagram::unbound()?;
324            if sock.connect(&sock_path).is_err() {
325                self.boot_background(ipc_url)?;
326            } else {
327                sock.send(ipc_url.as_bytes())?;
328            }
329            Ok(())
330        }
331
332        fn run_foreground(&self, ipc_url: String) -> io::Result<ExitStatus> {
333            std::process::Command::new(self.0.clone())
334                .arg(ipc_url)
335                .status()
336        }
337    }
338
339    impl App {
340        fn boot_background(&self, ipc_url: String) -> anyhow::Result<()> {
341            let path = &self.0;
342
343            match fork::fork() {
344                Ok(Fork::Parent(_)) => Ok(()),
345                Ok(Fork::Child) => {
346                    std::env::set_var(FORCE_CLI_MODE_ENV_VAR_NAME, "");
347                    if let Err(_) = fork::setsid() {
348                        eprintln!("failed to setsid: {}", std::io::Error::last_os_error());
349                        process::exit(1);
350                    }
351                    if let Err(_) = fork::close_fd() {
352                        eprintln!("failed to close_fd: {}", std::io::Error::last_os_error());
353                    }
354                    let error =
355                        exec::execvp(path.clone(), &[path.as_os_str(), &OsString::from(ipc_url)]);
356                    // if exec succeeded, we never get here.
357                    eprintln!("failed to exec {:?}: {}", path, error);
358                    process::exit(1)
359                }
360                Err(_) => Err(anyhow!(io::Error::last_os_error())),
361            }
362        }
363
364        fn wait_for_socket(
365            &self,
366            sock_addr: &SocketAddr,
367            sock: &mut UnixDatagram,
368        ) -> Result<(), std::io::Error> {
369            for _ in 0..100 {
370                thread::sleep(Duration::from_millis(10));
371                if sock.connect_addr(&sock_addr).is_ok() {
372                    return Ok(());
373                }
374            }
375            sock.connect_addr(&sock_addr)
376        }
377    }
378}
379
380#[cfg(any(target_os = "linux", target_os = "freebsd"))]
381mod flatpak {
382    use std::ffi::OsString;
383    use std::path::PathBuf;
384    use std::process::Command;
385    use std::{env, process};
386
387    const EXTRA_LIB_ENV_NAME: &'static str = "ZED_FLATPAK_LIB_PATH";
388    const NO_ESCAPE_ENV_NAME: &'static str = "ZED_FLATPAK_NO_ESCAPE";
389
390    /// Adds bundled libraries to LD_LIBRARY_PATH if running under flatpak
391    pub fn ld_extra_libs() {
392        let mut paths = if let Ok(paths) = env::var("LD_LIBRARY_PATH") {
393            env::split_paths(&paths).collect()
394        } else {
395            Vec::new()
396        };
397
398        if let Ok(extra_path) = env::var(EXTRA_LIB_ENV_NAME) {
399            paths.push(extra_path.into());
400        }
401
402        env::set_var("LD_LIBRARY_PATH", env::join_paths(paths).unwrap());
403    }
404
405    /// Restarts outside of the sandbox if currently running within it
406    pub fn try_restart_to_host() {
407        if let Some(flatpak_dir) = get_flatpak_dir() {
408            let mut args = vec!["/usr/bin/flatpak-spawn".into(), "--host".into()];
409            args.append(&mut get_xdg_env_args());
410            args.push("--env=ZED_UPDATE_EXPLANATION=Please use flatpak to update zed".into());
411            args.push(
412                format!(
413                    "--env={EXTRA_LIB_ENV_NAME}={}",
414                    flatpak_dir.join("lib").to_str().unwrap()
415                )
416                .into(),
417            );
418            args.push(flatpak_dir.join("bin").join("zed").into());
419
420            let mut is_app_location_set = false;
421            for arg in &env::args_os().collect::<Vec<_>>()[1..] {
422                args.push(arg.clone());
423                is_app_location_set |= arg == "--zed";
424            }
425
426            if !is_app_location_set {
427                args.push("--zed".into());
428                args.push(flatpak_dir.join("libexec").join("zed-editor").into());
429            }
430
431            let error = exec::execvp("/usr/bin/flatpak-spawn", args);
432            eprintln!("failed restart cli on host: {:?}", error);
433            process::exit(1);
434        }
435    }
436
437    pub fn set_bin_if_no_escape(mut args: super::Args) -> super::Args {
438        if env::var(NO_ESCAPE_ENV_NAME).is_ok()
439            && env::var("FLATPAK_ID").map_or(false, |id| id.starts_with("dev.zed.Zed"))
440        {
441            if args.zed.is_none() {
442                args.zed = Some("/app/libexec/zed-editor".into());
443                env::set_var("ZED_UPDATE_EXPLANATION", "Please use flatpak to update zed");
444            }
445        }
446        args
447    }
448
449    fn get_flatpak_dir() -> Option<PathBuf> {
450        if env::var(NO_ESCAPE_ENV_NAME).is_ok() {
451            return None;
452        }
453
454        if let Ok(flatpak_id) = env::var("FLATPAK_ID") {
455            if !flatpak_id.starts_with("dev.zed.Zed") {
456                return None;
457            }
458
459            let install_dir = Command::new("/usr/bin/flatpak-spawn")
460                .arg("--host")
461                .arg("flatpak")
462                .arg("info")
463                .arg("--show-location")
464                .arg(flatpak_id)
465                .output()
466                .unwrap();
467            let install_dir = PathBuf::from(String::from_utf8(install_dir.stdout).unwrap().trim());
468            Some(install_dir.join("files"))
469        } else {
470            None
471        }
472    }
473
474    fn get_xdg_env_args() -> Vec<OsString> {
475        let xdg_keys = [
476            "XDG_DATA_HOME",
477            "XDG_CONFIG_HOME",
478            "XDG_CACHE_HOME",
479            "XDG_STATE_HOME",
480        ];
481        env::vars()
482            .filter(|(key, _)| xdg_keys.contains(&key.as_str()))
483            .map(|(key, val)| format!("--env=FLATPAK_{}={}", key, val).into())
484            .collect()
485    }
486}
487
488// todo("windows")
489#[cfg(target_os = "windows")]
490mod windows {
491    use crate::{Detect, InstalledApp};
492    use std::io;
493    use std::path::Path;
494    use std::process::ExitStatus;
495
496    struct App;
497    impl InstalledApp for App {
498        fn zed_version_string(&self) -> String {
499            unimplemented!()
500        }
501        fn launch(&self, _ipc_url: String) -> anyhow::Result<()> {
502            unimplemented!()
503        }
504        fn run_foreground(&self, _ipc_url: String) -> io::Result<ExitStatus> {
505            unimplemented!()
506        }
507    }
508
509    impl Detect {
510        pub fn detect(_path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
511            Ok(App)
512        }
513    }
514}
515
516#[cfg(target_os = "macos")]
517mod mac_os {
518    use anyhow::{anyhow, Context, Result};
519    use core_foundation::{
520        array::{CFArray, CFIndex},
521        string::kCFStringEncodingUTF8,
522        url::{CFURLCreateWithBytes, CFURL},
523    };
524    use core_services::{kLSLaunchDefaults, LSLaunchURLSpec, LSOpenFromURLSpec, TCFType};
525    use serde::Deserialize;
526    use std::{
527        ffi::OsStr,
528        fs, io,
529        path::{Path, PathBuf},
530        process::{Command, ExitStatus},
531        ptr,
532    };
533
534    use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
535
536    use crate::{Detect, InstalledApp};
537
538    #[derive(Debug, Deserialize)]
539    struct InfoPlist {
540        #[serde(rename = "CFBundleShortVersionString")]
541        bundle_short_version_string: String,
542    }
543
544    enum Bundle {
545        App {
546            app_bundle: PathBuf,
547            plist: InfoPlist,
548        },
549        LocalPath {
550            executable: PathBuf,
551            plist: InfoPlist,
552        },
553    }
554
555    fn locate_bundle() -> Result<PathBuf> {
556        let cli_path = std::env::current_exe()?.canonicalize()?;
557        let mut app_path = cli_path.clone();
558        while app_path.extension() != Some(OsStr::new("app")) {
559            if !app_path.pop() {
560                return Err(anyhow!("cannot find app bundle containing {:?}", cli_path));
561            }
562        }
563        Ok(app_path)
564    }
565
566    impl Detect {
567        pub fn detect(path: Option<&Path>) -> anyhow::Result<impl InstalledApp> {
568            let bundle_path = if let Some(bundle_path) = path {
569                bundle_path
570                    .canonicalize()
571                    .with_context(|| format!("Args bundle path {bundle_path:?} canonicalization"))?
572            } else {
573                locate_bundle().context("bundle autodiscovery")?
574            };
575
576            match bundle_path.extension().and_then(|ext| ext.to_str()) {
577                Some("app") => {
578                    let plist_path = bundle_path.join("Contents/Info.plist");
579                    let plist =
580                        plist::from_file::<_, InfoPlist>(&plist_path).with_context(|| {
581                            format!("Reading *.app bundle plist file at {plist_path:?}")
582                        })?;
583                    Ok(Bundle::App {
584                        app_bundle: bundle_path,
585                        plist,
586                    })
587                }
588                _ => {
589                    println!("Bundle path {bundle_path:?} has no *.app extension, attempting to locate a dev build");
590                    let plist_path = bundle_path
591                        .parent()
592                        .with_context(|| format!("Bundle path {bundle_path:?} has no parent"))?
593                        .join("WebRTC.framework/Resources/Info.plist");
594                    let plist =
595                        plist::from_file::<_, InfoPlist>(&plist_path).with_context(|| {
596                            format!("Reading dev bundle plist file at {plist_path:?}")
597                        })?;
598                    Ok(Bundle::LocalPath {
599                        executable: bundle_path,
600                        plist,
601                    })
602                }
603            }
604        }
605    }
606
607    impl InstalledApp for Bundle {
608        fn zed_version_string(&self) -> String {
609            let is_dev = matches!(self, Self::LocalPath { .. });
610            format!(
611                "Zed {}{}{}",
612                self.plist().bundle_short_version_string,
613                if is_dev { " (dev)" } else { "" },
614                self.path().display(),
615            )
616        }
617
618        fn launch(&self, url: String) -> anyhow::Result<()> {
619            match self {
620                Self::App { app_bundle, .. } => {
621                    let app_path = app_bundle;
622
623                    let status = unsafe {
624                        let app_url = CFURL::from_path(app_path, true)
625                            .with_context(|| format!("invalid app path {app_path:?}"))?;
626                        let url_to_open = CFURL::wrap_under_create_rule(CFURLCreateWithBytes(
627                            ptr::null(),
628                            url.as_ptr(),
629                            url.len() as CFIndex,
630                            kCFStringEncodingUTF8,
631                            ptr::null(),
632                        ));
633                        // equivalent to: open zed-cli:... -a /Applications/Zed\ Preview.app
634                        let urls_to_open =
635                            CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]);
636                        LSOpenFromURLSpec(
637                            &LSLaunchURLSpec {
638                                appURL: app_url.as_concrete_TypeRef(),
639                                itemURLs: urls_to_open.as_concrete_TypeRef(),
640                                passThruParams: ptr::null(),
641                                launchFlags: kLSLaunchDefaults,
642                                asyncRefCon: ptr::null_mut(),
643                            },
644                            ptr::null_mut(),
645                        )
646                    };
647
648                    anyhow::ensure!(
649                        status == 0,
650                        "cannot start app bundle {}",
651                        self.zed_version_string()
652                    );
653                }
654
655                Self::LocalPath { executable, .. } => {
656                    let executable_parent = executable
657                        .parent()
658                        .with_context(|| format!("Executable {executable:?} path has no parent"))?;
659                    let subprocess_stdout_file = fs::File::create(
660                        executable_parent.join("zed_dev.log"),
661                    )
662                    .with_context(|| format!("Log file creation in {executable_parent:?}"))?;
663                    let subprocess_stdin_file =
664                        subprocess_stdout_file.try_clone().with_context(|| {
665                            format!("Cloning descriptor for file {subprocess_stdout_file:?}")
666                        })?;
667                    let mut command = std::process::Command::new(executable);
668                    let command = command
669                        .env(FORCE_CLI_MODE_ENV_VAR_NAME, "")
670                        .stderr(subprocess_stdout_file)
671                        .stdout(subprocess_stdin_file)
672                        .arg(url);
673
674                    command
675                        .spawn()
676                        .with_context(|| format!("Spawning {command:?}"))?;
677                }
678            }
679
680            Ok(())
681        }
682
683        fn run_foreground(&self, ipc_url: String) -> io::Result<ExitStatus> {
684            let path = match self {
685                Bundle::App { app_bundle, .. } => app_bundle.join("Contents/MacOS/zed"),
686                Bundle::LocalPath { executable, .. } => executable.clone(),
687            };
688
689            std::process::Command::new(path).arg(ipc_url).status()
690        }
691    }
692
693    impl Bundle {
694        fn plist(&self) -> &InfoPlist {
695            match self {
696                Self::App { plist, .. } => plist,
697                Self::LocalPath { plist, .. } => plist,
698            }
699        }
700
701        fn path(&self) -> &Path {
702            match self {
703                Self::App { app_bundle, .. } => app_bundle,
704                Self::LocalPath { executable, .. } => executable,
705            }
706        }
707    }
708
709    pub(super) fn spawn_channel_cli(
710        channel: release_channel::ReleaseChannel,
711        leftover_args: Vec<String>,
712    ) -> Result<()> {
713        use anyhow::bail;
714
715        let app_id_prompt = format!("id of app \"{}\"", channel.display_name());
716        let app_id_output = Command::new("osascript")
717            .arg("-e")
718            .arg(&app_id_prompt)
719            .output()?;
720        if !app_id_output.status.success() {
721            bail!("Could not determine app id for {}", channel.display_name());
722        }
723        let app_name = String::from_utf8(app_id_output.stdout)?.trim().to_owned();
724        let app_path_prompt = format!("kMDItemCFBundleIdentifier == '{app_name}'");
725        let app_path_output = Command::new("mdfind").arg(app_path_prompt).output()?;
726        if !app_path_output.status.success() {
727            bail!(
728                "Could not determine app path for {}",
729                channel.display_name()
730            );
731        }
732        let app_path = String::from_utf8(app_path_output.stdout)?.trim().to_owned();
733        let cli_path = format!("{app_path}/Contents/MacOS/cli");
734        Command::new(cli_path).args(leftover_args).spawn()?;
735        Ok(())
736    }
737}