mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
A portable Windows build (the raw exe from the portable ZIP, which has no bundle type) updated through the installer, which installed the app elsewhere instead of updating the folder it was unpacked in. - Updater: an unbundled Windows build asks for the `windows-<arch>-portable` target. Its update is the signed portable ZIP (verified by the updater's download), unpacked into a staging folder next to the exe; only `openidea.exe` and `WebView2Loader.dll` are accepted. Each file is replaced with a backup, rolled back if any replacement fails, and the backups are removed on the next start. - Release: the portable ZIP is signed with the updater key and `latest.json` lists `windows-x86_64-portable` and `windows-aarch64-portable`. Co-authored-by: caojcm <43193197+caojcm@users.noreply.github.com>
402 lines
17 KiB
Rust
402 lines
17 KiB
Rust
//! OpenIDEA desktop shell: wires the backend crates into Tauri.
|
||
//!
|
||
//! This crate is glue only. Each `commands/<domain>.rs` is a thin, typed
|
||
//! adapter from IPC to a backend crate; no domain logic lives here.
|
||
|
||
mod application_focus;
|
||
mod cli;
|
||
mod commands;
|
||
#[cfg(debug_assertions)]
|
||
mod dev;
|
||
mod events;
|
||
mod jump_list;
|
||
#[cfg(any(target_os = "linux", test))]
|
||
mod linux_webkit;
|
||
#[cfg(any(target_os = "linux", test))]
|
||
mod linux_window_state;
|
||
mod logging;
|
||
#[cfg(target_os = "macos")]
|
||
mod macos;
|
||
mod menu;
|
||
#[cfg(target_os = "macos")]
|
||
mod page_swipe;
|
||
mod plugin_web;
|
||
#[cfg(target_os = "macos")]
|
||
mod relaunch;
|
||
mod remote_container;
|
||
mod remote_dev;
|
||
mod remote_wsl;
|
||
|
||
mod state;
|
||
mod theme_frame;
|
||
#[cfg(target_os = "macos")]
|
||
mod titlebar;
|
||
mod updates;
|
||
mod window_groups;
|
||
#[cfg(any(windows, test))]
|
||
mod window_work_area;
|
||
mod window_zoom;
|
||
mod windows;
|
||
#[cfg(windows)]
|
||
mod windows_error_mode;
|
||
|
||
use parking_lot::Mutex;
|
||
use state::AppState;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
use tauri::{Emitter, Manager};
|
||
use windows::WindowsState;
|
||
|
||
/// Builds the tauri-specta builder holding every command and exported type.
|
||
/// Also used by `export_bindings` (tests) to regenerate `src/bindings.ts`.
|
||
pub fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||
commands::builder().typ::<ide_core::IdeEvent>().dangerously_cast_bigints_to_number()
|
||
}
|
||
|
||
pub fn export_bindings(builder: &tauri_specta::Builder<tauri::Wry>) {
|
||
let target = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"));
|
||
// Rewritten only when it changes: every debug launch exports, also a second instance that just
|
||
// hands its path over, and a rewrite makes Vite reload the running app's windows.
|
||
let fresh = std::env::temp_dir().join(format!("openidea-bindings-{}.ts", std::process::id()));
|
||
builder
|
||
.export(
|
||
specta_typescript::Typescript::default().header("// @ts-nocheck\n/* eslint-disable */\n// Generated by tauri-specta. Do not edit; run `pnpm gen:bindings`."),
|
||
&fresh,
|
||
)
|
||
.expect("failed to export TypeScript bindings");
|
||
let text = std::fs::read(&fresh).expect("exported TypeScript bindings");
|
||
let _ = std::fs::remove_file(&fresh);
|
||
if std::fs::read(target).ok().as_deref() != Some(text.as_slice()) {
|
||
std::fs::write(target, text).expect("failed to write TypeScript bindings");
|
||
}
|
||
}
|
||
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
ide_workspace::process::mark_start();
|
||
#[cfg(windows)]
|
||
windows_error_mode::quiet_error_dialogs();
|
||
let context = tauri::generate_context!();
|
||
#[cfg(debug_assertions)]
|
||
let context = dev::configure(context);
|
||
if let Some(project) = ide_mcp::stdio_request(&std::env::args().collect::<Vec<_>>()) {
|
||
let app_data = ide_host::data_dirs::data_dir(&context.config().identifier).unwrap_or_default();
|
||
std::process::exit(ide_mcp::stdio::run(&ide_mcp::discovery_path(&app_data), project));
|
||
}
|
||
// 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);
|
||
#[cfg(target_os = "linux")]
|
||
linux_webkit::configure();
|
||
|
||
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
|
||
// A custom directory from Settings › System Settings › Caches applies here too
|
||
// (`OPENIDEA_LOCAL_DATA_DIR` still wins), so tools resolve against the new root.
|
||
let local_data_dir = ide_host::data_dirs::startup_local_data_dir(&context.config().identifier);
|
||
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
|
||
match &local_data_dir {
|
||
Ok(data) => {
|
||
ide_tools::extend_process_path(data);
|
||
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(data));
|
||
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(data));
|
||
}
|
||
Err(error) => tracing::error!("{error}"),
|
||
}
|
||
if let Some(refresh) = refresh_shell_path {
|
||
refresh();
|
||
}
|
||
|
||
let builder = specta_builder();
|
||
#[cfg(debug_assertions)]
|
||
export_bindings(&builder);
|
||
|
||
let app = tauri::Builder::default().manage(WindowsState::default());
|
||
// Single instance first: a second launch focuses this one and opens its path argument.
|
||
// Debug builds have a separate identifier per worktree.
|
||
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
|
||
// Install before window-state restores maximized geometry during window creation.
|
||
#[cfg(windows)]
|
||
let app = app.plugin(window_work_area::init());
|
||
let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init());
|
||
#[cfg(target_os = "linux")]
|
||
let app = app.plugin(linux_window_state::plugin());
|
||
#[cfg(not(target_os = "linux"))]
|
||
let window_state = tauri_plugin_window_state::Builder::default()
|
||
.with_state_flags(window_state_flags())
|
||
// One remembered geometry per kind of auxiliary window (all projects' settings windows share one).
|
||
.map_label(windows::state_key);
|
||
// The plugin stores its file in the OS configuration directory; an absolute name follows
|
||
// `OPENIDEA_CONFIG_DIR` instead.
|
||
#[cfg(not(target_os = "linux"))]
|
||
let window_state = match ide_host::data_dirs::config_dir(&context.config().identifier) {
|
||
Ok(dir) => window_state.with_filename(dir.join(tauri_plugin_window_state::DEFAULT_FILENAME).to_string_lossy()),
|
||
Err(_) => window_state,
|
||
};
|
||
#[cfg(target_os = "macos")]
|
||
let window_state = window_state.with_filter(windows::keeps_window_state);
|
||
#[cfg(not(target_os = "linux"))]
|
||
let app = app.plugin(window_state.build());
|
||
let app = app
|
||
// Inert until an endpoint is configured (see `commands::window::app_check_update`).
|
||
.plugin(tauri_plugin_updater::Builder::new().build());
|
||
// Files of plugins' web views, for their sandboxed frames.
|
||
let app = app.register_asynchronous_uri_scheme_protocol(plugin_web::SCHEME, plugin_web::handle);
|
||
// E2E automation bridge (Tauri MCP), debug builds only, localhost only.
|
||
#[cfg(debug_assertions)]
|
||
let app = app.plugin(tauri_plugin_mcp_bridge::Builder::new().bind_address("127.0.0.1").build());
|
||
|
||
// Paths macOS asked to open (Finder's Open With, the Dock) before setup ran: the first window takes them.
|
||
let early_opens: Arc<Mutex<Option<Vec<PathBuf>>>> = Arc::default();
|
||
let setup_opens = early_opens.clone();
|
||
let typed_invoke = builder.invoke_handler();
|
||
// Raw Response is not a JSON/Specta type; keep the binary command out of generated bindings.
|
||
let raw_invoke: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![commands::fonts::user_font_data];
|
||
let invoke = Arc::new(move |request: tauri::ipc::Invoke| {
|
||
if request.message.command() == "user_font_data" { raw_invoke(request) } else { typed_invoke(request) }
|
||
});
|
||
app.invoke_handler(move |request| {
|
||
let webview = request.message.webview_ref();
|
||
if let Some(state) = webview.try_state::<AppState>() {
|
||
let label = AppState::owner_window(webview.label());
|
||
let command = request.message.command();
|
||
let authorized = match state.remote_dev.get(label) {
|
||
Some(connection) => connection.authorize_command(&state.host.trust, command),
|
||
None => state.host.authorize_command(webview.label(), command),
|
||
};
|
||
if let Err(error) = authorized {
|
||
request.resolver.reject(error);
|
||
return true;
|
||
}
|
||
}
|
||
invoke(request)
|
||
})
|
||
.setup(move |app| {
|
||
builder.mount_events(app);
|
||
#[cfg(windows)]
|
||
tauri::async_runtime::spawn_blocking(|| {
|
||
if let Ok(exe) = tauri::utils::platform::current_exe() {
|
||
ide_tools::portable_update::cleanup(&exe);
|
||
}
|
||
});
|
||
// An unusable directory variable stops the start with a message, never a silent fallback.
|
||
let identifier = app.config().identifier.clone();
|
||
let local_data_dir = local_data_dir.and_then(|dir| {
|
||
ide_host::data_dirs::config_dir(&identifier)?;
|
||
ide_host::data_dirs::data_dir(&identifier)?;
|
||
Ok(dir)
|
||
});
|
||
let local_data_dir = match local_data_dir {
|
||
Ok(dir) => dir,
|
||
Err(error) => {
|
||
use tauri_plugin_dialog::DialogExt;
|
||
let handle = app.handle().clone();
|
||
app.dialog()
|
||
.message(error)
|
||
.title("OpenIDEA")
|
||
.kind(tauri_plugin_dialog::MessageDialogKind::Error)
|
||
.show(move |_| handle.exit(1));
|
||
return Ok(());
|
||
}
|
||
};
|
||
let state = AppState::new(app.handle().clone(), local_data_dir)?;
|
||
let windows = app.state::<WindowsState>();
|
||
if let Ok(cwd) = std::env::current_dir() {
|
||
match windows::cli_launch(&std::env::args().collect::<Vec<_>>(), &cwd) {
|
||
Ok(Some(launch)) => windows.set_launch("main", launch),
|
||
Ok(None) => {}
|
||
Err(error) => windows::report_cli_error(app.handle(), error),
|
||
}
|
||
}
|
||
let menu_state = menu::MenuState::default();
|
||
menu::install(app.handle(), &menu_state, &state.recent.list());
|
||
app.manage(state);
|
||
commands::mcp::apply(&app.state::<AppState>());
|
||
#[cfg(windows)]
|
||
if let Err(error) = windows::allow_external_launches(app.handle()) {
|
||
windows::report_cli_error(app.handle(), error);
|
||
}
|
||
app.manage(application_focus::ApplicationFocus::default());
|
||
#[cfg(target_os = "macos")]
|
||
macos::intercept_system_quit(app.handle());
|
||
#[cfg(target_os = "macos")]
|
||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||
macos::prepare_window_tabbing(mtm);
|
||
}
|
||
#[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);
|
||
}
|
||
app.on_menu_event(on_menu_event);
|
||
windows::create_main_window(app.handle())?;
|
||
windows::ready_for_cli(app.handle());
|
||
jump_list::refresh(app.handle(), app.state::<AppState>().recent.list());
|
||
Ok(())
|
||
})
|
||
.on_window_event(|window, event| match event {
|
||
tauri::WindowEvent::CloseRequested { .. } => {
|
||
#[cfg(target_os = "linux")]
|
||
linux_window_state::save_before_close(window.app_handle());
|
||
if let Some(w) = window.try_state::<WindowsState>() {
|
||
w.close_requested(window.label());
|
||
}
|
||
}
|
||
tauri::WindowEvent::Focused(true) => {
|
||
application_focus::changed(window.app_handle(), true);
|
||
if let Some(w) = window.try_state::<WindowsState>() {
|
||
w.focused(window.label());
|
||
}
|
||
}
|
||
tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false),
|
||
tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_) => {
|
||
window_groups::frame_changed(window.app_handle(), window.label());
|
||
}
|
||
tauri::WindowEvent::Destroyed => {
|
||
window_zoom::forget(window.label());
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
page_swipe::forget(window.label());
|
||
titlebar::forget(window);
|
||
}
|
||
if windows::auxiliary_parent(window.label()).is_none() {
|
||
window_groups::destroyed(window.app_handle(), window.label());
|
||
windows::close_auxiliary_windows(window.app_handle(), window.label());
|
||
if let Some(state) = window.try_state::<AppState>() {
|
||
#[cfg(target_os = "macos")]
|
||
if let Some(w) = window.try_state::<WindowsState>() {
|
||
let settings = ide_host::ops::project::settings(&state.host, window.label());
|
||
w.keep_alive.store(settings.general.close_without_welcome, std::sync::atomic::Ordering::SeqCst);
|
||
}
|
||
state.updates.forget_window(window.label());
|
||
state.close_project(window.label());
|
||
state.plugins.forget_client(window.label());
|
||
}
|
||
}
|
||
if let Some(w) = window.try_state::<WindowsState>() {
|
||
w.forget(window.label());
|
||
}
|
||
if windows::auxiliary_parent(window.label()).is_none() {
|
||
windows::continue_quit(window.app_handle(), Some(window.label()));
|
||
}
|
||
}
|
||
_ => {}
|
||
})
|
||
.build(context)
|
||
.expect("error while running OpenIDEA")
|
||
.run(move |app, event| {
|
||
#[cfg(target_os = "macos")]
|
||
match &event {
|
||
tauri::RunEvent::ExitRequested { code: None, api, .. }
|
||
if app.state::<WindowsState>().keep_alive.load(std::sync::atomic::Ordering::SeqCst)
|
||
&& !app.state::<WindowsState>().quitting() =>
|
||
{
|
||
api.prevent_exit();
|
||
}
|
||
tauri::RunEvent::Reopen { has_visible_windows: false, .. }
|
||
if windows::project_window_labels(app).is_empty() =>
|
||
{
|
||
windows::spawn_window(app, None);
|
||
}
|
||
_ => {}
|
||
}
|
||
#[cfg(target_os = "macos")]
|
||
if let tauri::RunEvent::Opened { urls } = &event {
|
||
let paths = windows::opened_paths(urls);
|
||
if app.try_state::<AppState>().is_some() {
|
||
windows::open_paths(app, paths);
|
||
} else {
|
||
early_opens.lock().get_or_insert_default().extend(paths);
|
||
}
|
||
}
|
||
#[cfg(not(target_os = "macos"))]
|
||
let _ = &early_opens;
|
||
if let tauri::RunEvent::Exit = event {
|
||
if let Some(state) = app.try_state::<AppState>() {
|
||
state.host.stop_processes_on_exit();
|
||
commands::mcp::stop(&state);
|
||
state.host.clear_workspace_edit_history_on_exit();
|
||
state.host.shutdown_notebooks_on_exit();
|
||
}
|
||
// Cleanup must finish before update/restart replaces this process.
|
||
if app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested()) {
|
||
#[cfg(target_os = "macos")]
|
||
relaunch::restart(&app.env());
|
||
#[cfg(not(target_os = "macos"))]
|
||
tauri::process::restart(&app.env());
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
|
||
use tauri_plugin_window_state::StateFlags;
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
// Start at the configured size, centered, instead of restoring geometry after the
|
||
// window is visible (Windows briefly showed the default position before that move).
|
||
StateFlags::MAXIMIZED | StateFlags::FULLSCREEN
|
||
}
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN
|
||
}
|
||
}
|
||
|
||
/// Routes IDE menu items to the focused window's frontend (`executeAction`).
|
||
fn on_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) {
|
||
let id = event.id().as_ref();
|
||
menu::restore_check(app, &app.state::<menu::MenuState>(), id);
|
||
// A keymap accelerator only draws the shortcut: its key reached the menu because the keymap declined it.
|
||
#[cfg(target_os = "macos")]
|
||
if !menu::fires_natively(&menu::spec(menu::Os::Mac), id)
|
||
&& (macos::handling_key_press() || macos::native_modal_open())
|
||
{
|
||
return;
|
||
}
|
||
dispatch_menu_action(app, id);
|
||
}
|
||
|
||
/// Runs menu item `id` as if it were chosen: in the focused window's frontend, or here without one.
|
||
pub(crate) fn dispatch_menu_action(app: &tauri::AppHandle, id: &str) {
|
||
let Some(route) = menu::route(&menu::spec(menu::Os::current()), id) else { return };
|
||
match windows::target_window(app) {
|
||
Some(w) => {
|
||
// App-level items of an auxiliary window go to its project window. Settings is modal for it:
|
||
// nothing else gets through. Others handle their own items; Close Tab closes the window.
|
||
let label = match windows::auxiliary(w.label()) {
|
||
Some((_, parent))
|
||
if matches!(
|
||
route.action.as_str(),
|
||
"app.quit" | "file.settings" | "file.projectStructure" | "file.newProjectsSettings"
|
||
) =>
|
||
{
|
||
parent
|
||
}
|
||
Some((kind, _)) if kind.modal() => return,
|
||
Some(_) if route.action == "editor.closeTab" => {
|
||
let _ = w.close();
|
||
return;
|
||
}
|
||
_ => w.label(),
|
||
};
|
||
if let Err(e) = app.emit_to(label, menu::MENU_EVENT, &route) {
|
||
tracing::warn!("failed to deliver menu event {id}: {e}");
|
||
}
|
||
}
|
||
// No window left (macOS keeps the menu bar): handle the few that make sense.
|
||
None => match route.action.as_str() {
|
||
"app.quit" => app.exit(0),
|
||
"window.new" | "file.openProject" => windows::spawn_window(app, None),
|
||
menu::OPEN_RECENT_ACTION => windows::spawn_window(app, route.arg.map(Into::into)),
|
||
_ => {}
|
||
},
|
||
}
|
||
}
|