//! # IdeA — `app-tauri` (presentation / driving adapter + composition root) //! //! This crate is the **only** place that knows every other crate. It: //! - builds the concrete adapters and injects them into use cases //! ([`state::AppState`], the composition root), //! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases, //! - relays domain events to the frontend ([`events::TauriEventRelay`]), //! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3 and its //! structured-chat twin ([`chat::ChatBridge`]) for §17. //! //! The wiring lives in the library (testable) and `main.rs` is a thin shim. #![forbid(unsafe_code)] #![warn(missing_docs)] pub mod chat; pub mod commands; pub mod dto; pub mod events; pub mod mcp_bridge; pub mod mcp_endpoint; pub mod openai_tools; pub mod pty; pub mod state; pub mod tickets; use std::process::ExitCode; use application::SnapshotOpenWindowsInput; use domain::{ PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, ProjectId, }; use tauri::{ Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; use uuid::Uuid; use state::AppState; /// The `argv[1]` subcommand token that switches the binary into the headless /// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri. pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server"; /// Process entry point: routes `argv` **before** anything Tauri/WebKit is touched. /// /// When invoked as ` mcp-server …` (an MCP CLI spawned us from the injected /// `.mcp.json` declaration), we run the **stdio↔loopback bridge** headless and /// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. Any other /// invocation is the normal IDE launch: [`run`] (which blocks until the window /// closes and then exits the process itself). /// /// Returns the [`ExitCode`] for the bridge path; the normal path does not return. #[must_use] pub fn dispatch() -> ExitCode { let mut args = std::env::args_os().skip(1); if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) { // Headless bridge: bypass Tauri entirely. Forward the remaining args // (`--endpoint`, `--project`, `--requester`) to the bridge parser. let rest: Vec = args.map(|a| a.to_string_lossy().into_owned()).collect(); return mcp_bridge::run_mcp_bridge(rest); } run(); ExitCode::SUCCESS } /// Builds and runs the Tauri application. /// /// Sets up the composition root (resolving the app-data directory via the Tauri /// path API), registers commands, spawns the event relay, and starts the main /// window. /// /// # Panics /// Panics if the Tauri application fails to build or run (no window/webview), or /// if the app-data directory cannot be resolved. pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .setup(|app| { // Resolve the machine-local IDE data directory (ARCHITECTURE §9.2) // and build the composition root once the app handle exists, so the // stores receive a concrete path without ever touching Tauri. let app_data_dir = app .path() .app_data_dir() .expect("failed to resolve the app data directory"); // Point the orchestrator's best-effort diagnostics at a persistent file // (`/logs/idea.log`) so inter-agent rendezvous beacons survive a // click-launched AppImage (whose stderr is otherwise discarded). Best-effort: // if the file can't be opened the beacons simply stay on stderr. application::diag::set_log_path(app_data_dir.join("logs").join("idea.log")); application::diag!("[startup] IdeA launched; diagnostics log armed"); let app_state = AppState::build(app_data_dir); // Wire the domain event bus → Tauri events relay. events::spawn_relay(app.handle().clone(), &app_state.event_bus); app.manage(app_state); // Kill all live PTYs cleanly when the main window is closing. This is // independent of the per-view (navigation/layout) lifecycle — those // must NEVER kill a PTY — and only fires on a genuine app shutdown. // A brutal crash is best-effort and out of scope. if let Some(window) = app.get_webview_window("main") { let handle = app.handle().clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { .. } = event { if let Some(state) = handle.try_state::() { let open_windows = snapshot_open_webview_windows(&handle); let window_snapshot = std::sync::Arc::clone(&state.snapshot_open_windows); let pty = std::sync::Arc::clone(&state.pty_port); // ORDER IS CRITICAL: freeze `agent_was_running` on every // agent leaf of every open project FIRST, reading the live // PTY registry as it stands now; only THEN kill the PTYs. // If we killed first, the registry would be empty and every // agent would be persisted as "closed". let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); let model_servers = std::sync::Arc::clone(&state.ensure_local_model_server); let open_projects = state.open_project_ids(); let handles = state.terminal_sessions.handles(); tauri::async_runtime::block_on(async move { let _ = window_snapshot .execute(SnapshotOpenWindowsInput { windows: open_windows, }) .await; for project_id in open_projects { let _ = snapshot .execute(application::SnapshotRunningAgentsInput { project_id, }) .await; } for h in handles { let _ = pty.kill(&h).await; } let _ = model_servers.stop_on_app_exit().await; }); } close_non_main_webview_windows(&handle); } }); } restore_open_webview_windows(app.handle()); Ok(()) }) .invoke_handler(tauri::generate_handler![ commands::health, commands::create_project, commands::open_project, commands::close_project, commands::list_projects, commands::read_project_context, commands::update_project_context, commands::get_project_permissions, commands::update_project_permissions, commands::update_agent_permissions, commands::resolve_agent_permissions, commands::open_terminal, commands::write_terminal, commands::resize_terminal, commands::close_terminal, commands::reattach_terminal, commands::load_layout, commands::mutate_layout, commands::list_layouts, commands::create_layout, commands::rename_layout, commands::delete_layout, commands::set_active_layout, commands::first_run_state, commands::reference_profiles, commands::detect_profiles, commands::list_profiles, commands::save_profile, commands::clone_opencode_profile_from_seed, commands::delete_profile, commands::configure_profiles, commands::list_model_servers, commands::save_model_server, commands::preview_model_server_command, commands::delete_model_server, commands::list_embedder_profiles, commands::save_embedder_profile, commands::delete_embedder_profile, commands::describe_embedder_engines, commands::dismiss_embedder_suggestion, commands::create_agent, commands::list_agents, tickets::ticket_create, tickets::ticket_read, tickets::ticket_delete, tickets::open_ticket_chat, tickets::close_ticket_chat, tickets::ticket_list, tickets::ticket_update, tickets::ticket_read_carnet, tickets::ticket_update_carnet, tickets::ticket_link, tickets::ticket_unlink, tickets::ticket_assign, tickets::sprint_create, tickets::sprint_list, tickets::sprint_rename, tickets::sprint_reorder, tickets::sprint_delete, tickets::ticket_assign_sprint, tickets::ticket_unassign_sprint, commands::get_project_work_state, commands::read_conversation_page, commands::list_live_agents, commands::attach_live_agent, commands::stop_live_agent, commands::read_agent_context, commands::update_agent_context, commands::delete_agent, commands::launch_agent, commands::change_agent_profile, commands::agent_send, commands::cancel_resume, commands::set_resume_at, commands::interrupt_agent, commands::delegation_delivered, commands::set_front_attached, commands::reattach_agent_chat, commands::close_agent_session, commands::list_resumable_agents, commands::inspect_conversation, commands::create_template, commands::update_template, commands::list_templates, commands::delete_template, commands::create_agent_from_template, commands::detect_agent_drift, commands::sync_agent_with_template, commands::git_status, commands::git_stage, commands::git_unstage, commands::git_commit, commands::git_branches, commands::git_checkout, commands::git_log, commands::git_init, commands::git_graph, commands::create_skill, commands::update_skill, commands::list_skills, commands::delete_skill, commands::assign_skill_to_agent, commands::unassign_skill_from_agent, commands::create_memory, commands::update_memory, commands::list_memories, commands::get_memory, commands::delete_memory, commands::read_memory_index, commands::recall_memory, commands::resolve_memory_links, commands::set_focused_project, commands::get_focused_project, commands::list_open_view_windows, commands::open_view_window, commands::close_view_window, commands::move_tab_to_new_window, commands::spawn_background_command, commands::cancel_background_task, commands::retry_background_task, commands::list_background_tasks, ]) .run(tauri::generate_context!()) .expect("error while running IdeA Tauri application"); } fn close_non_main_webview_windows(handle: &tauri::AppHandle) { for (label, window) in handle.webview_windows() { if !should_close_with_main_window(&label) { continue; } let _ = window.close(); } } fn should_close_with_main_window(label: &str) -> bool { label != "main" } fn snapshot_open_webview_windows(handle: &tauri::AppHandle) -> Vec { handle .webview_windows() .into_iter() .filter_map(|(label, window)| snapshot_webview_window(&label, &window)) .collect() } fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option { let (kind, panel, project_id, url) = persisted_window_identity(label)?; let outer_position = window .outer_position() .ok() .map(|p| PersistedWindowPosition { x: p.x, y: p.y }); let outer_size = window.outer_size().ok().map(|s| PersistedWindowSize { width: s.width, height: s.height, }); let monitor = window.current_monitor().ok().flatten().map(|m| { let position = m.position(); let size = m.size(); PersistedMonitorState { name: m.name().cloned(), scale_factor: Some(m.scale_factor()), position: Some(PersistedWindowPosition { x: position.x, y: position.y, }), size: Some(PersistedWindowSize { width: size.width, height: size.height, }), } }); Some(PersistedWindowState { label: label.to_owned(), kind, panel, project_id, url, visible: window.is_visible().unwrap_or(true), maximized: window.is_maximized().unwrap_or(false), fullscreen: window.is_fullscreen().unwrap_or(false), outer_position, outer_size, monitor, last_focused_at: None, }) } fn persisted_window_identity( label: &str, ) -> Option<( PersistedWindowKind, Option, Option, Option, )> { if label == "main" { return Some((PersistedWindowKind::Main, None, None, None)); } let panel = persisted_view_identity_from_label(label)?; Some(( PersistedWindowKind::View, Some(panel.as_str().to_owned()), None, Some(commands::view_window_url(panel)), )) } fn persisted_view_identity_from_label(label: &str) -> Option { let rest = label.strip_prefix("view-")?; if let Ok(panel) = commands::ViewPanel::parse(rest) { return Some(panel); } let (panel, project_id) = rest.rsplit_once('-')?; if project_id.len() != 32 || Uuid::parse_str(project_id).is_err() { return None; } commands::ViewPanel::parse(panel).ok() } fn restore_open_webview_windows(handle: &tauri::AppHandle) { let Some(state) = handle.try_state::() else { return; }; let restore = std::sync::Arc::clone(&state.restore_open_windows); let Ok(output) = tauri::async_runtime::block_on(async move { restore.execute().await }) else { return; }; for window_state in output.windows { match window_state.kind { PersistedWindowKind::Main => { if let Some(window) = handle.get_webview_window("main") { apply_persisted_window_state(handle, &window, &window_state); } } PersistedWindowKind::View => { restore_view_window(handle, &window_state); } } } } fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) { let Some(panel) = state .panel .as_deref() .and_then(|raw| commands::ViewPanel::parse(raw).ok()) .or_else(|| persisted_view_identity_from_label(&state.label)) else { return; }; let label = commands::view_window_label(panel); if handle.get_webview_window(&label).is_some() { return; } let url = commands::view_window_url(panel); let Ok(window) = WebviewWindowBuilder::new(handle, &label, WebviewUrl::App(url.into())) .title(format!("IdeA - {}", panel.title())) .inner_size(1120.0, 760.0) .min_inner_size(720.0, 480.0) .resizable(true) .maximizable(true) .minimizable(true) .closable(true) .decorations(true) .visible(state.visible) .build() else { return; }; let event_app = handle.clone(); let event_label = label.clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { .. } = event { commands::emit_view_window_lifecycle(&event_app, "closed", panel, &event_label); } }); apply_persisted_window_state(handle, &window, state); commands::emit_view_window_lifecycle(handle, "opened", panel, &label); } fn apply_persisted_window_state( handle: &tauri::AppHandle, window: &WebviewWindow, state: &PersistedWindowState, ) { if persisted_monitor_is_available(handle, state.monitor.as_ref()) { if let Some(size) = state.outer_size { let _ = window.set_size(PhysicalSize::new(size.width, size.height)); } if let Some(position) = state.outer_position { let _ = window.set_position(PhysicalPosition::new(position.x, position.y)); } } else { let _ = window.center(); } if state.fullscreen { let _ = window.set_fullscreen(true); } else if state.maximized { let _ = window.maximize(); } if state.visible { let _ = window.show(); } else { let _ = window.hide(); } } fn persisted_monitor_is_available( handle: &tauri::AppHandle, monitor: Option<&PersistedMonitorState>, ) -> bool { let Some(saved) = monitor else { return true; }; let Ok(monitors) = handle.available_monitors() else { return false; }; monitors.into_iter().any(|current| { if let Some(saved_name) = saved.name.as_deref() { if current.name().is_some_and(|name| name == saved_name) { return true; } } let position_matches = saved.position.is_some_and(|p| { let current_position = current.position(); p.x == current_position.x && p.y == current_position.y }); let size_matches = saved.size.is_some_and(|s| { let current_size = current.size(); s.width == current_size.width && s.height == current_size.height }); position_matches && size_matches }) } #[cfg(test)] mod tests { use super::{persisted_view_identity_from_label, persisted_window_identity}; use super::{should_close_with_main_window, PersistedWindowKind}; #[test] fn main_window_close_does_not_target_main_again() { assert!(!should_close_with_main_window("main")); } #[test] fn main_window_close_targets_detached_view_windows() { assert!(should_close_with_main_window( "view-work-00000000-0000-0000-0000-000000000001" )); } #[test] fn main_window_close_targets_other_auxiliary_windows() { assert!(should_close_with_main_window("settings")); } #[test] fn persisted_identity_accepts_main_and_stable_view_labels() { let (kind, panel, project_id, url) = persisted_window_identity("main").unwrap(); assert_eq!(kind, PersistedWindowKind::Main); assert!(panel.is_none()); assert!(project_id.is_none()); assert!(url.is_none()); let (kind, panel, project_id, url) = persisted_window_identity("view-tickets-0000000000000000000000000000002a").unwrap(); assert_eq!(kind, PersistedWindowKind::View); assert_eq!(panel.as_deref(), Some("tickets")); assert!(project_id.is_none()); assert_eq!(url.as_deref(), Some("index.html?panel=tickets")); assert_eq!( persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a") .unwrap() .as_str(), "tickets" ); } #[test] fn persisted_identity_accepts_new_panel_only_view_labels() { let (kind, panel, project_id, url) = persisted_window_identity("view-agents").unwrap(); assert_eq!(kind, PersistedWindowKind::View); assert_eq!(panel.as_deref(), Some("agents")); assert!(project_id.is_none()); assert_eq!(url.as_deref(), Some("index.html?panel=agents")); } #[test] fn persisted_identity_filters_unknown_or_headless_labels() { assert!(persisted_window_identity("mcp-server").is_none()); assert!(persisted_window_identity("settings").is_none()); assert!( persisted_window_identity("view-unknown-0000000000000000000000000000002a").is_none() ); assert!(persisted_window_identity("view-tickets-not-a-project").is_none()); } }