//! Simba desktop shell. //! //! A thin native wrapper around the deployed SimbaRAG web app: the main window //! starts on a bundled connect page (`ui/index.html`) that checks the server is //! reachable and then navigates to it. The Rust side owns everything the web //! app can't do: the menu-bar icon, the global hotkey, and keeping the app //! alive when the window is closed. use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Manager, RunEvent, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent, }; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; const MAIN: &str = "main"; const DEFAULT_SERVER_URL: &str = "http://localhost:8080"; /// Server URL: `SIMBA_URL` at runtime, else at build time, else localhost. fn server_url() -> String { std::env::var("SIMBA_URL") .ok() .or_else(|| option_env!("SIMBA_URL").map(str::to_owned)) .unwrap_or_else(|| DEFAULT_SERVER_URL.to_owned()) .trim_end_matches('/') .to_owned() } /// Bundled connect page, told which server to probe and open. fn connect_url() -> WebviewUrl { let mut url = url::Url::parse("tauri://localhost/index.html").expect("static url"); url.query_pairs_mut().append_pair("server", &server_url()); WebviewUrl::App(format!("index.html?{}", url.query().unwrap_or_default()).into()) } fn main_window(app: &AppHandle) -> Option { app.get_webview_window(MAIN) } fn show(app: &AppHandle) { if let Some(window) = main_window(app) { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } } fn toggle(app: &AppHandle) { let Some(window) = main_window(app) else { return }; let focused = window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false); if focused { let _ = window.hide(); } else { show(app); } } /// Re-run the connect page (server probe + redirect), e.g. after an outage. fn reconnect(app: &AppHandle) { if let Some(window) = main_window(app) { let WebviewUrl::App(path) = connect_url() else { return }; if let Ok(url) = url::Url::parse(&format!("tauri://localhost/{}", path.display())) { let _ = window.navigate(url); } show(app); } } fn new_chat(app: &AppHandle) { if let Some(window) = main_window(app) { if let Ok(url) = url::Url::parse(&format!("{}/", server_url())) { let _ = window.navigate(url); } show(app); } } fn build_tray(app: &AppHandle) -> tauri::Result<()> { let show_item = MenuItem::with_id(app, "show", "Show Simba", true, Some("Alt+Space"))?; let new_chat_item = MenuItem::with_id(app, "new_chat", "New Chat", true, None::<&str>)?; let reconnect_item = MenuItem::with_id(app, "reconnect", "Reconnect", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit Simba", true, Some("Cmd+Q"))?; let menu = Menu::with_items( app, &[ &show_item, &new_chat_item, &PredefinedMenuItem::separator(app)?, &reconnect_item, &PredefinedMenuItem::separator(app)?, &quit_item, ], )?; let mut tray = TrayIconBuilder::with_id("simba") .tooltip("Simba") .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { "show" => show(app), "new_chat" => new_chat(app), "reconnect" => reconnect(app), "quit" => app.exit(0), _ => {} }) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { toggle(tray.app_handle()); } }); if let Some(icon) = app.default_window_icon() { tray = tray.icon(icon.clone()); } tray.build(app)?; Ok(()) } fn register_hotkey(app: &AppHandle) -> tauri::Result<()> { let hotkey = Shortcut::new(Some(Modifiers::ALT), Code::Space); app.plugin( tauri_plugin_global_shortcut::Builder::new() .with_handler(move |app, shortcut, event| { if shortcut == &hotkey && event.state() == ShortcutState::Pressed { toggle(app); } }) .build(), )?; // Another app may already own ⌥Space; the tray still works without it. if let Err(e) = app.global_shortcut().register(hotkey) { eprintln!("simba: could not register Alt+Space: {e}"); } Ok(()) } pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| show(app))) .setup(|app| { WebviewWindowBuilder::new(app, MAIN, connect_url()) .title("Simba") .inner_size(1200.0, 820.0) .min_inner_size(420.0, 520.0) // Let HTML5 drag-and-drop reach the webview (file drop in chat). .disable_drag_drop_handler() .build()?; build_tray(app.handle())?; register_hotkey(app.handle())?; Ok(()) }) .on_window_event(|window, event| { // Closing the window hides it; Simba lives on in the menu bar. if let WindowEvent::CloseRequested { api, .. } = event { if window.label() == MAIN { api.prevent_close(); let _ = window.hide(); } } }) .build(tauri::generate_context!()) .expect("error while building Simba") .run(|app, event| { // Clicking the Dock icon brings the hidden window back. if let RunEvent::Reopen { .. } = event { show(app); } }); }