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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
Generated
+232
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+4973
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.90.0"
|
||||
profile = "minimal"
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user