diff --git a/.gitignore b/.gitignore index 81008ec50..c1a0bfbd4 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,6 @@ data-private/ /backend/bin/ # The local PocketBase's data (`bun run dev`, internal/backend-dev-plugin.mjs): per laptop, never committed. /.pb/ + +# Desktop sidecars (built from backend// per target triple) — never committed. +/deployment/src-tauri/binaries/ diff --git a/backend/rudder-engine/devices.go b/backend/rudder-engine/devices.go index f9b77c46d..70a423094 100644 --- a/backend/rudder-engine/devices.go +++ b/backend/rudder-engine/devices.go @@ -117,6 +117,14 @@ func listDevices() DeviceList { if bus, ok := s.Properties["device.bus"].(string); ok { d.Bus = bus } + // RUDDER_PRIVATE_LABELS=1: show microphones by KIND, not by name. Device names are often personal + // ("Anna's AirPods") — use this for screenshots, screen-sharing and demos. + if os.Getenv("RUDDER_PRIVATE_LABELS") == "1" { + d.Label = map[string]string{"bluetooth": "Bluetooth headset", "usb": "USB microphone", "pci": "Built-in microphone"}[d.Bus] + if d.Label == "" { + d.Label = "Microphone" + } + } for _, ch := range s.Volume { // first channel is representative if m, ok := ch.(map[string]any); ok { if p, ok := m["value_percent"].(string); ok { diff --git a/backend/rudder-engine/main.go b/backend/rudder-engine/main.go index 1f792d8bc..0f21b431e 100644 --- a/backend/rudder-engine/main.go +++ b/backend/rudder-engine/main.go @@ -935,6 +935,40 @@ func main() { } }) + // RUDDER_WEB (desktop app): serve the built web tree from THIS origin, so the desktop window opens + // http://127.0.0.1:/rudder-voice/rudder-voice/index.html and the page reaches /api/rudder + // same-origin — exactly as it does under the dev server's proxy. No plugin change and no rebuild: the + // app runs the very page the Plugin Studio runs. Directory listings are refused. + if web := os.Getenv("RUDDER_WEB"); web != "" { + files := http.FileServer(http.Dir(web)) + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/") { + if st, err := os.Stat(filepath.Join(web, filepath.FromSlash(r.URL.Path), "index.html")); err != nil || st.IsDir() { + http.NotFound(w, r) + return + } + } + files.ServeHTTP(w, r) + })) + logs.add("serving the web tree for the desktop window") + } + + // RUDDER_EXIT_WITH_PARENT (desktop app): if the app that started this engine dies without stopping it, + // stop too — and with it the ~1 GB speech server — instead of lingering until logout. The parent's + // death shows up as a changed parent PID (the orphan is re-parented). + if os.Getenv("RUDDER_EXIT_WITH_PARENT") == "1" { + parent := os.Getppid() + go func() { + for range time.Tick(time.Second) { + if os.Getppid() != parent { + logs.add("parent app exited — stopping") + e.shutdown() + os.Exit(0) + } + } + }() + } + srv := &http.Server{Addr: "127.0.0.1:" + cfg.Port, Handler: guard(mux)} logs.add("rudder-engine listening on http://127.0.0.1:%s (runtime=%s model=%s)", cfg.Port, cfg.NemoBin, cfg.Model) e.startNemo() diff --git a/deployment/src-tauri/rudder.conf.json b/deployment/src-tauri/rudder.conf.json new file mode 100644 index 000000000..8646a9316 --- /dev/null +++ b/deployment/src-tauri/rudder.conf.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Rudder", + "mainBinaryName": "rudder", + "identifier": "life.doxa.rudder", + "build": { + "frontendDist": "../../public/dx-maps/rudder-voice" + }, + "app": { + "windows": [] + }, + "bundle": { + "active": true, + "targets": ["deb"], + "externalBin": ["binaries/rudder-engine"], + "resources": { + "../../public/dx-maps/rudder-voice/": "rudder-web/rudder-voice/" + }, + "shortDescription": "Private, offline, streaming voice-to-text", + "longDescription": "Rudder turns speech into text on this computer with NVIDIA's open NeMo-Speech.cpp runtime. No account, no cloud, no telemetry." + } +} diff --git a/deployment/src-tauri/src/main.rs b/deployment/src-tauri/src/main.rs index fc8265878..28a844cdc 100644 --- a/deployment/src-tauri/src/main.rs +++ b/deployment/src-tauri/src/main.rs @@ -1,16 +1,118 @@ // main.rs — the whole desktop shell. // -// A Tauri app around the SAME public/ tree Capacitor and the CDN serve. The window, its -// size and the entry page all come from ../tauri.conf.json (app.windows[0]) so there is -// ONE place to change them and no value is duplicated between config and code. +// A Tauri app around the SAME public/ tree Capacitor and the CDN serve. For a plain build (the Doxa +// Maps app) the window, its size and the entry page all come from ../tauri.conf.json (app.windows[0]): +// no commands, no IPC, no state — the maps load in the webview exactly as a browser loads them. // -// No commands, no IPC, no state. The maps are a compiled Vue custom element loaded by the -// webview exactly as a browser loads them; giving this shell logic would fork behaviour -// between desktop and web, which is the opposite of what one-build-four-consumers buys us. +// ★ THE ONE PIECE OF LOGIC: a plugin with a local backend. Rudder (rudder.conf.json) bundles its engine +// as a sidecar (binaries/rudder-engine) and its web page as a resource (rudder-web/). When that resource +// is present, and only then, the shell: +// 1. uses loopback ports 5293/5294 — a FIXED port, because the page keeps its draft and settings in +// localStorage, which is per origin (host + port): a new random port each launch would forget +// them — and falls back to free ports only when those are taken (a dev server uses 5193/5194), +// 2. starts the engine with RUDDER_WEB = the bundled page, so the engine serves the page AND its API +// from one origin — the window opens http://127.0.0.1:/rudder-voice/rudder-voice/ and the +// page reaches /api/rudder same-origin, exactly as under the dev server (no plugin change), +// 3. opens the window once the engine answers, and +// 4. stops the engine when the app exits (RUDDER_EXIT_WITH_PARENT also stops it if the app crashes). +// Everything else stays in the web tree; keep this to starting and stopping the sidecar. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::process::{Child, Command}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use tauri::{Manager, RunEvent, WebviewUrl, WebviewWindowBuilder}; + +struct Sidecar(Mutex>); + +// `preferred` if it's free (a stable origin for the page's saved draft), otherwise any free port. +fn port_or_free(preferred: u16) -> std::io::Result { + if TcpListener::bind(("127.0.0.1", preferred)).is_ok() { + return Ok(preferred); + } + Ok(TcpListener::bind("127.0.0.1:0")?.local_addr()?.port()) +} + +// True once GET answers 200 on 127.0.0.1:. +fn wait_ok(port: u16, path: &str, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if let Ok(mut s) = TcpStream::connect(("127.0.0.1", port)) { + let _ = s.set_read_timeout(Some(Duration::from_secs(2))); + let req = format!("GET {path} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); + let mut head = [0u8; 12]; + if s.write_all(req.as_bytes()).is_ok() && s.read_exact(&mut head).is_ok() && &head[9..12] == b"200" { + return true; + } + } + std::thread::sleep(Duration::from_millis(200)); + } + false +} + +fn start_rudder(app: &tauri::App) -> Result<(), Box> { + let web = app.path().resource_dir()?.join("rudder-web"); + if !web.is_dir() { + return Ok(()); // no bundled backend: a plain shell, windows come from tauri.conf.json + } + // Tauri installs an externalBin next to the app's own executable, without the target-triple suffix. + let exe_dir = std::env::current_exe()?.parent().map(|p| p.to_path_buf()).unwrap_or_default(); + let engine = exe_dir.join(if cfg!(windows) { "rudder-engine.exe" } else { "rudder-engine" }); + let port = port_or_free(5293)?; + let nemo_port = port_or_free(5294)?; + let child = Command::new(&engine) + .env("RUDDER_PORT", port.to_string()) + .env("RUDDER_NEMO_PORT", nemo_port.to_string()) + .env("RUDDER_WEB", &web) + .env("RUDDER_EXIT_WITH_PARENT", "1") + .spawn()?; + *app.state::().0.lock().unwrap() = Some(child); + + let handle = app.handle().clone(); + std::thread::spawn(move || { + let ready = wait_ok(port, "/api/rudder/status", Duration::from_secs(30)); + let url = format!("http://127.0.0.1:{port}/rudder-voice/rudder-voice/"); + let h = handle.clone(); + let _ = handle.run_on_main_thread(move || { + let target = if ready { + WebviewUrl::External(url.parse().expect("loopback URL")) + } else { + // The engine never answered: say so instead of a blank window. + WebviewUrl::External( + "data:text/html,\ +

