diff --git a/Cargo.lock b/Cargo.lock index 23d15e832..9834f6761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4168,6 +4168,17 @@ dependencies = [ "zip", ] +[[package]] +name = "ide-cli" +version = "0.1.35" +dependencies = [ + "dirs 6.0.0", + "serde", + "serde_json", + "tempfile", + "tracing", +] + [[package]] name = "ide-clipboard" version = "0.1.38" @@ -6367,6 +6378,7 @@ dependencies = [ "ide-analysis", "ide-android", "ide-build", + "ide-cli", "ide-clipboard", "ide-core", "ide-coverage", diff --git a/Cargo.toml b/Cargo.toml index 73910c928..67d9c4b66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ ide-host = { path = "crates/ide-host" } ide-notebook = { path = "crates/ide-notebook" } ide-extension = { path = "crates/ide-extension" } ide-plugin = { path = "crates/ide-plugin" } +ide-cli = { path = "crates/ide-cli" } # serialization & IPC typing serde = { version = "1", features = ["derive"] } diff --git a/crates/ide-cli/Cargo.toml b/crates/ide-cli/Cargo.toml new file mode 100644 index 000000000..829943612 --- /dev/null +++ b/crates/ide-cli/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ide-cli" +description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +dirs.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ide-cli/src/args.rs b/crates/ide-cli/src/args.rs new file mode 100644 index 000000000..e43f965b6 --- /dev/null +++ b/crates/ide-cli/src/args.rs @@ -0,0 +1,176 @@ +//! Command-line arguments of `openidea`. + +use crate::Target; +use std::path::Path; + +pub const USAGE: &str = "\ +Usage: openidea [options] [[--line ] [--column ] path[:line[:column]] ...] + +Opens folders as projects and files in the editor of the running OpenIDEA, +starting it if needed. Without paths, brings OpenIDEA to the front. +Lines and columns start at 1; --line and --column apply to the next path. + +Options: + --line Put the caret on line of the next file + --column Put the caret in column of that line + -h, --help Show this help + -v, --version Show the version +"; + +#[derive(Debug, PartialEq, Eq)] +pub enum Command { + Help, + Version, + Open(Vec), +} + +/// Parses `args` (without the program name); relative paths resolve against `cwd`. +pub fn parse(args: &[String], cwd: &Path) -> Result { + parse_with(args, cwd, &|p| p.exists()) +} + +fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result { + let mut targets = Vec::new(); + let mut options = true; + let (mut line, mut column) = (None, None); + let mut args = args.iter(); + while let Some(arg) = args.next() { + let (flag, inline) = match arg.split_once('=') { + Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())), + _ => (arg.as_str(), None), + }; + match flag { + "--" if options => options = false, + "-h" | "--help" if options => return Ok(Command::Help), + "-v" | "--version" if options => return Ok(Command::Version), + "--line" | "--column" if options => { + let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?; + let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?; + *if flag == "--line" { &mut line } else { &mut column } = Some(n); + } + a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")), + _ => { + let mut target = target(arg, cwd, exists)?; + if let Some(line) = line.take() { + target.line = Some(line); + target.column = column.take(); + } + targets.push(target); + } + } + } + if line.is_some() || column.is_some() { + return Err("--line and --column need a file after them".into()); + } + Ok(Command::Open(targets)) +} + +/// `arg` as a path, else as `path:line[:column]` when `path` exists. +fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result { + let path = cwd.join(arg); + if exists(&path) { + return Ok(Target { path, line: None, column: None }); + } + for (base, line, column) in split_position(arg) { + let path = cwd.join(base); + if exists(&path) { + return Ok(Target { path, line: Some(line), column }); + } + } + Err(format!("{arg}: no such file or directory")) +} + +/// The readings of `arg` as `path:line:column`, then as `path:line`. +fn split_position(arg: &str) -> Vec<(&str, u32, Option)> { + let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() }; + let Some(last) = position(last) else { return Vec::new() }; + let mut readings = Vec::new(); + if let Some((base, line)) = rest.rsplit_once(':') + && let Some(line) = position(line) + && !base.is_empty() + { + readings.push((base, line, Some(last))); + } + if !rest.is_empty() { + readings.push((rest, last, None)); + } + readings +} + +fn position(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok().filter(|n| *n > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|s| (*s).to_owned()).collect() + } + + fn parse_in(list: &[&str], existing: &[&str]) -> Result { + let existing: Vec = existing.iter().map(PathBuf::from).collect(); + parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p)) + } + + fn at(path: &str, line: Option, column: Option) -> Target { + Target { path: path.into(), line, column } + } + + #[test] + fn paths_resolve_against_cwd() { + assert_eq!( + parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]), + Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)])) + ); + assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![]))); + } + + #[test] + fn positions_split_only_when_the_whole_arg_is_missing() { + let files = ["/work/a.rs", "/work/b:2"]; + assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)]))); + assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))]))); + assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)]))); + assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)]))); + assert!(parse_in(&["a.rs:0"], &files).is_err()); + assert!(parse_in(&["a.rs:x"], &files).is_err()); + assert!(parse_in(&["missing.rs:3"], &files).is_err()); + } + + #[test] + fn windows_drive_colon_is_not_a_position() { + assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]); + assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]); + assert!(split_position(r"C:\a.rs").is_empty()); + } + + #[test] + fn line_and_column_flags_apply_to_the_next_path() { + let files = ["/work/a.rs", "/work/b.rs"]; + assert_eq!( + parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files), + Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)])) + ); + assert_eq!( + parse_in(&["--line=3", "a.rs:9:9"], &files), + Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)])) + ); + assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err()); + assert!(parse_in(&["--line"], &files).is_err()); + assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err()); + } + + #[test] + fn options() { + assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help)); + assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version)); + assert!(parse_in(&["--nope"], &[]).is_err()); + assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)]))); + } +} diff --git a/crates/ide-cli/src/install.rs b/crates/ide-cli/src/install.rs new file mode 100644 index 000000000..e27a53a28 --- /dev/null +++ b/crates/ide-cli/src/install.rs @@ -0,0 +1,172 @@ +//! Installing the `openidea` command: a symlink on `PATH` to the launcher +//! script in the app bundle (`Contents/Resources/bin/openidea`). macOS only. + +use std::ffi::OsStr; +use std::io; +use std::path::{Path, PathBuf}; + +pub const COMMAND: &str = "openidea"; +const LAUNCHER: &str = "Contents/Resources/bin/openidea"; +const SYSTEM_DIR: &str = "/usr/local/bin"; + +/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle. +pub fn bundled_launcher(exe: &Path) -> Option { + let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER); + launcher.is_file().then_some(launcher) +} + +/// macOS runs a quarantined app from a random read-only copy until it is moved: +/// a link there would break on the next launch. +pub fn translocated(path: &Path) -> bool { + path.to_string_lossy().contains("/AppTranslocation/") +} + +/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator +/// rights needed), else `/usr/local/bin`. +pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf { + let local = home.map(|h| h.join(".local/bin")); + match (path_var, local) { + (Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local, + _ => PathBuf::from(SYSTEM_DIR), + } +} + +/// Directories an earlier install may have used. +pub fn link_dirs(home: Option<&Path>) -> Vec { + home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect() +} + +/// The installed links among `dirs` (any OpenIDEA bundle's launcher). +pub fn installed(dirs: &[PathBuf]) -> Vec { + dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect() +} + +fn is_launcher_link(link: &Path) -> bool { + std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER)) +} + +/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir` +/// is not writable. Refuses to replace an `openidea` that is not ours. +pub fn install(launcher: &Path, dir: &Path) -> io::Result { + let link = dir.join(COMMAND); + if link.symlink_metadata().is_ok() && !is_launcher_link(&link) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} already exists and is not an OpenIDEA command", link.display()), + )); + } + let direct = std::fs::create_dir_all(dir).and_then(|()| { + if link.symlink_metadata().is_ok() { + std::fs::remove_file(&link)?; + } + symlink(launcher, &link) + }); + match direct { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))? + } + result => result?, + } + Ok(link) +} + +/// Removes `links`, asking for administrator rights where needed. +pub fn uninstall(links: &[PathBuf]) -> io::Result<()> { + for link in links { + match std::fs::remove_file(link) { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?, + result => result?, + } + } + Ok(()) +} + +#[cfg(unix)] +fn symlink(target: &Path, link: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +#[cfg(not(unix))] +fn symlink(_target: &Path, _link: &Path) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform")) +} + +/// Runs `command` as root after the system's password prompt. Cancelling it is +/// [`io::ErrorKind::Interrupted`]. +fn privileged(command: &str) -> io::Result<()> { + if !cfg!(target_os = "macos") { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required")); + } + let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command)); + let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?; + if output.status.success() { + return Ok(()); + } + let message = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + // "User canceled. (-128)" + let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied }; + Err(io::Error::new(kind, message)) +} + +fn quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', r"'\''")) +} + +fn applescript_escape(s: &str) -> String { + s.replace('\\', r"\\").replace('"', "\\\"") +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + fn bundle(root: &Path) -> (PathBuf, PathBuf) { + let app = root.join("OpenIDEA.app"); + let exe = app.join("Contents/MacOS/openidea"); + let launcher = app.join(LAUNCHER); + std::fs::create_dir_all(exe.parent().unwrap()).unwrap(); + std::fs::create_dir_all(launcher.parent().unwrap()).unwrap(); + std::fs::write(&exe, "").unwrap(); + std::fs::write(&launcher, "").unwrap(); + (exe, launcher) + } + + #[test] + fn finds_the_launcher_of_a_bundle_only() { + let dir = tempfile::tempdir().unwrap(); + let (exe, launcher) = bundle(dir.path()); + assert_eq!(bundled_launcher(&exe), Some(launcher)); + assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None); + assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app"))); + } + + #[test] + fn prefers_local_bin_on_path() { + let home = Path::new("/Users/u"); + let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap(); + assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin")); + assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR)); + assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR)); + } + + #[test] + fn install_replaces_its_own_link_but_not_other_files() { + let dir = tempfile::tempdir().unwrap(); + let (_, launcher) = bundle(dir.path()); + let bin = dir.path().join("bin"); + let link = install(&launcher, &bin).unwrap(); + assert_eq!(std::fs::read_link(&link).unwrap(), launcher); + assert_eq!(install(&launcher, &bin).unwrap(), link); + assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]); + uninstall(&installed(std::slice::from_ref(&bin))).unwrap(); + assert!(installed(std::slice::from_ref(&bin)).is_empty()); + std::fs::write(&link, "someone else's").unwrap(); + assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists); + } + + #[test] + fn quoting() { + assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'"); + assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#); + } +} diff --git a/crates/ide-cli/src/lib.rs b/crates/ide-cli/src/lib.rs new file mode 100644 index 000000000..ebd15b1d7 --- /dev/null +++ b/crates/ide-cli/src/lib.rs @@ -0,0 +1,46 @@ +//! The `openidea` command. +//! +//! The command is the app's own executable run in CLI mode: it parses its +//! arguments ([`args`]), sends one [`Request`] to the running app over a local +//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher +//! script into a directory on `PATH`. + +pub mod args; +pub mod install; +pub mod socket; + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Bumped when [`Request`] changes incompatibly; an app still running an older +/// version answers with an error instead of misreading it. +pub const PROTOCOL_VERSION: u32 = 1; + +/// A file or folder to open, with an optional 1-based position in a file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Target { + /// Absolute. + pub path: PathBuf, + pub line: Option, + pub column: Option, +} + +/// What the command asks the running app to do. No targets: bring the app to the front. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Request { + pub version: u32, + pub targets: Vec, +} + +impl Request { + pub fn open(targets: Vec) -> Self { + Self { version: PROTOCOL_VERSION, targets } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Response { + Ok, + Error { message: String }, +} diff --git a/crates/ide-cli/src/socket.rs b/crates/ide-cli/src/socket.rs new file mode 100644 index 000000000..22023f797 --- /dev/null +++ b/crates/ide-cli/src/socket.rs @@ -0,0 +1,154 @@ +//! The local socket the running app listens on: one JSON [`Request`] line in, +//! one [`Response`] line out. Unix domain sockets; not available on Windows yet. + +use crate::{PROTOCOL_VERSION, Request, Response}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// The socket of the app with bundle identifier `identifier`. The name is a hash: +/// socket paths are limited to about 100 bytes, and development identifiers are long. +pub fn socket_path(identifier: &str) -> Option { + let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) }; + Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier)))) +} + +fn fnv1a(s: &str) -> u64 { + s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)) +} + +fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response { + match serde_json::from_str::(line) { + Ok(request) if request.version == PROTOCOL_VERSION => handle(request), + Ok(request) => Response::Error { message: format!("unsupported request version {}", request.version) }, + Err(e) => Response::Error { message: format!("invalid request: {e}") }, + } +} + +#[cfg(unix)] +mod imp { + use super::*; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::{UnixListener, UnixStream}; + + pub fn send(socket: &Path, request: &Request) -> io::Result { + let stream = UnixStream::connect(socket)?; + stream.set_read_timeout(Some(TIMEOUT))?; + write_line(&stream, request)?; + let mut reply = String::new(); + BufReader::new(&stream).read_line(&mut reply)?; + Ok(serde_json::from_str(&reply)?) + } + + pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> { + let listener = bind(socket)?; + std::thread::Builder::new().name("cli-socket".into()).spawn(move || { + for stream in listener.incoming().flatten() { + if let Err(e) = serve_one(&stream, &handle) { + tracing::warn!("cli request failed: {e}"); + } + } + })?; + Ok(()) + } + + /// Binds `socket`, replacing a stale file no one answers on. + fn bind(socket: &Path) -> io::Result { + if let Some(dir) = socket.parent() { + std::fs::create_dir_all(dir)?; + } + let listener = match UnixListener::bind(socket) { + Err(e) if e.kind() == io::ErrorKind::AddrInUse => { + if UnixStream::connect(socket).is_ok() { + return Err(e); + } + std::fs::remove_file(socket)?; + UnixListener::bind(socket)? + } + bound => bound?, + }; + std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?; + Ok(listener) + } + + fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> { + stream.set_read_timeout(Some(TIMEOUT))?; + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line)?; + write_line(stream, &answer(&line, handle)) + } + + fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> { + let mut line = serde_json::to_string(value)?; + line.push('\n'); + stream.write_all(line.as_bytes()) + } +} + +#[cfg(not(unix))] +mod imp { + use super::*; + + fn unsupported() -> io::Error { + io::Error::new(io::ErrorKind::Unsupported, "the openidea command is not available on this platform yet") + } + + pub fn send(_socket: &Path, _request: &Request) -> io::Result { + Err(unsupported()) + } + + pub fn serve(_socket: &Path, _handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> { + Err(unsupported()) + } +} + +/// Sends `request` to the app listening on `socket` and waits for its answer. +pub use imp::send; +/// Answers requests on `socket` with `handle`, on a thread of its own. +pub use imp::serve; + +#[cfg(test)] +mod tests { + use super::*; + use crate::Target; + + #[test] + fn socket_names_separate_identifiers_and_stay_short() { + let release = socket_path("ai.l0ng.openidea").unwrap(); + let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap(); + assert_ne!(release, dev); + assert_eq!(dev.file_name().unwrap().len(), "openidea-0123456789abcdef.sock".len()); + } + + #[test] + fn answers_only_its_protocol_version() { + let ok = |_: Request| Response::Ok; + assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok); + let old = r#"{"version":0,"targets":[]}"#; + assert!(matches!(answer(old, &ok), Response::Error { .. })); + assert!(matches!(answer("garbage", &ok), Response::Error { .. })); + } + + #[cfg(unix)] + #[test] + fn request_round_trip_and_stale_socket() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("cli.sock"); + // A file left by an app that exited without removing it. + drop(std::os::unix::net::UnixListener::bind(&socket).unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + serve(&socket, move |request| { + tx.send(request).unwrap(); + Response::Ok + }) + .unwrap(); + let target = Target { path: "/p/a.rs".into(), line: Some(3), column: None }; + assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok); + assert_eq!(rx.recv().unwrap().targets, vec![target]); + // A live server keeps its socket. + assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15d00456c..22539b38d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ tracing-subscriber.workspace = true dirs.workspace = true ide-core.workspace = true +ide-cli.workspace = true dunce.workspace = true ide-clipboard.workspace = true ide-fonts.workspace = true diff --git a/src-tauri/cli/openidea b/src-tauri/cli/openidea new file mode 100755 index 000000000..29458bfd1 --- /dev/null +++ b/src-tauri/cli/openidea @@ -0,0 +1,13 @@ +#!/bin/sh +# The `openidea` command, linked onto PATH by "Install 'openidea' Command". +# Runs the app executable of this bundle in CLI mode (src-tauri/src/cli.rs). +self=$0 +while [ -L "$self" ]; do + link=$(readlink "$self") + case $link in + /*) self=$link ;; + *) self=$(dirname "$self")/$link ;; + esac +done +contents=$(cd "$(dirname "$self")/../.." && pwd -P) || exit 1 +exec "$contents/MacOS/openidea" --cli "$@" diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs new file mode 100644 index 000000000..bb2fcad73 --- /dev/null +++ b/src-tauri/src/cli.rs @@ -0,0 +1,163 @@ +//! The `openidea` command: this executable in CLI mode (started by the bundle's +//! launcher script with [`CLI_FLAG`]), the socket the app answers it on, and +//! installing the command on `PATH`. + +use ide_cli::args::{self, Command}; +use ide_cli::{Request, Response, socket}; +use ide_core::{IdeError, IdeResult, IpcPath}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tauri::AppHandle; + +/// First argument from `Contents/Resources/bin/openidea`. +const CLI_FLAG: &str = "--cli"; +/// How long a starting app has to open its socket. +const START_TIMEOUT: Duration = Duration::from_secs(30); + +/// Runs as the `openidea` command when started with [`CLI_FLAG`]; returns the exit code. +pub fn run(identifier: &str) -> Option { + let mut args = std::env::args().skip(1); + if args.next().as_deref() != Some(CLI_FLAG) { + return None; + } + let args: Vec = args.collect(); + Some(match client(identifier, &args) { + Ok(()) => 0, + Err(message) => { + eprintln!("openidea: {message}"); + 1 + } + }) +} + +fn client(identifier: &str, args: &[String]) -> Result<(), String> { + let cwd = std::env::current_dir().map_err(|e| format!("no working directory: {e}"))?; + let targets = match args::parse(args, &cwd)? { + Command::Help => { + print!("{}", args::USAGE); + return Ok(()); + } + Command::Version => { + println!("OpenIDEA {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + Command::Open(targets) => targets, + }; + let socket = socket::socket_path(identifier).ok_or("no cache directory for the OpenIDEA socket")?; + let request = Request::open(targets); + let response = match socket::send(&socket, &request) { + Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => { + start_app()?; + send_when_started(&socket, &request)? + } + sent => sent.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?, + }; + match response { + Response::Ok => Ok(()), + Response::Error { message } => Err(message), + } +} + +/// Starts the app detached from this terminal: through Launch Services for a +/// bundle, else (a development build) this executable without [`CLI_FLAG`]. +fn start_app() -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let mut command = match app_bundle(&exe) { + Some(bundle) => { + let mut open = std::process::Command::new("/usr/bin/open"); + open.arg("-a").arg(bundle); + open + } + None => std::process::Command::new(&exe), + }; + command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(&mut command, 0); + command.spawn().map(drop).map_err(|e| format!("cannot start OpenIDEA: {e}")) +} + +fn send_when_started(socket: &Path, request: &Request) -> Result { + let start = Instant::now(); + loop { + match socket::send(socket, request) { + Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => { + if start.elapsed() > START_TIMEOUT { + return Err("OpenIDEA did not start in time".into()); + } + std::thread::sleep(Duration::from_millis(100)); + } + sent => return sent.map_err(|e| format!("cannot reach OpenIDEA: {e}")), + } + } +} + +/// `….app` of the bundle executable `exe` (`….app/Contents/MacOS/openidea`). +fn app_bundle(exe: &Path) -> Option { + let bundle = exe.parent()?.parent()?.parent()?; + (cfg!(target_os = "macos") && bundle.extension().is_some_and(|e| e == "app")).then(|| bundle.to_path_buf()) +} + +/// Answers the `openidea` command for this app: its targets open like paths from Finder. +pub fn serve(app: &AppHandle, identifier: &str) { + if !cfg!(unix) { + return; + } + let Some(socket) = socket::socket_path(identifier) else { return }; + let app = app.clone(); + let served = socket::serve(&socket, move |request| { + let handle = app.clone(); + match app.run_on_main_thread(move || crate::windows::open_targets(&handle, request.targets)) { + Ok(()) => Response::Ok, + Err(e) => Response::Error { message: format!("OpenIDEA cannot open windows: {e}") }, + } + }); + if let Err(e) = served { + tracing::warn!("cannot listen for the openidea command on {}: {e}", socket.display()); + } +} + +/// The launcher script of this app's bundle; it must be in a stable location. +fn launcher() -> IdeResult { + let exe = std::env::current_exe().map_err(|e| IdeError::internal(e.to_string()))?; + let launcher = ide_cli::install::bundled_launcher(&exe) + .ok_or_else(|| IdeError::unavailable("only the packaged macOS app can install the openidea command"))?; + if ide_cli::install::translocated(&launcher) { + return Err(IdeError::unavailable("move OpenIDEA to the Applications folder first")); + } + Ok(launcher) +} + +fn install_error(e: std::io::Error) -> IdeError { + if e.kind() == ErrorKind::Interrupted { IdeError::cancelled() } else { IdeError::internal(e.to_string()) } +} + +/// Links the `openidea` command onto `PATH`; returns the link. Blocks while the +/// administrator password prompt is up. +pub fn install() -> IdeResult { + let launcher = launcher()?; + let dir = ide_cli::install::link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref()); + let link = ide_cli::install::install(&launcher, &dir).map_err(install_error)?; + tracing::info!("installed the openidea command at {}", link.display()); + Ok(link.into()) +} + +/// Removes every installed `openidea` command link; returns them. +pub fn uninstall() -> IdeResult> { + let links = ide_cli::install::installed(&ide_cli::install::link_dirs(dirs::home_dir().as_deref())); + ide_cli::install::uninstall(&links).map_err(install_error)?; + Ok(links.into_iter().map(Into::into).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundle_of_the_app_executable() { + let exe = Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"); + let expected = cfg!(target_os = "macos").then(|| PathBuf::from("/Applications/OpenIDEA.app")); + assert_eq!(app_bundle(exe), expected); + assert_eq!(app_bundle(Path::new("/repo/target/debug/openidea")), None); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ecb5fd3a8..d49fbf723 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -441,6 +441,8 @@ pub fn builder() -> tauri_specta::Builder { window::menu_model, window::menu_set_disabled, window::app_log_location, + window::app_cli_install, + window::app_cli_uninstall, window::app_build_info, window::app_check_update, window::app_startup_update_check, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index fb1534d85..17f1529e4 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -295,6 +295,20 @@ pub fn app_log_location() -> IdeResult { Ok(LogLocation { dir: dir.into(), file: crate::logging::latest_log(dir).map(Into::into) }) } +/// Links the `openidea` command onto `PATH` (macOS app bundle only); returns the link. +#[tauri::command] +#[specta::specta] +pub async fn app_cli_install() -> IdeResult { + tauri::async_runtime::spawn_blocking(crate::cli::install).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +/// Removes the installed `openidea` command; returns the links removed. +#[tauri::command] +#[specta::specta] +pub async fn app_cli_uninstall() -> IdeResult> { + tauri::async_runtime::spawn_blocking(crate::cli::uninstall).await.map_err(|e| IdeError::internal(e.to_string()))? +} + #[derive(Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct BuildInfo { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d00f4402c..93f3be92f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ //! adapter from IPC to a backend crate; no domain logic lives here. mod application_focus; +mod cli; mod commands; #[cfg(debug_assertions)] mod dev; @@ -69,6 +70,10 @@ pub fn run() { let context = tauri::generate_context!(); #[cfg(debug_assertions)] let context = dev::configure(context); + // The `openidea` command: answer and exit, no UI. + if let Some(code) = cli::run(&context.config().identifier) { + std::process::exit(code); + } // Before the first window: one taskbar identity for every launch path. jump_list::set_app_id(&context.config().identifier); logging::init(&context.config().identifier); @@ -189,6 +194,7 @@ pub fn run() { #[cfg(target_os = "macos")] application_focus::observe(app.handle()); app.manage(menu_state); + cli::serve(app.handle(), &app.config().identifier); if let Some(paths) = setup_opens.lock().take() { windows::open_paths_at_launch(app.handle(), paths); } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 226b4c086..d802e6656 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -191,6 +191,9 @@ pub fn spec(os: Os) -> Vec { act("app.about", "About OpenIDEA"), act("app.checkForUpdates", "Check for Updates…"), SEP, + act("app.installCli", "Install 'openidea' Command"), + act("app.uninstallCli", "Uninstall 'openidea' Command"), + SEP, act("file.settings", "Settings…"), SEP, Entry::Native(Native::Services), diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 6ba3b1051..1e0176609 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -1127,16 +1127,40 @@ pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { remember_cli_position(&app.state::(), &args, &path); open_path(app, &path) } - None => { - if let Some(w) = target_window(app) { - focus(&w); - } else { - spawn_window(app, None); - } - } + None => focus_any(app), } } +/// Focuses the window the user last looked at, else opens a new one. +fn focus_any(app: &AppHandle) { + if let Some(w) = target_window(app) { + focus(&w); + } else { + spawn_window(app, None); + } +} + +/// Opens what the `openidea` command asked for, like [`open_paths`]; files open at their +/// 1-based line and column. Without targets, focuses a window. +pub fn open_targets(app: &AppHandle, targets: Vec) { + if targets.is_empty() { + return focus_any(app); + } + let windows = app.state::(); + let paths = targets + .into_iter() + .map(|t| { + if let Some(line) = t.line + && !t.path.is_dir() + { + windows.set_position(&t.path, (line.saturating_sub(1), t.column.map_or(0, |c| c.saturating_sub(1)))); + } + t.path + }) + .collect(); + open_paths(app, paths); +} + /// `openidea diff a.txt b.txt` while the app is running: hands the pair to the window the user is /// looking at (a project window, never an auxiliary one), else opens a new window for it. fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) { diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 2bf5a414d..c9732a387 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -1,5 +1,10 @@ { "bundle": { + "macOS": { + "files": { + "Resources/bin/openidea": "./cli/openidea" + } + }, "fileAssociations": [ { "name": "Text and Source Code", diff --git a/src/bindings.ts b/src/bindings.ts index 91103e03b..cb6a89071 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -952,6 +952,10 @@ export const commands = { */ menuSetDisabled: (ids: string[]) => __TAURI_INVOKE("menu_set_disabled", { ids }), appLogLocation: () => typedError(__TAURI_INVOKE("app_log_location")), + /** Links the `openidea` command onto `PATH` (macOS app bundle only); returns the link. */ + appCliInstall: () => typedError(__TAURI_INVOKE("app_cli_install")), + /** Removes the installed `openidea` command; returns the links removed. */ + appCliUninstall: () => typedError(__TAURI_INVOKE("app_cli_uninstall")), appBuildInfo: () => __TAURI_INVOKE("app_build_info"), appCheckUpdate: (source: UpdateSource) => typedError(__TAURI_INVOKE("app_check_update", { source })), /** diff --git a/src/editor/core/file-open-order.test.ts b/src/editor/core/file-open-order.test.ts index cb8630974..d9df6a260 100644 --- a/src/editor/core/file-open-order.test.ts +++ b/src/editor/core/file-open-order.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { registerFileEditorProvider } from "@/workbench/contributions"; import { EditorController, openFilePaths, useTabs } from "./controller"; import { createMemoryBackend } from "./backend"; +import { storeSession } from "./model/session"; import { initialTabsState } from "./model/tabs"; import { useProject } from "@/platform/project"; @@ -63,6 +64,25 @@ function tabFor(path: string) { } describe("file open order", () => { + it("keeps the caret and activation of a file opened while the project's session restores", async () => { + const before = setup(); + before.restoreSession("/p"); + await before.openFile("/p/b.ts", { focus: false }); + before.stop(); + useTabs.setState(initialTabsState(), true); + try { + const controller = setup(); + const open = controller.openFile("/p/a.ts", { selection: { line: 1, col: 2 }, focus: false }); + controller.restoreSession("/p"); + await open; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/a.ts"); + expect(controller.slotFor(tabFor("/p/a.ts")!.id)?.caret()).toMatchObject({ line: 1, col: 2 }); + } finally { + storeSession("/p", null); + } + }); + it.each([1, 30])("keeps the newer file active after an earlier probe (tab limit: %s)", async (tabLimit) => { useProject.setState({ settings: { editor: { tabs: { tabLimit } } } as never }); const { started, response } = delayProvider(); diff --git a/src/features/app/cliCommand.ts b/src/features/app/cliCommand.ts new file mode 100644 index 000000000..5e91f84eb --- /dev/null +++ b/src/features/app/cliCommand.ts @@ -0,0 +1,55 @@ +/** Install / Uninstall the `openidea` shell command (macOS app bundle only). */ +import type { Action } from "@/platform/actions"; +import { t } from "@/platform/i18n"; +import { call, commands, isCancelled } from "@/platform/ipc"; +import { notifications } from "@/workbench/contributions"; + +const KEY = "app.cli"; + +function errorText(e: unknown) { + return e instanceof Error ? e.message : String(e); +} + +async function installCli(): Promise { + try { + const link = await call(commands.appCliInstall()); + notifications().notify({ level: "info", title: t("app.cli.installed"), body: link, key: KEY }); + } catch (e) { + if (!isCancelled(e)) + notifications().notify({ level: "error", title: t("app.cli.installFailed"), body: errorText(e), key: KEY }); + } +} + +async function uninstallCli(): Promise { + try { + const links = await call(commands.appCliUninstall()); + notifications().notify({ + level: "info", + title: t(links.length ? "app.cli.uninstalled" : "app.cli.notInstalled"), + body: links.join("\n") || undefined, + key: KEY, + }); + } catch (e) { + if (!isCancelled(e)) + notifications().notify({ level: "error", title: t("app.cli.uninstallFailed"), body: errorText(e), key: KEY }); + } +} + +export function cliActions(): Action[] { + return [ + { + id: "app.installCli", + title: t("app.action.installCli"), + category: t("app.category.tools"), + keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"], + run: installCli, + }, + { + id: "app.uninstallCli", + title: t("app.action.uninstallCli"), + category: t("app.category.tools"), + keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"], + run: uninstallCli, + }, + ]; +} diff --git a/src/features/app/index.ts b/src/features/app/index.ts index abc6b5667..80270905a 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -6,10 +6,11 @@ import { registerOverlay, registerStatusItem } from "@/workbench/contributions"; import { AboutOverlay } from "./AboutDialog"; import { appActions, updateActions } from "./appActions"; import { checkForUpdatesOnStartup } from "./updates"; +import { cliActions } from "./cliCommand"; import { installCloseGuard } from "./closeGuard"; import { TerminationOverlay } from "./termination"; import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches"; -import { buildInfo } from "./host"; +import { buildInfo, guessOs } from "./host"; import { KeymapReferenceOverlay } from "./KeymapReference"; import { LightEditStatus } from "./LightEditStatus"; import { installLocaleSync, registerLocalizedActions } from "./locale"; @@ -31,6 +32,7 @@ export function activate(): () => void { const disposers = [ installLocaleSync(), registerLocalizedActions(() => [...appActions(), ...windowActions()]), + registerLocalizedActions(() => (guessOs() === "macos" ? cliActions() : [])), registerOverlay({ id: "app.about", component: AboutOverlay }), registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }), registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }), diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 493b0cf13..37f77f045 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -17,6 +17,7 @@ "app.action.checkForUpdates": "Check for Updates…", "app.action.copy": "Copy", "app.action.cut": "Cut", + "app.action.installCli": "Install 'openidea' Command", "app.action.keymapReference": "Keymap Reference", "app.action.lightEditOpenProject": "Open Containing Folder as Project", "app.action.newWindow": "New Window", @@ -32,11 +33,18 @@ "app.action.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}", "app.action.toggleFullScreen": "Toggle Full Screen", "app.action.undo": "Undo", + "app.action.uninstallCli": "Uninstall 'openidea' Command", "app.category.edit": "Edit", "app.category.file": "File", "app.category.help": "Help", + "app.category.tools": "Tools", "app.category.view": "View", "app.category.window": "Window", + "app.cli.installFailed": "Cannot install the openidea command", + "app.cli.installed": "The openidea command is installed", + "app.cli.notInstalled": "The openidea command is not installed", + "app.cli.uninstallFailed": "Cannot uninstall the openidea command", + "app.cli.uninstalled": "The openidea command is uninstalled", "app.exit.body": "Are you sure you want to exit OpenIDEA?", "app.exit.confirm": "Exit", "app.exit.dontAsk": "Don't ask again", @@ -100,7 +108,9 @@ "menu.app": "OpenIDEA", "menu.app.about": "About OpenIDEA", "menu.app.checkForUpdates": "Check for Updates…", + "menu.app.installCli": "Install 'openidea' Command", "menu.app.quit": "{os, select, macos {Quit OpenIDEA} other {Exit}}", + "menu.app.uninstallCli": "Uninstall 'openidea' Command", "menu.appearance": "Appearance", "menu.code": "Code", "menu.code.commentBlock": "Comment with Block Comment", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 9db8c6437..bae5edc3a 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -17,6 +17,7 @@ "app.action.checkForUpdates": "检查更新…", "app.action.copy": "复制", "app.action.cut": "剪切", + "app.action.installCli": "安装 'openidea' 命令", "app.action.keymapReference": "快捷键参考", "app.action.lightEditOpenProject": "将所在文件夹作为项目打开", "app.action.newWindow": "新建窗口", @@ -32,11 +33,18 @@ "app.action.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}", "app.action.toggleFullScreen": "切换全屏", "app.action.undo": "撤销", + "app.action.uninstallCli": "卸载 'openidea' 命令", "app.category.edit": "编辑", "app.category.file": "文件", "app.category.help": "帮助", + "app.category.tools": "工具", "app.category.view": "视图", "app.category.window": "窗口", + "app.cli.installFailed": "无法安装 openidea 命令", + "app.cli.installed": "openidea 命令已安装", + "app.cli.notInstalled": "openidea 命令未安装", + "app.cli.uninstallFailed": "无法卸载 openidea 命令", + "app.cli.uninstalled": "openidea 命令已卸载", "app.exit.body": "确定要退出 OpenIDEA 吗?", "app.exit.confirm": "退出", "app.exit.dontAsk": "不再询问", @@ -100,7 +108,9 @@ "menu.app": "OpenIDEA", "menu.app.about": "关于 OpenIDEA", "menu.app.checkForUpdates": "检查更新…", + "menu.app.installCli": "安装 'openidea' 命令", "menu.app.quit": "{os, select, macos {退出 OpenIDEA} other {退出}}", + "menu.app.uninstallCli": "卸载 'openidea' 命令", "menu.appearance": "外观", "menu.code": "代码", "menu.code.commentBlock": "使用块注释",