дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
25424465c0
коміт
0bfd4fa3cc
28 files changed
+1601
-42
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Заблокувати користувача