Add Tauri desktop shell spike

desktop/ is a thin Tauri v2 app: a bundled connect page probes
SIMBA_URL/api/health and hands the window to the deployed web app.
Adds a menu-bar icon (Show / New Chat / Reconnect / Quit), Alt+Space
toggle, close-to-hide, single-instance and Dock reopen. The remote
origin gets no IPC. Rust 1.90 is pinned via rust-toolchain.toml.
New make targets: desktop-dev, desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:40:50 -04:00
parent eea96d9010
commit 6a03f3f9f0
19 changed files with 5554 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
src-tauri/target/
src-tauri/gen/
+232
View File
@@ -0,0 +1,232 @@
{
"name": "simba-desktop",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simba-desktop",
"version": "0.1.0",
"devDependencies": {
"@tauri-apps/cli": "^2.12.2"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz",
"integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.12.2",
"@tauri-apps/cli-darwin-x64": "2.12.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.12.2",
"@tauri-apps/cli-linux-arm64-musl": "2.12.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.2",
"@tauri-apps/cli-linux-x64-gnu": "2.12.2",
"@tauri-apps/cli-linux-x64-musl": "2.12.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.12.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.12.2",
"@tauri-apps/cli-win32-x64-msvc": "2.12.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz",
"integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz",
"integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz",
"integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz",
"integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz",
"integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz",
"integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz",
"integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz",
"integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz",
"integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz",
"integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz",
"integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "simba-desktop",
"version": "0.1.0",
"private": true,
"scripts": {
"tauri": "tauri",
"dev": "tauri dev",
"build": "tauri build --bundles app"
},
"devDependencies": {
"@tauri-apps/cli": "^2.12.2"
}
}
+4973
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "simba-desktop"
version = "0.1.0"
edition = "2021"
[lib]
name = "simba_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"
url = "2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Bundled pages only (offline/connect screen). The remote SimbaRAG origin gets no IPC.",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.90.0"
profile = "minimal"
+171
View File
@@ -0,0 +1,171 @@
//! 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)
.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);
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
simba_desktop_lib::run()
}
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simba",
"version": "0.1.0",
"identifier": "co.torrtle.simba",
"build": {
"frontendDist": "../ui"
},
"app": {
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.png"
]
}
}
+88
View File
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Simba</title>
<style>
:root {
--cream: #FAF8F2;
--leaf: #2A4D38;
--muted: #7a7266;
--card: #ffffff;
--border: #e6e0d4;
}
@media (prefers-color-scheme: dark) {
:root { --cream: #1b1d1a; --leaf: #8fc4a1; --muted: #a39d92; --card: #24271f; --border: #34382f; }
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
background: var(--cream); color: var(--leaf);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-user-select: none; user-select: none;
}
main { text-align: center; padding: 16px; max-width: 360px; }
.cat { font-size: 48px; display: inline-block; animation: bob 1.2s ease-in-out infinite; }
.offline .cat { animation: none; filter: grayscale(1); }
@keyframes bob { 50% { transform: translateY(-6px); } }
h1 { font-size: 17px; margin: 12px 0 4px; }
p { margin: 0; color: var(--muted); font-size: 13px; }
code { font-size: 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; }
button {
margin-top: 16px; padding: 6px 16px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--border); background: var(--card); color: var(--leaf); font: inherit;
}
.online-only { display: block; }
.offline-only { display: none; }
.offline .online-only { display: none; }
.offline .offline-only { display: block; }
</style>
</head>
<body>
<main id="root">
<div class="cat">🐈</div>
<div class="online-only">
<h1>waking up simba…</h1>
<p>connecting to <code id="server-a"></code></p>
</div>
<div class="offline-only">
<h1>Can't reach Simba</h1>
<p><code id="server-b"></code> isn't responding. Retrying every few seconds.</p>
<button id="retry" type="button">Try now</button>
</div>
</main>
<script>
// The Rust shell passes the server in ?server=. Probe it, then hand the
// whole window over to the real web app.
const server = (new URLSearchParams(location.search).get("server") || "http://localhost:8080").replace(/\/+$/, "");
const root = document.getElementById("root");
document.getElementById("server-a").textContent = server;
document.getElementById("server-b").textContent = server;
let timer = null;
async function probe() {
clearTimeout(timer);
const ctrl = new AbortController();
const abort = setTimeout(() => ctrl.abort(), 5000);
try {
// no-cors: we only care that the server answered, not what it said.
await fetch(`${server}/api/health`, { mode: "no-cors", cache: "no-store", signal: ctrl.signal });
location.replace(`${server}/`);
} catch {
root.classList.add("offline");
timer = setTimeout(probe, 5000);
} finally {
clearTimeout(abort);
}
}
document.getElementById("retry").addEventListener("click", () => {
root.classList.remove("offline");
probe();
});
probe();
</script>
</body>
</html>