Lot B2 du chantier server/client mode : le cœur backend émet désormais ses flux via une abstraction de sink agnostique, sans dépendre directement de tauri::ipc::Channel, préalable au futur serveur web + PTY WebSocket. - crates/backend : abstraction de sink (stream.rs) câblée dans lib.rs. - crates/app-tauri : implémentation Tauri du sink (stream.rs) et adaptation des surfaces lib.rs, pty.rs, chat.rs. - Nettoyage clippy des 2 warnings B2. Validé : cargo check --workspace vert, tests backend/app-tauri verts, cœur agnostique Tauri, clippy B2 propre. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
568 lines
21 KiB
Rust
568 lines
21 KiB
Rust
//! # 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 stream;
|
|
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 `<exe> 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<String> = 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
|
|
// (`<app-data>/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::<AppState>() {
|
|
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<PersistedWindowState> {
|
|
handle
|
|
.webview_windows()
|
|
.into_iter()
|
|
.filter_map(|(label, window)| snapshot_webview_window(&label, &window))
|
|
.collect()
|
|
}
|
|
|
|
fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option<PersistedWindowState> {
|
|
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<String>,
|
|
Option<ProjectId>,
|
|
Option<String>,
|
|
)> {
|
|
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<commands::ViewPanel> {
|
|
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::<AppState>() 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());
|
|
}
|
|
}
|