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:
parent
373b7979b1
commit
c0a1c7f6dd
7 changed files with 218 additions and 12 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
22
deployment/src-tauri/rudder.conf.json
Normal file
22
deployment/src-tauri/rudder.conf.json
Normal 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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue