Files
ai-control/src-tauri/src/app.rs
T
marcus hinz 9a99bca466 Modul-Split: Fachlogik nach domain/, OS-Code nach platform/, Tauri-Verdrahtung in app.rs/commands.rs
- lib.rs nur noch Prozessrollen-Dispatch + Panic-Tracer
- KWin-Script als KDE-Pendant zur GNOME-Extension (Popup ans Zeiger-
  Rechteck), wird von deb/rpm mit ausgeliefert
- SNI-Tray auf ksni (pure Rust), appindicator-Depends entfallen
2026-07-15 20:10:33 +02:00

302 lines
11 KiB
Rust

//! Tauri-Verdrahtung: Haupt-App (Tray, main-/popup-Fenster, Watcher) und
//! Terminal-Prozess. Das Popup ist auf allen OS dasselbe HTML-Fenster; nur
//! der Klick-Trigger kommt aus platform::init_tray (Anchor-Kontrakt).
use crate::domain::paths::Paths;
use crate::domain::pool::provision_pools_for_panel;
use crate::domain::project::terminal_config;
use crate::domain::watcher::spawn_session_watcher;
use crate::platform::Anchor;
use crate::{commands, terminal};
/// Sperrt das Auto-Hide des Popups bei Fokusverlust für einen Moment nach dem
/// Anzeigen (KDE-Erststart-Race). Als Tauri-State geführt.
#[cfg(target_os = "linux")]
pub(crate) struct PopupBlurGuard(pub(crate) std::sync::Arc<std::sync::atomic::AtomicBool>);
/// Popup an den Klick-Koordinaten platzieren, in den Monitor geklemmt.
#[cfg(target_os = "linux")]
fn position_popup(w: &tauri::WebviewWindow, x: i32, y: i32) {
use tauri::{PhysicalPosition, PhysicalSize};
let size = w.outer_size().unwrap_or_else(|_| PhysicalSize::new(320, 300));
let (mon_pos, mon_size) = w
.current_monitor()
.ok()
.flatten()
.map(|m| (*m.position(), *m.size()))
.unwrap_or((PhysicalPosition::new(0, 0), PhysicalSize::new(1920, 1080)));
// Rechte Kante an den Klick; oben/unten ergibt sich aus dem Klemmen an die
// Monitorkante (Panel oben -> unter dem Icon, Panel unten -> darüber).
let max_x = mon_pos.x + mon_size.width as i32 - size.width as i32;
let max_y = mon_pos.y + mon_size.height as i32 - size.height as i32;
let px = (x - size.width as i32).clamp(mon_pos.x, max_x.max(mon_pos.x));
let py = y.clamp(mon_pos.y, max_y.max(mon_pos.y));
let _ = w.set_position(PhysicalPosition::new(px, py));
}
/// Popup zeigen — ein Codepfad für alle OS, nur die Platzierung folgt dem
/// Anchor aus dem Tray-Trigger.
pub(crate) fn show_popup(app: &tauri::AppHandle, anchor: Anchor) {
use tauri::Manager;
let win = app.clone();
let _ = app.run_on_main_thread(move || {
let Some(w) = win.get_webview_window("popup") else {
return;
};
match anchor {
// Nativer Tray (macOS/Windows): rechte Popup-Kante an der rechten
// Icon-Kante; macOS unter dem Menüleisten-Icon, Windows über der Taskbar.
Anchor::IconRect { rect, popup_below } => {
let scale = w.scale_factor().unwrap_or(1.0);
let pos = rect.position.to_physical::<f64>(scale);
let size = rect.size.to_physical::<f64>(scale);
let win_w = w.outer_size().map(|s| s.width as f64).unwrap_or(320.0);
let x = (pos.x + size.width - win_w).max(0.0);
let y = if popup_below {
pos.y + size.height
} else {
let win_h = w.outer_size().map(|s| s.height as f64).unwrap_or(300.0);
(pos.y - win_h).max(0.0)
};
let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
}
// SNI (KDE/XFCE/Cinnamon): Activate liefert keine brauchbaren
// Koordinaten, aber der Zeiger sitzt beim Klick auf dem Icon.
Anchor::Cursor => {
#[cfg(target_os = "linux")]
{
if let Some(g) = win.try_state::<PopupBlurGuard>() {
g.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
match win.cursor_position() {
Ok(p) => position_popup(&w, p.x as i32, p.y as i32),
Err(_) => {
let _ = w.center();
}
}
// Guard nach kurzer Zeit lösen — danach schließt Fokusverlust wieder
// normal.
let g2 = win.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(350));
if let Some(g) = g2.try_state::<PopupBlurGuard>() {
g.0.store(false, std::sync::atomic::Ordering::SeqCst);
}
});
}
}
// GNOME-Extension/KWin: der Compositor positioniert selbst.
Anchor::Managed => {}
}
let _ = w.show();
let _ = w.set_focus();
});
}
/// Popup verstecken (GNOME-Toggle über D-Bus Hide()).
pub(crate) fn hide_popup(app: &tauri::AppHandle) {
use tauri::Manager;
let win = app.clone();
let _ = app.run_on_main_thread(move || {
if let Some(w) = win.get_webview_window("popup") {
let _ = w.hide();
}
});
}
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default()
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
// Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag.
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
spawn_session_watcher();
// Panel-MCP-Server + Tool-Freigabe in alle Pools legen (auch bestehende),
// damit write_panel in jedem CLAUDE_CONFIG_DIR ohne Rückfrage bereitsteht.
provision_pools_for_panel(&Paths::real());
// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,
// damit sie da sind, bevor ein Terminal startet (No-op außerhalb Linux).
crate::platform::sync_all_desktops(&Paths::real());
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
.title("ai-control")
.inner_size(800.0, 600.0)
// Fenster-Icon (_NET_WM_ICON) — deckt X11-DEs wie XFCE direkt ab,
// unabhängig von der .desktop-Zuordnung; auf Windows das Titel-/Taskbar-Icon.
.icon(tauri::image::Image::from_bytes(include_bytes!(
"../icons/128x128.png"
))?)?
.visible(false);
// Wie die Terminal-Fenster: eigener Header als Titelleiste. macOS behält die
// Ampel (Overlay), Linux ist dekorationslos (eigene Fensterknöpfe im Header).
#[cfg(target_os = "macos")]
let main_win = main_win
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
#[cfg(target_os = "linux")]
let main_win = main_win.decorations(false);
main_win.build()?;
// Rahmenloses Popup-Fenster — dieselbe Optik auf allen Plattformen. Nur
// der Weg zum Klick unterscheidet sich (platform::init_tray).
tauri::WebviewWindowBuilder::new(app, "popup", tauri::WebviewUrl::App("popup.html".into()))
.title("ai-control-popup")
.inner_size(320.0, 300.0)
.decorations(false)
.visible(false)
.transparent(true)
.skip_taskbar(true)
.always_on_top(true)
.resizable(false)
.build()?;
#[cfg(target_os = "linux")]
{
use tauri::Manager;
app.manage(PopupBlurGuard(std::sync::Arc::new(
std::sync::atomic::AtomicBool::new(false),
)));
}
// Der gesamte Tray-Kontrakt: Icon zeigen, Klick als Anchor melden —
// was dann passiert, entscheidet ausschließlich dieser Code.
crate::platform::init_tray(
app.handle(),
crate::platform::TrayCallbacks {
show: Box::new(|app, anchor| show_popup(app, anchor)),
hide: Box::new(hide_popup),
},
)?;
Ok(())
})
// Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü.
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
window.hide().unwrap();
}
// Popup schließt bei Fokusverlust.
if let tauri::WindowEvent::Focused(false) = event {
if window.label() == "popup" {
// KDE feuert direkt nach show/set_focus ein Focused(false) — ohne
// diese kurze Sperre verschwände das Popup sofort wieder (erst der
// zweite Klick hielte). GNOME/macOS setzen die Sperre nie.
#[cfg(target_os = "linux")]
{
use tauri::Manager;
if let Some(g) = window.try_state::<PopupBlurGuard>() {
if g.0.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
}
}
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
commands::list_projects,
commands::create_project_full,
commands::add_project,
commands::remove_project,
commands::delete_project,
commands::project_work_dirs,
commands::set_project_dir,
commands::add_work_dir,
commands::remove_work_dir,
commands::list_pools,
commands::create_oauth_pool,
commands::create_apikey_pool,
commands::rename_pool,
commands::delete_pool,
commands::assign_pool,
commands::unassign_pool,
commands::set_terminal_config,
commands::project_icon,
commands::pool_label,
commands::todo_state,
commands::set_todo,
commands::usage_stats,
commands::stop_project,
commands::restart_project,
commands::start_or_focus_cmd,
commands::open_main_window,
commands::quit_app,
commands::sync_setting,
commands::set_sync_setting,
commands::terminal_font_size,
commands::set_terminal_font_size,
commands::link_pool_runtime,
commands::oauth_login,
commands::keychain_status,
commands::set_apikey,
terminal::open_terminal
])
}
/// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt
/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal.
pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(terminal::Terminals::default())
.setup(move |app| {
let cfg = terminal_config(&project)?;
terminal::build_window(app.handle(), &project, &cfg)?;
Ok(())
})
// Fenster zu → PTY-Session abräumen; danach endet der Prozess. Das
// abgelöste Panel-Fenster hat keine PTY: sein Schließen dockt das Panel
// wieder an (panel-attached), der Prozess läuft weiter.
.on_window_event(|window, event| {
if let tauri::WindowEvent::Destroyed = event {
if window.label().starts_with("panel-") {
use tauri::{Emitter, Manager};
let _ = window.app_handle().emit("panel-window-closed", ());
} else {
terminal::close(window);
}
}
})
.invoke_handler(tauri::generate_handler![
terminal::term_start,
terminal::term_log,
terminal::term_write,
terminal::term_resize,
terminal::panel_read,
terminal::panel_set,
terminal::open_panel_window,
commands::panel_archive_dir_cmd,
commands::panel_archive_cmd,
commands::reveal_path_cmd,
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
commands::list_projects,
commands::project_icon,
commands::pool_label,
commands::terminal_font_size,
commands::set_terminal_font_size,
commands::spellcheck_lang
])
}