3e50aecb96
Tauri v2 intercepts drag-and-drop by default, so the chat's drop overlay never saw dropped files. Disable its handler on the main window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
5.9 KiB
Rust
174 lines
5.9 KiB
Rust
//! 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<WebviewWindow> {
|
|
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);
|
|
}
|
|
});
|
|
}
|