feat: desktop app sidecar, connection screen, and 3D orb#452
Conversation
Add Tauri sidecar integration so the desktop app can launch a bundled Spacebot server locally. Introduces a ConnectionScreen that gates the app until a server is reachable, with options to connect to a remote URL or start the bundled binary. Refactor the API client from a static base URL to a dynamic getter so cross-origin Tauri requests work. New components: ConnectionScreen, Orb (WebGL via ogl). New hook: useServer for Tauri-aware server lifecycle management. Adds bundle-sidecar.sh, desktop justfile recipes, and getting-started desktop docs.
|
Caution Review failedPull request was closed or merged during review WalkthroughAdds desktop (Tauri) integration: persistent server URL IPC, client-side dynamic server handling and health polling, a Connection UI with sidecar lifecycle, Orb visual, docs, build scripts for bundling the sidecar, and minor gitignore updates for sidecar binaries. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
📝 Coding Plan
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.41.1)src/api/server.rsThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| # Determine Rust target triple | ||
| TARGET_TRIPLE="${TAURI_ENV_TARGET_TRIPLE:-$(rustc -vV | awk '/^host:/ {print $2}')}" | ||
|
|
||
| # Build mode | ||
| BUILD_MODE="release" | ||
| CARGO_FLAGS="--release" | ||
| if [[ "${1:-}" != "--release" ]]; then | ||
| BUILD_MODE="debug" | ||
| CARGO_FLAGS="" | ||
| fi | ||
|
|
||
| echo "Building spacebot ($BUILD_MODE) for $TARGET_TRIPLE..." | ||
| cargo build $CARGO_FLAGS --manifest-path "$REPO_ROOT/Cargo.toml" | ||
|
|
||
| # Source binary path | ||
| SRC_BIN="$REPO_ROOT/target/$BUILD_MODE/spacebot" |
There was a problem hiding this comment.
TAURI_ENV_TARGET_TRIPLE can point at a non-host target, but the script always builds the host binary (no --target) and then copies it under the target triple name. That seems like it could silently ship the wrong binary when cross-building.
| # Determine Rust target triple | |
| TARGET_TRIPLE="${TAURI_ENV_TARGET_TRIPLE:-$(rustc -vV | awk '/^host:/ {print $2}')}" | |
| # Build mode | |
| BUILD_MODE="release" | |
| CARGO_FLAGS="--release" | |
| if [[ "${1:-}" != "--release" ]]; then | |
| BUILD_MODE="debug" | |
| CARGO_FLAGS="" | |
| fi | |
| echo "Building spacebot ($BUILD_MODE) for $TARGET_TRIPLE..." | |
| cargo build $CARGO_FLAGS --manifest-path "$REPO_ROOT/Cargo.toml" | |
| # Source binary path | |
| SRC_BIN="$REPO_ROOT/target/$BUILD_MODE/spacebot" | |
| # Determine Rust target triple | |
| HOST_TRIPLE="$(rustc -vV | awk '/^host:/ {print $2}')" | |
| TARGET_TRIPLE="${TAURI_ENV_TARGET_TRIPLE:-$HOST_TRIPLE}" | |
| # Build mode | |
| BUILD_MODE="release" | |
| CARGO_FLAGS="--release" | |
| if [[ "${1:-}" != "--release" ]]; then | |
| BUILD_MODE="debug" | |
| CARGO_FLAGS="" | |
| fi | |
| echo "Building spacebot ($BUILD_MODE) for $TARGET_TRIPLE..." | |
| if [[ "$TARGET_TRIPLE" != "$HOST_TRIPLE" ]]; then | |
| cargo build $CARGO_FLAGS --target "$TARGET_TRIPLE" --manifest-path "$REPO_ROOT/Cargo.toml" | |
| SRC_BIN="$REPO_ROOT/target/$TARGET_TRIPLE/$BUILD_MODE/spacebot" | |
| else | |
| cargo build $CARGO_FLAGS --manifest-path "$REPO_ROOT/Cargo.toml" | |
| SRC_BIN="$REPO_ROOT/target/$BUILD_MODE/spacebot" | |
| fi |
| { | ||
| "name": "binaries/spacebot", | ||
| "sidecar": true, | ||
| "args": true |
There was a problem hiding this comment.
Since the webview code always calls the sidecar with a fixed arg list, it’s safer to scope args down to just those values instead of true.
| "args": true | |
| "args": ["start", "--foreground"] |
| /// Resolve the path to the connection settings file in the app data directory. | ||
| fn settings_path(app: &tauri::AppHandle) -> PathBuf { | ||
| let dir = app | ||
| .path() | ||
| .app_data_dir() | ||
| .expect("failed to resolve app data dir"); | ||
| dir.join("connection.json") | ||
| } | ||
|
|
||
| /// Read the saved server URL, or return the default. | ||
| #[tauri::command] | ||
| fn get_server_url(app: tauri::AppHandle) -> String { | ||
| let path = settings_path(&app); | ||
| if let Ok(contents) = fs::read_to_string(&path) { | ||
| if let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) { | ||
| if let Some(url) = value.get("server_url").and_then(|v| v.as_str()) { | ||
| return url.to_string(); | ||
| } | ||
| } | ||
| } | ||
| "http://localhost:19898".to_string() | ||
| } | ||
|
|
||
| /// Persist the server URL to disk. | ||
| #[tauri::command] | ||
| fn set_server_url(app: tauri::AppHandle, url: String) -> Result<(), String> { | ||
| let path = settings_path(&app); | ||
| if let Some(parent) = path.parent() { | ||
| fs::create_dir_all(parent).map_err(|e| e.to_string())?; | ||
| } | ||
| let value = serde_json::json!({ "server_url": url }); | ||
| fs::write(&path, serde_json::to_string_pretty(&value).unwrap()).map_err(|e| e.to_string())?; | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
Minor robustness: app_data_dir().expect(...) and to_string_pretty(...).unwrap() will hard-crash the desktop app on what should probably be recoverable errors.
| /// Resolve the path to the connection settings file in the app data directory. | |
| fn settings_path(app: &tauri::AppHandle) -> PathBuf { | |
| let dir = app | |
| .path() | |
| .app_data_dir() | |
| .expect("failed to resolve app data dir"); | |
| dir.join("connection.json") | |
| } | |
| /// Read the saved server URL, or return the default. | |
| #[tauri::command] | |
| fn get_server_url(app: tauri::AppHandle) -> String { | |
| let path = settings_path(&app); | |
| if let Ok(contents) = fs::read_to_string(&path) { | |
| if let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) { | |
| if let Some(url) = value.get("server_url").and_then(|v| v.as_str()) { | |
| return url.to_string(); | |
| } | |
| } | |
| } | |
| "http://localhost:19898".to_string() | |
| } | |
| /// Persist the server URL to disk. | |
| #[tauri::command] | |
| fn set_server_url(app: tauri::AppHandle, url: String) -> Result<(), String> { | |
| let path = settings_path(&app); | |
| if let Some(parent) = path.parent() { | |
| fs::create_dir_all(parent).map_err(|e| e.to_string())?; | |
| } | |
| let value = serde_json::json!({ "server_url": url }); | |
| fs::write(&path, serde_json::to_string_pretty(&value).unwrap()).map_err(|e| e.to_string())?; | |
| Ok(()) | |
| } | |
| /// Resolve the path to the connection settings file in the app data directory. | |
| fn settings_path(app: &tauri::AppHandle) -> Result<PathBuf, String> { | |
| let dir = app | |
| .path() | |
| .app_data_dir() | |
| .map_err(|e| e.to_string())?; | |
| Ok(dir.join("connection.json")) | |
| } | |
| /// Read the saved server URL, or return the default. | |
| #[tauri::command] | |
| fn get_server_url(app: tauri::AppHandle) -> String { | |
| let Ok(path) = settings_path(&app) else { | |
| return "http://localhost:19898".to_string(); | |
| }; | |
| if let Ok(contents) = fs::read_to_string(&path) { | |
| if let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) { | |
| if let Some(url) = value.get("server_url").and_then(|v| v.as_str()) { | |
| return url.to_string(); | |
| } | |
| } | |
| } | |
| "http://localhost:19898".to_string() | |
| } | |
| /// Persist the server URL to disk. | |
| #[tauri::command] | |
| fn set_server_url(app: tauri::AppHandle, url: String) -> Result<(), String> { | |
| let path = settings_path(&app)?; | |
| if let Some(parent) = path.parent() { | |
| fs::create_dir_all(parent).map_err(|e| e.to_string())?; | |
| } | |
| let value = serde_json::json!({ "server_url": url }); | |
| let contents = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?; | |
| fs::write(&path, contents).map_err(|e| e.to_string())?; | |
| Ok(()) | |
| } |
| async function checkHealth(baseUrl: string): Promise<boolean> { | ||
| try { | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), 3000); | ||
| const response = await fetch(`${baseUrl}/api/health`, { | ||
| signal: controller.signal, | ||
| }); | ||
| clearTimeout(timeout); | ||
| return response.ok; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
Small thing: if fetch throws before clearTimeout(timeout), the timeout stays live until it fires. Wrapping clearTimeout in a finally keeps it tidy (especially since this runs on an interval).
| async function checkHealth(baseUrl: string): Promise<boolean> { | |
| try { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 3000); | |
| const response = await fetch(`${baseUrl}/api/health`, { | |
| signal: controller.signal, | |
| }); | |
| clearTimeout(timeout); | |
| return response.ok; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function checkHealth(baseUrl: string): Promise<boolean> { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 3000); | |
| try { | |
| const response = await fetch(`${baseUrl}/api/health`, { | |
| signal: controller.signal, | |
| }); | |
| return response.ok; | |
| } catch { | |
| return false; | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| } |
| ]) | ||
| .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::ACCEPT]); | ||
| .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::ACCEPT]) | ||
| .allow_credentials(true); |
There was a problem hiding this comment.
Worth double-checking the implications of .allow_credentials(true) together with AllowOrigin::mirror_request(): this effectively opts into credentialed CORS for any requesting origin. If auth ever moves to cookies (or there are other ambient credentials), that’s a pretty sharp edge; consider keeping credentials off unless it’s explicitly needed, or restricting allow_origin to an allowlist/predicate.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@desktop/src-tauri/src/main.rs`:
- Around line 9-14: The code currently panics via expect/unwrap in settings_path
and set_server_url; change settings_path(app: &tauri::AppHandle) to return
Result<PathBuf, String> and remove the expect by mapping the Option from
app.path().app_data_dir() into an Err(String) on None; update get_server_url to
call settings_path and handle the Err by returning the existing fallback URL (or
converting the error into the Result flow), and update set_server_url to
propagate both path resolution errors and serde_json::to_string / std::fs::write
errors by returning a tauri::Result (use ? or map_err to convert errors into
tauri::Error/String) instead of calling unwrap, ensuring functions mentioned
(settings_path, get_server_url, set_server_url) no longer call expect/unwrap and
surface recoverable errors to the Tauri command handler.
In `@interface/src/App.tsx`:
- Around line 27-31: The code uses hasConnected (from useServer) as a one-way
health latch which hides ConnectionScreen too early; instead introduce and use a
real bootstrap/ready signal that only flips after the app's initial data queries
succeed. Update useServer.tsx to expose a bootstrapped/serverReady boolean
(e.g., hasBootstrapped or isServerReady) that is set true only when initial
bootstrap queries complete, and in App.tsx replace the check (state !==
"connected" && !hasConnected) with (state !== "connected" && !hasBootstrapped)
or equivalent; ensure LiveContextProvider consumes the same bootstrap signal or
exposes a promise so ConnectionScreen remains until LiveContextProvider's
initial queries finish.
In `@interface/src/components/ConnectionScreen.tsx`:
- Around line 55-72: The close handler for command.on("close") currently only
treats non-zero codes as failures; update that handler to also handle clean
exits (code === 0) and null codes that occur before the readiness log is
observed by checking the current sidecar state (e.g., if sidecarState is
"starting" or not yet "running") and transitioning to a recoverable state (e.g.,
call setSidecarState("stopped") or setSidecarState("error") as appropriate) and
set a user-visible message via setSidecarError (include the code or "null" in
the message); make this change inside the command.on("close") callback so the
start button is re-enabled and users can retry if the sidecar exits before
command.stdout.on("data") ever reports "HTTP server listening".
In `@interface/src/hooks/useServer.tsx`:
- Around line 86-96: In loadPersistedUrl(): when running under IS_TAURI, treat
the Tauri IPC result value that acts as a default sentinel (e.g.
"http://localhost:19898") or empty string as “not persisted” so the code falls
back to localStorage; update the invoke handling in loadPersistedUrl to check
the returned url for truthiness AND that it is not the sentinel value (and not
the empty string) before returning it, otherwise continue to return
localStorage.getItem(STORAGE_KEY) so a real persisted URL in localStorage is
used.
- Around line 46-52: The normalizeUrl function currently preserves user-entered
paths which causes downstream desktop connections to become origin+path; update
normalizeUrl so it returns only the origin (scheme + host [+ port]) for a given
input: trim and default to DEFAULT_SERVER_URL, ensure protocol (add http:// when
missing), then parse the URL and return its origin (not pathname/search/hash).
Refer to the normalizeUrl function to implement this change so desktop
connections use origin-only URLs.
In `@scripts/bundle-sidecar.sh`:
- Around line 18-33: The script computes TARGET_TRIPLE but doesn't pass it to
cargo or use it in SRC_BIN/DEST_BIN; update the cargo invocation (the cargo
build call where CARGO_FLAGS and BUILD_MODE are used) to include --target
"$TARGET_TRIPLE" when TARGET_TRIPLE is set, and change SRC_BIN (and any
subsequent DEST_BIN references) to point to
target/$TARGET_TRIPLE/$BUILD_MODE/spacebot instead of
target/$BUILD_MODE/spacebot so the built binary path matches cross-compile
output; ensure this logic respects BUILD_MODE and CARGO_FLAGS variables and only
adds --target when TARGET_TRIPLE is non-empty.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9855dc36-50d9-4c3f-b55c-77f723461437
⛔ Files ignored due to path filters (7)
desktop/src-tauri/capabilities/default.jsonis excluded by!**/*.jsondesktop/src-tauri/gen/schemas/capabilities.jsonis excluded by!**/gen/**,!**/*.json,!**/gen/**desktop/src-tauri/tauri.conf.jsonis excluded by!**/*.jsondocs/content/docs/(getting-started)/meta.jsonis excluded by!**/*.jsoninterface/bun.lockis excluded by!**/*.lock,!**/*.lockinterface/package.jsonis excluded by!**/*.jsoninterface/public/ball.pngis excluded by!**/*.png,!**/*.png
📒 Files selected for processing (15)
.gitignoredesktop/src-tauri/.gitignoredesktop/src-tauri/src/main.rsdocs/content/docs/(getting-started)/desktop.mdxdocs/content/docs/(getting-started)/quickstart.mdxinterface/src/App.tsxinterface/src/api/client.tsinterface/src/components/ConnectionScreen.tsxinterface/src/components/Orb.cssinterface/src/components/Orb.tsxinterface/src/hooks/useLiveContext.tsxinterface/src/hooks/useServer.tsxjustfilescripts/bundle-sidecar.shsrc/api/server.rs
| // Show connection screen if we've never connected, or if we lost | ||
| // connection before any data was loaded. | ||
| if (state !== "connected" && !hasConnected) { | ||
| return <ConnectionScreen />; | ||
| } |
There was a problem hiding this comment.
Use a real bootstrap signal here.
hasConnected is only a one-way health-check latch in interface/src/hooks/useServer.tsx, while LiveContextProvider immediately starts real data queries once this branch mounts. A brief /api/health success is enough to hide ConnectionScreen even if the first app queries fail, which leaves the user in the main shell with no way to change the server URL or restart the sidecar.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/App.tsx` around lines 27 - 31, The code uses hasConnected (from
useServer) as a one-way health latch which hides ConnectionScreen too early;
instead introduce and use a real bootstrap/ready signal that only flips after
the app's initial data queries succeed. Update useServer.tsx to expose a
bootstrapped/serverReady boolean (e.g., hasBootstrapped or isServerReady) that
is set true only when initial bootstrap queries complete, and in App.tsx replace
the check (state !== "connected" && !hasConnected) with (state !== "connected"
&& !hasBootstrapped) or equivalent; ensure LiveContextProvider consumes the same
bootstrap signal or exposes a promise so ConnectionScreen remains until
LiveContextProvider's initial queries finish.
- Remove expect/unwrap panics in Tauri command handlers; settings_path now returns Result and errors propagate gracefully - Handle clean (code 0) and null sidecar exits before readiness so the start button re-enables and users can retry - Replace hasConnected one-way health latch with hasBootstrapped signal that waits for LiveContextProvider's initial queries to resolve - Strip normalizeUrl to origin-only so user-entered paths don't break desktop API connections - Treat Tauri IPC default sentinel as 'not persisted' so localStorage fallback is reachable - Pass --target to cargo and fix SRC_BIN path in bundle-sidecar.sh for cross-compilation correctness - Move clearTimeout into finally block in checkHealth - Scope sidecar shell args to ["start", "--foreground"] instead of true - Remove allow_credentials(true) from CORS layer (Bearer auth only)
Summary
bundle-sidecar.shcompiles the binary and copies it with the target-triple suffix Tauri expects. Shell permissions added for spawn/stdin/kill.ConnectionScreengates the Tauri app until a server is reachable. Users can edit the server URL or start the bundled sidecar with one click. Server URL is persisted via Tauri IPC (connection.json) with localStorage fallback.client.tsfrom a staticAPI_BASEconstant togetApiBase()/setServerUrl()so cross-origin requests fromtauri://localhostwork correctly.eventsUrlis nowgetEventsUrl().useServerhook: New React context (ServerProvider) manages server lifecycle — health polling, sidecar process management, and connection state for Tauri mode. Same-origin mode (web/hosted/Vite dev) skips the connection screen entirely.Orb.tsx) using theogllibrary, shown on the connection screen.beforeDevCommandandbeforeBuildCommandintauri.conf.jsonnow automate sidecar bundling + frontend build. Newjust desktop-dev/just desktop-buildrecipes.get_server_url/set_server_urlindesktop/src-tauri/src/main.rspersist the server URL in the app data directory.docs/content/docs/(getting-started)/desktop.mdxcovering connection screen, sidecar, building from source, architecture details, and CORS. Updated quickstart with desktop references.ball.png(218K → 135K), corrected Vite dev server port in quickstart docs (3000 → 19840).Note
This PR adds desktop application support to Spacebot via Tauri. The core achievement is bundling the Rust server as a sidecar binary that the desktop app can spawn locally, with a connection screen that handles server discovery and lifecycle. The new
useServerhook abstracts server management (health checks, process spawning) from the UI, while the dynamic API base URL refactor makes cross-origin requests work seamlessly in Tauri'stauri://protocol. Key frontend additions include a 3D orb visualization and persistent server URL configuration.Written by Tembo for commit ddbe6ef. This will update automatically on new commits.