From 0bfd4fa3ccbd759b0482e4a7b7e49350774f6e1d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:25:50 +0800 Subject: [PATCH] feat(app): install the openidea command on macOS, Windows and Linux (#2009) Adds Install/Uninstall 'openidea' Command actions. macOS uses the bundle launcher, Windows a user PATH wrapper and named pipe, and Linux the executable/AppImage path with a PATH hint. Files, folders, positions and `diff` go to the running app or start it. Closes #1286 Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- Cargo.lock | 15 + Cargo.toml | 1 + crates/ide-cli/Cargo.toml | 20 + crates/ide-cli/src/args.rs | 234 +++++++++ crates/ide-cli/src/install.rs | 476 ++++++++++++++++++ crates/ide-cli/src/lib.rs | 72 +++ crates/ide-cli/src/socket.rs | 266 ++++++++++ crates/ide-host/src/trust.rs | 2 + crates/ide-host/tests/trust.rs | 2 + docs/command-line.md | 41 ++ src-tauri/Cargo.toml | 1 + src-tauri/cli/openidea | 13 + src-tauri/src/cli.rs | 169 +++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/window.rs | 21 + src-tauri/src/lib.rs | 6 + src-tauri/src/menu.rs | 75 +-- src-tauri/src/windows.rs | 40 +- src-tauri/tauri.macos.conf.json | 5 + src/bindings.ts | 9 + src/editor/core/file-open-order.test.ts | 20 + src/features/app/__tests__/cliCommand.test.ts | 56 +++ src/features/app/cliCommand.ts | 73 +++ src/features/app/index.ts | 2 + src/platform/locales/en.json | 2 + src/platform/locales/text/en.json | 9 + src/platform/locales/text/zh.json | 9 + src/platform/locales/zh.json | 2 + 28 files changed, 1601 insertions(+), 42 deletions(-) create mode 100644 crates/ide-cli/Cargo.toml create mode 100644 crates/ide-cli/src/args.rs create mode 100644 crates/ide-cli/src/install.rs create mode 100644 crates/ide-cli/src/lib.rs create mode 100644 crates/ide-cli/src/socket.rs create mode 100644 docs/command-line.md create mode 100755 src-tauri/cli/openidea create mode 100644 src-tauri/src/cli.rs create mode 100644 src/features/app/__tests__/cliCommand.test.ts create mode 100644 src/features/app/cliCommand.ts diff --git a/Cargo.lock b/Cargo.lock index 7432746b2..12aa9501f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4168,6 +4168,20 @@ dependencies = [ "zip", ] +[[package]] +name = "ide-cli" +version = "0.1.39" +dependencies = [ + "dirs 6.0.0", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "windows-sys 0.61.2", + "winreg 0.56.0", +] + [[package]] name = "ide-clipboard" version = "0.1.39" @@ -6370,6 +6384,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 6faf0bef6..ba7bbc16d 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..193aa5d40 --- /dev/null +++ b/crates/ide-cli/Cargo.toml @@ -0,0 +1,20 @@ +[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 + +[target.'cfg(windows)'.dependencies] +tokio = { workspace = true, features = ["net"] } +winreg = "0.56" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] } + +[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..fc1787788 --- /dev/null +++ b/crates/ide-cli/src/args.rs @@ -0,0 +1,234 @@ +//! Command-line arguments of `openidea`. + +use crate::Target; +use std::path::Path; + +pub const USAGE: &str = "\ +Usage: openidea [options] [[--line ] [--column ] path[:line[:column]] ...] + openidea diff + +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), + Diff(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 { + if args.first().is_some_and(|arg| arg == "diff") { + return match parse_paths(&args[1..], cwd, exists)? { + Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)), + Command::Help => Ok(Command::Help), + _ => Err("diff needs two files".into()), + }; + } + parse_paths(args, cwd, exists) +} + +fn parse_paths(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 line.is_some() || column.is_some() { + target.line = line.take().or(target.line).or(Some(1)); + 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"]; + 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!(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()); + } + + // Windows file names cannot contain `:`, and `b:2` reads as a path on drive B. + #[cfg(unix)] + #[test] + fn an_existing_name_with_a_colon_wins_over_a_position() { + let files = ["/work/b:2"]; + 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)]))); + } + + #[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()); + } + + #[cfg(windows)] + #[test] + fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() { + let local = PathBuf::from(r"C:\项目\my file.rs"); + let network = PathBuf::from(r"\\server\share\folder name"); + let command = parse_with( + &args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]), + Path::new(r"D:\work"), + &|path| path == local || path == network, + ); + assert_eq!( + command, + Ok(Command::Open(vec![ + Target { path: local, line: Some(12), column: Some(5) }, + Target { path: network, line: None, column: None }, + ])) + ); + } + + #[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)]))); + } + + #[test] + fn existing_diff_command_is_supported() { + let files = ["/work/a.rs", "/work/b.rs"]; + assert_eq!( + parse_in(&["diff", "a.rs", "b.rs"], &files), + Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),])) + ); + assert!(parse_in(&["diff", "a.rs"], &files).is_err()); + assert_eq!( + parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]), + Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),])) + ); + assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)]))); + assert_eq!( + parse_in(&["--column=4", "a.rs", "b.rs"], &files), + Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),])) + ); + } +} diff --git a/crates/ide-cli/src/install.rs b/crates/ide-cli/src/install.rs new file mode 100644 index 000000000..15fc6aba6 --- /dev/null +++ b/crates/ide-cli/src/install.rs @@ -0,0 +1,476 @@ +//! Installing the `openidea` command: a symlink on `PATH` to the launcher +//! script in a macOS bundle, or a wrapper in a user's bin directory. + +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('"', "\\\"") +} + +const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1"; + +/// AppImages remount their executable on every launch. Keep the original image path. +pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result { + let path = appimage.unwrap_or(exe); + if !path.is_absolute() || !path.is_file() { + return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location")); + } + Ok(path.to_path_buf()) +} + +pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf { + dir.join(if windows { "openidea.cmd" } else { COMMAND }) +} + +fn wrapper(exe: &Path, windows: bool) -> io::Result { + let exe = + exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?; + if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters")); + } + Ok(if windows { + format!( + "@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n", + exe.replace('%', "%%") + ) + } else { + format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe))) + }) +} + +pub fn is_wrapper(path: &Path, windows: bool) -> bool { + path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) + && std::fs::read_to_string(path).is_ok_and(|text| { + text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" })) + }) +} + +/// Never follows or overwrites a foreign file, even if it has the same command name. +pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result { + use std::io::Write; + let text = wrapper(exe, windows)?; + let path = wrapper_path(dir, windows); + if path.symlink_metadata().is_ok() { + if !is_wrapper(&path, windows) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} already exists and is not an OpenIDEA command", path.display()), + )); + } + std::fs::remove_file(&path)?; + } + std::fs::create_dir_all(dir)?; + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o755))?; + } + file.write_all(text.as_bytes())?; + Ok(path) +} + +pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result> { + let path = wrapper_path(dir, windows); + if !is_wrapper(&path, windows) { + return Ok(vec![]); + } + std::fs::remove_file(&path)?; + Ok(vec![path]) +} + +/// Preserve every unrelated PATH entry, including expandable environment variables. +pub fn windows_path(current: &str, dir: &str, add: bool) -> Option { + fn equal(a: &str, b: &str) -> bool { + a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/'])) + } + let found = current.split(';').any(|entry| equal(entry, dir)); + if add { + if found { + return None; + } + Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" })) + } else if found { + Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::>().join(";")) + } else { + None + } +} + +#[cfg(windows)] +pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> { + use winreg::{ + RegKey, + enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ}, + types::{FromRegValue, ToRegValue}, + }; + let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?; + let raw = match environment.get_raw_value("Path") { + Ok(raw) => Some(raw), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default(); + let Some(updated) = windows_path(¤t, &dir.to_string_lossy(), add) else { return Ok(()) }; + let mut value = updated.to_reg_value(); + value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone()); + environment.set_raw_value("Path", &value)?; + let message: Vec = "Environment\0".encode_utf16().collect(); + // SAFETY: the terminated string stays alive for this bounded synchronous broadcast. + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW( + windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST, + windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE, + 0, + message.as_ptr() as isize, + windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG, + 1000, + std::ptr::null_mut(), + ); + } + Ok(()) +} + +pub struct Installation { + pub path: PathBuf, + /// Some when the user needs to add this directory to their shell's PATH. + pub path_directory: Option, +} + +fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool { + path_var + .and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file())) + .is_some_and(|candidate| candidate == command) +} + +fn user_bin() -> io::Result { + if cfg!(windows) { + dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin")) + } else { + dirs::home_dir().map(|dir| dir.join(".local/bin")) + } + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command")) +} + +pub fn install_current() -> io::Result { + let exe = std::env::current_exe()?; + if cfg!(target_os = "macos") { + let launcher = bundled_launcher(&exe).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app") + })?; + if translocated(&launcher) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "move OpenIDEA to the Applications folder first", + )); + } + let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref()); + return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None }); + } + let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None }; + let exe = stable_executable(&exe, image.as_deref())?; + let dir = user_bin()?; + let path = install_wrapper(&exe, &dir, cfg!(windows))?; + #[cfg(windows)] + update_user_path(&dir, true)?; + let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path); + Ok(Installation { path, path_directory: (!on_path).then_some(dir) }) +} + +pub fn uninstall_current() -> io::Result> { + if cfg!(target_os = "macos") { + let links = installed(&link_dirs(dirs::home_dir().as_deref())); + uninstall(&links)?; + return Ok(links); + } + let dir = user_bin()?; + let path = wrapper_path(&dir, cfg!(windows)); + if is_wrapper(&path, cfg!(windows)) { + #[cfg(windows)] + update_user_path(&dir, false)?; + return uninstall_wrapper(&dir, cfg!(windows)); + } + Ok(vec![]) +} + +#[cfg(test)] +mod wrapper_tests { + use super::*; + + #[test] + fn wrappers_install_update_and_remove_only_their_own_files() { + for windows in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("App folder/程序 & it's 100%.exe"); + let path = install_wrapper(&exe, dir.path(), windows).unwrap(); + assert!(is_wrapper(&path, windows)); + assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path); + assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]); + assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty()); + std::fs::write(&path, "foreign command").unwrap(); + assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command"); + } + } + + #[test] + fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() { + let dir = r"C:\Users\用户\OpenIDEA\bin"; + let initial = r"%USERPROFILE%\tools;C:\other"; + let updated = windows_path(initial, dir, true).unwrap(); + assert_eq!(updated, format!("{dir};{initial}")); + assert_eq!(windows_path(&updated, dir, true), None); + assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial)); + assert_eq!( + windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(), + Some(r"C:\other") + ); + assert_eq!(windows_path(initial, dir, false), None); + } + + #[test] + fn appimage_uses_the_persistent_image_not_the_mounted_binary() { + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("OpenIDEA.AppImage"); + std::fs::write(&image, "image").unwrap(); + assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image); + assert_eq!(stable_executable(&image, None).unwrap(), image); + assert!(stable_executable(Path::new("missing"), None).is_err()); + } + + #[test] + fn detects_a_system_binary_shadowing_the_linux_wrapper() { + let dir = tempfile::tempdir().unwrap(); + let system = dir.path().join("system"); + let user = dir.path().join("user"); + for bin in [&system, &user] { + std::fs::create_dir_all(bin).unwrap(); + std::fs::write(bin.join(COMMAND), "command").unwrap(); + } + let path = std::env::join_paths([&system, &user]).unwrap(); + assert!(!resolves_to(Some(&path), &user.join(COMMAND))); + let path = std::env::join_paths([&user, &system]).unwrap(); + assert!(resolves_to(Some(&path), &user.join(COMMAND))); + } + + #[cfg(unix)] + #[test] + fn posix_wrapper_preserves_arguments_and_exit_status() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("app & it's $dollar `quoted` \"double\""); + std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap(); + let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n"); + } + + #[cfg(windows)] + #[test] + fn cmd_wrapper_preserves_arguments_and_exit_status() { + use std::os::windows::process::CommandExt; + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("程序 & 100% !special!.exe"); + let source = dir.path().join("helper.rs"); + std::fs::write( + &source, + r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#, + ) + .unwrap(); + let compiled = std::process::Command::new("rustc") + .args(["--crate-name", "launcher_test"]) + .arg(&source) + .arg("-o") + .arg(&exe) + .output() + .unwrap(); + assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr)); + let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap(); + std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap(); + let output = std::process::Command::new("cmd.exe") + .current_dir(dir.path()) + .args(["/d", "/s", "/c"]) + .raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display())) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n"); + let check = dir.path().join("check.cmd"); + std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap(); + let output = std::process::Command::new("cmd.exe") + .current_dir(dir.path()) + .args(["/d", "/s", "/c"]) + .raw_arg(format!("\"\"{}\"\"", check.display())) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp")); + } +} + +#[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..f23fa98ed --- /dev/null +++ b/crates/ide-cli/src/lib.rs @@ -0,0 +1,72 @@ +//! 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, + #[serde(default)] + pub diff: bool, +} + +impl Request { + pub fn open(targets: Vec) -> Self { + Self { version: PROTOCOL_VERSION, targets, diff: false } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Response { + Ok, + Error { message: String }, +} + +/// A GUI subsystem executable inherits redirected handles, but needs its parent's +/// console for interactive help and errors. Batch launchers wait for this process. +#[cfg(windows)] +pub fn attach_console() { + use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType}; + use windows_sys::Win32::System::Console::{ + ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + SetStdHandle, + }; + // SAFETY: attaches only this CLI process, without allocating a new console. + unsafe { + let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| { + let handle = GetStdHandle(kind); + (kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE)) + }); + AttachConsole(ATTACH_PARENT_PROCESS); + for (kind, handle, keep) in redirected { + if keep { + SetStdHandle(kind, handle); + } + } + } +} diff --git a/crates/ide-cli/src/socket.rs b/crates/ide-cli/src/socket.rs new file mode 100644 index 000000000..d5fb7861d --- /dev/null +++ b/crates/ide-cli/src/socket.rs @@ -0,0 +1,266 @@ +//! The local socket the running app listens on: one JSON [`Request`] line in, +//! one [`Response`] line out. Unix domain sockets or Windows named pipes. + +use crate::{Request, Response}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(10); +const MAX_MESSAGE: u64 = 1024 * 1024; + +fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response { + match serde_json::from_str::(line) { + Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => { + handle(request) + } + Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() }, + Err(e) => Response::Error { message: format!("invalid request: {e}") }, + } +} + +/// 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 { + if cfg!(windows) { + let user = dirs::data_local_dir()?; + return Some(PathBuf::from(format!( + r"\\.\pipe\openidea-{:016x}", + fnv1a(&format!("{}:{identifier}", user.display())) + ))); + } + 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)) +} + +#[cfg(unix)] +mod imp { + use super::*; + use std::io::{BufRead, BufReader, Read, Write}; + use std::os::unix::fs::{FileTypeExt, 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))?; + stream.set_write_timeout(Some(TIMEOUT))?; + write_line(&stream, request)?; + let mut reply = String::new(); + BufReader::new((&stream).take(MAX_MESSAGE)).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 !std::fs::symlink_metadata(socket)?.file_type().is_socket() || 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))?; + stream.set_write_timeout(Some(TIMEOUT))?; + let mut line = String::new(); + BufReader::new(stream.take(MAX_MESSAGE)).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(windows)] +mod imp { + use super::*; + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions}; + + fn runtime() -> io::Result { + tokio::runtime::Builder::new_current_thread().enable_all().build() + } + + pub fn send(socket: &Path, request: &Request) -> io::Result { + runtime()?.block_on(async { + tokio::time::timeout(TIMEOUT, async { + let mut pipe = loop { + match ClientOptions::new().open(socket) { + Ok(pipe) => break pipe, + // All existing instances are busy; wait instead of starting another app. + Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await, + Err(e) => return Err(e), + } + }; + let mut line = serde_json::to_vec(request)?; + line.push(b'\n'); + pipe.write_all(&line).await?; + let mut reply = String::new(); + BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?; + pipe.write_all(b"\n").await?; + Ok(serde_json::from_str(&reply)?) + }) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))? + }) + } + + pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> { + let socket = socket.to_owned(); + let (ready, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new().name("cli-pipe".into()).spawn(move || { + let runtime = match runtime() { + Ok(runtime) => runtime, + Err(e) => { + let _ = ready.send(Err(e)); + return; + } + }; + runtime.block_on(async move { + let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) { + Ok(pipe) => pipe, + Err(e) => { + let _ = ready.send(Err(e)); + return; + } + }; + let _ = ready.send(Ok(())); + loop { + if let Err(e) = pipe.connect().await { + tracing::warn!("CLI pipe connection failed: {e}"); + return; + } + // Keep an instance bound while answering, so another process cannot take the name. + let next = match ServerOptions::new().create(&socket) { + Ok(next) => next, + Err(e) => { + tracing::warn!("cannot create CLI pipe: {e}"); + return; + } + }; + let reply = tokio::time::timeout(TIMEOUT, async { + let mut line = String::new(); + BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?; + let mut reply = serde_json::to_vec(&answer(&line, &handle))?; + reply.push(b'\n'); + pipe.write_all(&reply).await?; + // Wait until the client has drained the reply before closing its instance. + pipe.read_exact(&mut [0u8; 1]).await.map(|_| ()) + }) + .await; + if !matches!(reply, Ok(Ok(()))) { + tracing::warn!("CLI pipe request failed: {reply:?}"); + } + // Dropping after the write lets the client drain its reply before disconnecting. + drop(pipe); + pipe = next; + } + }); + })?; + receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))? + } +} + +/// 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::*; + + #[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!(dev.as_os_str().len() < 100 || cfg!(unix)); + } + + #[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(windows)] + #[test] + fn named_pipe_round_trip() { + let socket = socket_path(&format!("test-{}", std::process::id())).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + serve(&socket, move |request| { + tx.send(request).unwrap(); + Response::Ok + }) + .unwrap(); + for _ in 0..2 { + let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) }; + assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok); + assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]); + } + assert!(serve(&socket, |_| Response::Ok).is_err()); + } + + #[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 = crate::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); + } + + #[cfg(unix)] + #[test] + fn refuses_to_remove_an_unrelated_file() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("cli.sock"); + std::fs::write(&socket, "keep").unwrap(); + assert!(serve(&socket, |_| Response::Ok).is_err()); + assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep"); + } +} diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 483a9b2bc..decb80b30 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -214,6 +214,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { cmd, "app_info" | "app_build_info" + | "app_cli_install" + | "app_cli_uninstall" | "app_log_location" | "app_check_update" | "app_startup_update_check" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index baa798680..311d3f537 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -33,6 +33,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( for command in [ "app_info", "app_build_info", + "app_cli_install", + "app_cli_uninstall", "app_log_location", "app_check_update", "app_startup_update_check", diff --git a/docs/command-line.md b/docs/command-line.md new file mode 100644 index 000000000..aa8381118 --- /dev/null +++ b/docs/command-line.md @@ -0,0 +1,41 @@ +# Command-line launcher + +Install the `openidea` command from **OpenIDEA → Install 'openidea' Command** on +macOS, or **Tools → Install 'openidea' Command** on Windows and Linux. The same +action is available in Find Action. Open a new terminal after installation. + +- **macOS:** move OpenIDEA to Applications first. The command links to the launcher + inside the app bundle. It uses `~/.local/bin` when that directory is on PATH; + otherwise it uses `/usr/local/bin` and may request an administrator password. +- **Windows:** installation creates `openidea.cmd` in + `%LOCALAPPDATA%\OpenIDEA\bin` and adds that directory to the user PATH. This + works with installed and portable copies, without administrator rights. +- **Linux:** installation creates `~/.local/bin/openidea`. If another command + takes precedence or this directory is absent from PATH, the notification asks + you to put it at the beginning of your shell's PATH. For Bash and Zsh, add + `export PATH="$HOME/.local/bin:$PATH"` to your shell profile. For Fish, run + `fish_add_path --prepend ~/.local/bin`. An AppImage launcher targets the original + AppImage; keep that file in a permanent location. + +```sh +openidea . +openidea src/main.rs README.md +openidea --line 12 --column 5 src/main.rs +openidea src/main.rs:12:5 +openidea diff before.txt after.txt +openidea --help +openidea --version +``` + +Folders open as projects. Files open in their project's window, or in LightEdit +when no project contains them. Lines and columns start at 1. With no paths, the +command focuses OpenIDEA. It starts the app if necessary and returns after handing +off the request. Unknown options and missing files exit with code 1. + +Use `--` before a filename beginning with `-`, or before a file named `diff`. +The existing GUI executable and OS file associations still work. + +**Uninstall 'openidea' Command** removes only OpenIDEA's launcher, and on Windows +its dedicated user PATH entry. A command belonging to another application is +never overwritten or removed. Reinstall the command after moving a portable +copy or AppImage to a different location. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c55cbdfc3..7c72950fc 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..61750797c --- /dev/null +++ b/src-tauri/src/cli.rs @@ -0,0 +1,169 @@ +//! 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(); + #[cfg(windows)] + ide_cli::attach_console(); + 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 command = args::parse(args, &cwd)?; + let diff = matches!(command, Command::Diff(_)); + let targets = match command { + Command::Help => { + print!("{}", args::USAGE); + return Ok(()); + } + Command::Version => { + println!("OpenIDEA {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + Command::Open(targets) | Command::Diff(targets) => targets, + }; + let socket = socket::socket_path(identifier).ok_or("no cache directory for the OpenIDEA socket")?; + let request = Request { diff, ..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 image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None }; + let exe = ide_cli::install::stable_executable(&exe, image.as_deref()).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()); + ide_core::process::host_env(&mut command); + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(&mut command, 0); + #[cfg(windows)] + std::os::windows::process::CommandExt::creation_flags(&mut command, 0x0000_0008 | 0x0000_0200); + 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) { + 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 || { + if request.diff { + let mut targets = request.targets.into_iter(); + if let (Some(left), Some(right)) = (targets.next(), targets.next()) { + crate::windows::open_diff_files( + &handle, + crate::windows::DiffFilesLaunch { left: left.path.into(), right: right.path.into() }, + ); + } + } else { + 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()); + } +} + +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 result = ide_cli::install::install_current().map_err(install_error)?; + Ok(crate::commands::window::CliInstallation { + path: result.path.into(), + path_directory: result.path_directory.map(Into::into), + }) +} + +/// Removes every installed `openidea` command link; returns them. +pub fn uninstall() -> IdeResult> { + let links = ide_cli::install::uninstall_current().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 94fd6dae0..b05cd63f7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -445,6 +445,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 b87219987..ac940ceeb 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -313,6 +313,27 @@ pub fn app_log_location() -> IdeResult { Ok(LogLocation { dir: dir.into(), file: crate::logging::latest_log(dir).map(Into::into) }) } +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CliInstallation { + pub path: ide_core::IpcPath, + pub path_directory: Option, +} + +/// Installs the `openidea` command for this user. +#[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 12f76873e..9e3892832 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; @@ -72,6 +73,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); @@ -196,6 +201,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 240946692..2e78951f6 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), @@ -702,41 +705,45 @@ pub fn spec(os: Os) -> Vec { ], )); - menus.push(sub( - "tools", - "Tools", - vec![ - act("http.newRequest", "New HTTP Request"), - act("http.showToolWindow", "HTTP Client"), + let mut tools = vec![ + act("http.newRequest", "New HTTP Request"), + act("http.showToolWindow", "HTTP Client"), + SEP, + act("db.showToolWindow", "Database"), + act("db.newDataSource", "New Data Source…"), + act("db.openConsole", "Open Query Console"), + SEP, + act("ssh.startSession", "Start SSH Session…"), + act("ssh.configurations", "SSH Configurations…"), + sub( + "deployment", + "Deployment", + vec![ + act("deployment.uploadToDefault", "Upload to Default Server"), + act("deployment.uploadTo", "Upload to…"), + act("deployment.downloadFromDefault", "Download from Default Server"), + act("deployment.downloadFrom", "Download from…"), + act("deployment.syncWith", "Sync with Deployed…"), + act("deployment.deleteFromServer", "Delete from Server"), + SEP, + act("deployment.toggleAutoUpload", "Automatic Upload"), + act("deployment.browseRemoteHost", "Browse Remote Host"), + act("deployment.openInBrowser", "Open in Browser"), + act("deployment.configure", "Configuration…"), + ], + ), + SEP, + act("vim.toggle", "Vim"), + Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS), + ]; + if !mac { + tools.extend([ SEP, - act("db.showToolWindow", "Database"), - act("db.newDataSource", "New Data Source…"), - act("db.openConsole", "Open Query Console"), - SEP, - act("ssh.startSession", "Start SSH Session…"), - act("ssh.configurations", "SSH Configurations…"), - sub( - "deployment", - "Deployment", - vec![ - act("deployment.uploadToDefault", "Upload to Default Server"), - act("deployment.uploadTo", "Upload to…"), - act("deployment.downloadFromDefault", "Download from Default Server"), - act("deployment.downloadFrom", "Download from…"), - act("deployment.syncWith", "Sync with Deployed…"), - act("deployment.deleteFromServer", "Delete from Server"), - SEP, - act("deployment.toggleAutoUpload", "Automatic Upload"), - act("deployment.browseRemoteHost", "Browse Remote Host"), - act("deployment.openInBrowser", "Open in Browser"), - act("deployment.configure", "Configuration…"), - ], - ), - SEP, - act("vim.toggle", "Vim"), - Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS), - ], - )); + act("app.installCli", "Install 'openidea' Command"), + act("app.uninstallCli", "Uninstall 'openidea' Command"), + ]); + } + menus.push(sub("tools", "Tools", tools)); let mut window = Vec::new(); if mac { diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 82f39ef2b..c0ce87375 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -1152,19 +1152,43 @@ 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) { +pub(crate) fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) { let launch = Launch { diff: Some(Box::new(diff)), ..Default::default() }; let target = target_window(app) .filter(|window| auxiliary_parent(window.label()).is_none()) 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 1af8bf51b..b9ebfdcd4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -979,6 +979,10 @@ export const commands = { */ menuSetDisabled: (ids: string[]) => __TAURI_INVOKE("menu_set_disabled", { ids }), appLogLocation: () => typedError(__TAURI_INVOKE("app_log_location")), + /** Installs the `openidea` command for this user. */ + 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 })), /** @@ -2284,6 +2288,11 @@ export type ClearCachesResult = { rssAfterBytes: number | null, }; +export type CliInstallation = { + path: IpcPath, + pathDirectory: IpcPath | null, +}; + export type CloseContext = { /** Quit is in progress (exit was already confirmed). */ quitting: boolean, 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/__tests__/cliCommand.test.ts b/src/features/app/__tests__/cliCommand.test.ts new file mode 100644 index 000000000..190798066 --- /dev/null +++ b/src/features/app/__tests__/cliCommand.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { getContext } from "@/platform/context"; +import { provideNotificationService } from "@/workbench/contributions"; + +const { install, uninstall, notify } = vi.hoisted(() => ({ install: vi.fn(), uninstall: vi.fn(), notify: vi.fn() })); +vi.mock("@/platform/ipc", () => ({ + call: (result: Promise) => result, + commands: { appCliInstall: install, appCliUninstall: uninstall }, + isCancelled: (error: unknown) => (error as { kind?: string })?.kind === "cancelled", +})); + +const { cliActions } = await import("../cliCommand"); +const run = (id: string) => + cliActions() + .find((action) => action.id === id)! + .run({ ctx: getContext() }); + +beforeEach(() => { + vi.resetAllMocks(); + provideNotificationService({ notify, expire: () => {} }); +}); + +it("explains the Linux PATH step when the user bin is missing from PATH", async () => { + install.mockResolvedValue({ path: "/home/user/.local/bin/openidea", pathDirectory: "/home/user/.local/bin" }); + await run("app.installCli"); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: "info", + body: expect.stringContaining("Add /home/user/.local/bin to the beginning of PATH"), + }), + ); +}); + +it("tells users to open a new terminal after installation", async () => { + install.mockResolvedValue({ path: "C:/Users/user/OpenIDEA/bin/openidea.cmd", pathDirectory: null }); + await run("app.installCli"); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining("Open a new terminal") }), + ); +}); + +it("reports an absent command on repeated uninstall", async () => { + uninstall.mockResolvedValue([]); + await run("app.uninstallCli"); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "The openidea command is not installed" })); +}); + +it("reports an install failure and stays quiet when an administrator prompt is cancelled", async () => { + install.mockRejectedValueOnce(new Error("foreign command")); + await run("app.installCli"); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "foreign command" })); + notify.mockClear(); + install.mockRejectedValueOnce({ kind: "cancelled" }); + await run("app.installCli"); + expect(notify).not.toHaveBeenCalled(); +}); diff --git a/src/features/app/cliCommand.ts b/src/features/app/cliCommand.ts new file mode 100644 index 000000000..267e43305 --- /dev/null +++ b/src/features/app/cliCommand.ts @@ -0,0 +1,73 @@ +/** Install / Uninstall the `openidea` shell command. */ +import type { Action } from "@/platform/actions"; +import { tx } 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 result = await call(commands.appCliInstall()); + const hint = result.pathDirectory + ? tx`Add ${result.pathDirectory} to the beginning of PATH, then open a new terminal.` + : tx("Open a new terminal to use the openidea command."); + notifications().notify({ + level: "info", + title: tx("The openidea command is installed"), + body: `${result.path}\n${hint}`, + key: KEY, + }); + } catch (e) { + if (!isCancelled(e)) + notifications().notify({ + level: "error", + title: tx("Cannot install the openidea command"), + body: errorText(e), + key: KEY, + }); + } +} + +async function uninstallCli(): Promise { + try { + const links = await call(commands.appCliUninstall()); + notifications().notify({ + level: "info", + title: tx(links.length ? "The openidea command is uninstalled" : "The openidea command is not installed"), + body: links.join("\n") || undefined, + key: KEY, + }); + } catch (e) { + if (!isCancelled(e)) + notifications().notify({ + level: "error", + title: tx("Cannot uninstall the openidea command"), + body: errorText(e), + key: KEY, + }); + } +} + +export function cliActions(): Action[] { + return [ + { + id: "app.installCli", + title: tx("Install 'openidea' Command"), + category: tx("Tools"), + keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"], + run: installCli, + }, + { + id: "app.uninstallCli", + title: tx("Uninstall 'openidea' Command"), + category: tx("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..b70867fb3 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -6,6 +6,7 @@ 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"; @@ -31,6 +32,7 @@ export function activate(): () => void { const disposers = [ installLocaleSync(), registerLocalizedActions(() => [...appActions(), ...windowActions()]), + registerLocalizedActions(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 77cee26a7..427911bc1 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -100,7 +100,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/text/en.json b/src/platform/locales/text/en.json index ea901b4a0..e56ac68b0 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -130,6 +130,15 @@ "WAR Archive": "WAR Archive", "WAR (exploded)": "WAR (exploded)", "No project is currently open.": "No project is currently open.", + "Install 'openidea' Command": "Install 'openidea' Command", + "Uninstall 'openidea' Command": "Uninstall 'openidea' Command", + "The openidea command is installed": "The openidea command is installed", + "The openidea command is uninstalled": "The openidea command is uninstalled", + "The openidea command is not installed": "The openidea command is not installed", + "Cannot install the openidea command": "Cannot install the openidea command", + "Cannot uninstall the openidea command": "Cannot uninstall the openidea command", + "Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.", + "Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.", "Default SDK used by modules, builds, and run configurations unless overridden at the module level.": "Default SDK used by modules, builds, and run configurations unless overridden at the module level.", "Project SDK": "Project SDK", "Default Java language level for source code assistance and compilation across project modules.": "Default Java language level for source code assistance and compilation across project modules.", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d35a04638..967fca9b6 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1715,6 +1715,15 @@ "WAR Archive": "WAR 归档", "WAR (exploded)": "WAR(展开)", "No project is currently open.": "当前未打开任何项目。", + "Install 'openidea' Command": "安装 'openidea' 命令", + "Uninstall 'openidea' Command": "卸载 'openidea' 命令", + "The openidea command is installed": "openidea 命令已安装", + "The openidea command is uninstalled": "openidea 命令已卸载", + "The openidea command is not installed": "openidea 命令未安装", + "Cannot install the openidea command": "无法安装 openidea 命令", + "Cannot uninstall the openidea command": "无法卸载 openidea 命令", + "Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。", + "Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。", "Default SDK used by modules, builds, and run configurations unless overridden at the module level.": "模块、构建和运行配置默认使用的 SDK(除非在模块级别覆盖)。", "Project SDK": "项目 SDK", "Default Java language level for source code assistance and compilation across project modules.": "项目各模块用于源码辅助和编译的默认 Java 语言级别。", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 97c7e6649..3f120c953 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -100,7 +100,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": "使用块注释",