Rudder could not start its speech engine

Run the app from a terminal to see the engine's messages.

" + .parse() + .expect("data URL"), + ) + }; + let _ = WebviewWindowBuilder::new(&h, "main", target) + .title("Rudder") + .inner_size(960.0, 780.0) + .min_inner_size(420.0, 480.0) + .build(); + }); + }); + Ok(()) +} + fn main() { tauri::Builder::default() - .run(tauri::generate_context!()) - .expect("error while running the Doxa Maps desktop shell"); + .manage(Sidecar(Mutex::new(None))) + .setup(|app| { + start_rudder(app)?; + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while building the desktop shell") + .run(|app, event| { + if let RunEvent::Exit = event { + if let Some(mut child) = app.state::().0.lock().unwrap().take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + }); } diff --git a/documentation/HOWTO-build-the-desktop-app.md b/documentation/HOWTO-build-the-desktop-app.md index c0226073a..6f4316b2b 100644 --- a/documentation/HOWTO-build-the-desktop-app.md +++ b/documentation/HOWTO-build-the-desktop-app.md @@ -1,4 +1,4 @@ -# HOWTO: build the Rudder desktop app (macOS / Windows) +# HOWTO: build the Rudder desktop app (Linux / macOS / Windows) Rudder (`plugin-designs/rudder-voice`) is voice-to-text that runs on your own computer: a web UI plus a local engine (`backend/rudder-engine`, Go) that drives NVIDIA's NeMo-Speech.cpp speech server. The @@ -18,7 +18,9 @@ desktop path has never been run by anyone, and the steps say so where that is th | The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. | | The engine accepting a WebSocket from the Tauri webview (`tauri://localhost`, `http://tauri.localhost`) | Proven against a running engine; another site's origin is refused. | | NeMo-Speech.cpp runtime for macOS / Windows | Published by NVIDIA (release v0.1.0: `macos-aarch64-cpu`, `macos-aarch64-metal`, `macos-x86_64-cpu`, `windows-x86_64-cpu` / `-cuda` / `-vulkan`). **Never run here.** | -| Rudder as a sidecar, first-run model download, installer | **Not built.** Steps 5–8 below are the work to do. | +| **Rudder desktop app on Linux** (`.deb`, engine as sidecar, window, stop on exit) | **Proven 2026-09-28** on Ubuntu 24.04 x86_64: built, launched, engine ready in 8 s, the window's page connected, closing the window stopped the engine and its speech server, and so did killing the app. Screenshot taken. See "Linux: the proven path" below. | +| Rudder as a sidecar on macOS / Windows | Steps 5–6 are built (the same `main.rs` and `rudder.conf.json`); **never run** on either. | +| First-run runtime + model download, installer that carries them | **Not built** (step 7). The `.deb` is 8 MB and does NOT contain the 742 MB model: on Linux, run `setup-runtime.sh` once first. | ## 1. Prerequisites @@ -76,7 +78,33 @@ cd backend/rudder-engine; go build -o ..\..\deployment\src-tauri\binaries\rudder `rustc -Vv` prints the exact triple (`host:` line) if unsure. -## 5. Make the Tauri shell start the engine (NOT BUILT: this is the work) +## Linux: the proven path + +```bash +bash backend/rudder-engine/setup-runtime.sh # once: runtime + model into ~/.local/share/rudder +sudo apt-get install libwebkit2gtk-4.1-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev \ + libgtk-3-dev librsvg2-dev libssl-dev libxdo-dev libayatana-appindicator3-dev +mkdir -p deployment/src-tauri/binaries +(cd backend/rudder-engine && go build -o ../../deployment/src-tauri/binaries/rudder-engine-x86_64-unknown-linux-gnu .) +./node_modules/.bin/tauri build --config deployment/src-tauri/rudder.conf.json +sudo apt install ./deployment/src-tauri/target/release/bundle/deb/Rudder_0.1.0_amd64.deb # or run target/release/rudder +``` + +No `bun run build` is needed: `public/dx-maps/rudder-voice/` is committed on main. The first Rust compile +took 5 minutes (about 430 crates); a rebuild about 1 minute. On Linux the engine records the microphone +itself (`parec`), so the webview never asks for the mic. + +## 5. Make the Tauri shell start the engine (BUILT; proven on Linux) + +What was built: **`deployment/src-tauri/rudder.conf.json`**, an overlay passed with `tauri build --config`, +so the Doxa Maps app's `tauri.conf.json` is unchanged. It names the app "Rudder", declares the sidecar +(`externalBin`), bundles Rudder's built page as a resource (`rudder-web/`), and opens no window from +config. **`src/main.rs`** starts the engine only when that resource is present (a plain build is untouched), +with `std::process` next to the app's executable — no shell plugin and no capability config needed. +It uses ports **5293/5294** (not the dev server's 5193/5194; a fixed port, because the page's saved draft is +per origin), falls back to free ports if those are taken, and stops the engine when the app exits. The engine +also stops on its own if the app is killed (`RUDDER_EXIT_WITH_PARENT=1`), so its ~1 GB speech server never +lingers. The original plan, kept for macOS/Windows reference: 1. **Declare the sidecar** in `deployment/src-tauri/tauri.conf.json`: `"bundle": { "externalBin": ["binaries/rudder-engine"], "targets": ["dmg"] }` on macOS, or @@ -94,7 +122,15 @@ cd backend/rudder-engine; go build -o ..\..\deployment\src-tauri\binaries\rudder desktop plugin with a backend, so this is the first logic it gains; keep it to starting and stopping the sidecar. -## 6. Open Rudder, and tell it where the engine is (NOT BUILT) +## 6. Open Rudder, and tell it where the engine is (BUILT; proven on Linux) + +**Chosen: the engine serves the page.** With `RUDDER_WEB` set, the engine serves the bundled web tree from +its own origin, and the window opens `http://127.0.0.1:5293/rudder-voice/rudder-voice/`. The page then +reaches `/api/rudder` **same-origin**, exactly as under the dev server's proxy: no plugin change, no rebuild +of `public/`, no new config channel. (Considered and rejected: a global read by the plugin — needs a bundle +rebuild and new SRI; patching the page after load — Rudder has already connected by then.) The engine's +Host check still refuses anything not addressed to 127.0.0.1/localhost, and directory listings are refused. +Original notes: The window opens `doxa-maps-spa/doxa-maps-spa/index.html` today. For a Rudder app, open Rudder's own page, `rudder-voice/rudder-voice/index.html`, and give the element the engine's **absolute** address: diff --git a/plugin-designs/rudder-voice/README.md b/plugin-designs/rudder-voice/README.md index 06c50fe9f..32ca731c0 100644 --- a/plugin-designs/rudder-voice/README.md +++ b/plugin-designs/rudder-voice/README.md @@ -48,6 +48,7 @@ so this plugin is not meant for web embeds. | `RUDDER_LANG` | `en-US` | default language (the model supports ~30) | | `RUDDER_KEEP_MSBC` | unset | keep Bluetooth headsets on mSBC instead of switching them to the more stable CVSD | | `RUDDER_BT_RESET` | unset | optional command that fully resets a Bluetooth headset, used when its mic link dies | +| `RUDDER_PRIVATE_LABELS` | unset | `1` shows microphones by kind ("Bluetooth headset") instead of their names — for screenshots, screen-sharing and demos | ## Not yet