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 <[email protected]>
Цей коміт міститься в:
l0ng-aiтаjaw52 authored and GitHub committed 2026-11-08 19:25:50 +08:00
1 джерело 25424465c0
коміт 0bfd4fa3cc
28 files changed
+1601 -42

No files matched your search

+169
Переглянути файл
@@ -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<i32> {
let mut args = std::env::args().skip(1);
if args.next().as_deref() != Some(CLI_FLAG) {
return None;
}
let args: Vec<String> = 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<Response, String> {
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<PathBuf> {
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<crate::commands::window::CliInstallation> {
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<Vec<IpcPath>> {
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);
}
}
+2
Переглянути файл
@@ -445,6 +445,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+21
Переглянути файл
@@ -313,6 +313,27 @@ pub fn app_log_location() -> IdeResult<LogLocation> {
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<ide_core::IpcPath>,
}
/// Installs the `openidea` command for this user.
#[tauri::command]
#[specta::specta]
pub async fn app_cli_install() -> IdeResult<CliInstallation> {
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<Vec<ide_core::IpcPath>> {
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 {
+6
Переглянути файл
@@ -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);
}
+41 -34
Переглянути файл
@@ -191,6 +191,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
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<Entry> {
],
));
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 {
+32 -8
Переглянути файл
@@ -1152,19 +1152,43 @@ pub fn on_second_instance(app: &AppHandle, args: Vec<String>, cwd: String) {
remember_cli_position(&app.state::<WindowsState>(), &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<ide_cli::Target>) {
if targets.is_empty() {
return focus_any(app);
}
let windows = app.state::<WindowsState>();
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())