Rudder desktop app on Linux: the engine as a Tauri sidecar, proven end to end

HOWTO steps 5–6, built and run on Ubuntu 24.04 x86_64: `tauri build --config
deployment/src-tauri/rudder.conf.json` produced Rudder_0.1.0_amd64.deb (8 MB); the app launched, the engine
was ready in 8 s, the window's page connected, and it transcribed speech. Closing the window stopped the
engine and its speech server; so did killing the app.

- deployment/src-tauri/rudder.conf.json: overlay for the Rudder app (name, id, externalBin sidecar, Rudder's
  built page as a resource, no config window). tauri.conf.json (the Doxa Maps app) is unchanged.
- src/main.rs: only when that resource is present, start the engine next to the app's executable
  (std::process, no shell plugin), on 5293/5294 — fixed, because the page's saved draft is per origin —
  falling back to free ports; open the window once it answers; stop it on exit.
- engine: RUDDER_WEB serves the web tree from the engine's own origin, so the window opens
  http://127.0.0.1:5293/rudder-voice/rudder-voice/ and /api/rudder is same-origin, exactly as under the dev
  server — no plugin change, no rebuild of public/. RUDDER_EXIT_WITH_PARENT stops the engine (and its ~1 GB
  speech server) if the app dies. RUDDER_PRIVATE_LABELS shows microphones by kind, not name (screenshots).
- HOWTO: the proven Linux path and what was built; step 7 (first-run model download) is still not built.
This commit is contained in:
Doxa Maps 2026-09-28 03:38:32 +00:00
parent 373b7979b1
commit c0a1c7f6dd
7 changed files with 218 additions and 12 deletions

3
.gitignore vendored
View file

@ -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/<name>/ per target triple) — never committed.
/deployment/src-tauri/binaries/

View file

@ -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 {

View file

@ -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:<port>/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()

View file

@ -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."
}
}

View file

@ -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:<port>/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<Option<Child>>);
// `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<u16> {
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 <path> answers 200 on 127.0.0.1:<port>.
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<dyn std::error::Error>> {
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::<Sidecar>().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,<body style='font:16px system-ui;padding:2em;background:%230b0f14;color:%23e5e7eb'>\
<h1>Rudder could not start its speech engine</h1><p>Run the app from a terminal to see the engine's messages.</p></body>"
.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::<Sidecar>().0.lock().unwrap().take() {
let _ = child.kill();
let _ = child.wait();
}
}
});
}

View file

@ -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:

View file

@ -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