diff --git a/Justfile b/Justfile index 0a43249d5..bff1c48fc 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,7 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) + SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz buzz-relay) if [[ "$TARGET" != *windows* ]]; then SIDECARS+=(buzz-backend-kubernetes) fi @@ -258,6 +258,7 @@ desktop-release-build target="aarch64-apple-darwin": touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" + touch "desktop/src-tauri/binaries/buzz-relay-$TARGET" pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} @@ -503,10 +504,10 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" - cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz buzz-relay; do cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85..d6350a7ee 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -51,8 +51,11 @@ pub fn get_identity(state: State<'_, AppState>) -> Result } #[tauri::command] -pub fn get_default_relay_url() -> String { - relay::relay_ws_url() +pub fn get_default_relay_url(state: State<'_, AppState>) -> Result { + // A hosted default remains just that: resolving it never spawns an unused + // local sidecar. The explicit local workspace sentinel starts its sidecar + // during `apply_workspace`. + Ok(relay::relay_ws_url_with_override(&state)) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39..f7b022d04 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -135,7 +135,9 @@ pub async fn apply_workspace( tokio::task::spawn_blocking(move || { let state = app.state::(); - // ── Validate before mutating ────────────────────────────────────────── + // Validate credentials before starting a local sidecar: an onboarding + // import may replace the in-memory identity, and the relay's durable + // nest/owner must match the identity that this workspace will use. let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(nsec_trimmed) => { Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) @@ -143,6 +145,18 @@ pub async fn apply_workspace( None => None, }; + if crate::local_relay::is_local_relay_url(&relay_url) { + // Only the UI-created local sentinel can launch the bundled relay. + // An arbitrary loopback URL remains a normal remote community. + let keys = parsed_keys.clone().unwrap_or(state.signing_keys()?); + let runtime = app.state::(); + crate::local_relay::ensure_started(&app, &runtime, &keys, None)?; + } else { + // A desktop-owned relay is private to the active local workspace; + // do not leave it serving after a switch to any remote workspace. + crate::local_relay::stop(&app.state::()); + } + // Decide the effective repos_dir from the candidate. A bad path does NOT // reject — it is treated as if no override were set: relay/keys still // apply, the bad value is not persisted, and a `repos-dir-error` surfaces @@ -165,8 +179,17 @@ pub async fn apply_workspace( // ── Apply all state changes (nothing below can fail) ────────────────── { + // The local sentinel persists across restarts while the sidecar + // endpoint is deliberately ephemeral. Resolve it only after + // `ensure_started` above, before any agent can be restored. + let effective_relay_url = if crate::local_relay::is_local_relay_url(&relay_url) { + crate::local_relay::relay_url(&app.state::()) + .ok_or("local relay did not start")? + } else { + relay_url + }; let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; - *override_guard = Some(relay_url); + *override_guard = Some(effective_relay_url); } // Reset the Rust-side admission gate when switching workspace/community, // matching `resetRateLimitGate()` on the TS side (useCommunityInit.ts:38). diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8..555fee19b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod initial_window; mod key_backup; mod link_preview_tags; mod linux_media; +mod local_relay; #[cfg(target_os = "macos")] mod macos_notifications; mod managed_agents; diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs new file mode 100644 index 000000000..c7d69e44c --- /dev/null +++ b/desktop/src-tauri/src/local_relay.rs @@ -0,0 +1,323 @@ +//! Lifecycle management for the embedded single-node relay. +//! +//! The relay remains the source of truth for local-mode behavior. Desktop only +//! chooses private loopback ports, supplies its identity and durable paths, and +//! supervises the bundled `buzz-relay` binary. + +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::Mutex, + time::{Duration, Instant}, +}; + +use nostr::Keys; +use tauri::{AppHandle, Manager}; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +pub(crate) struct LocalRelayRuntime { + child: Child, + url: String, + owner_pubkey: String, +} + +pub(crate) type RuntimeState = Mutex>; + +#[derive(Debug, PartialEq, Eq)] +struct LocalRelayConfig { + relay_addr: SocketAddr, + health_port: u16, + metrics_port: u16, + data_dir: PathBuf, + owner_pubkey: String, + relay_private_key: String, +} + +impl LocalRelayConfig { + fn url(&self) -> String { + format!("ws://{}", self.relay_addr) + } + + fn environment(&self) -> Vec<(&'static str, String)> { + let db_path = self.data_dir.join("relay.sqlite3"); + let media_dir = self.data_dir.join("media"); + vec![ + ("BUZZ_PROFILE", "single-node".to_string()), + ("BUZZ_BIND_ADDR", self.relay_addr.to_string()), + ("BUZZ_HEALTH_PORT", self.health_port.to_string()), + ("BUZZ_METRICS_PORT", self.metrics_port.to_string()), + ("RELAY_URL", self.url()), + ("BUZZ_LOCAL_DB", sqlite_url(&db_path)), + ("BUZZ_LOCAL_MEDIA_DIR", media_dir.display().to_string()), + ("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true".to_string()), + ("RELAY_OWNER_PUBKEY", self.owner_pubkey.clone()), + ("BUZZ_RELAY_PRIVATE_KEY", self.relay_private_key.clone()), + ] + } +} + +impl LocalRelayRuntime { + pub(crate) fn url(&self) -> &str { + &self.url + } + + fn stop(&mut self) { + // The relay handles SIGTERM with a WebSocket drain. It is our child, so + // waiting here prevents a restart from inheriting a stale listener. + #[cfg(unix)] + unsafe { + libc::kill(self.child.id() as i32, libc::SIGTERM); + } + #[cfg(windows)] + let _ = self.child.kill(); + + let deadline = Instant::now() + Duration::from_secs(8); + while Instant::now() < deadline { + if self.child.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Starts a relay for this desktop identity and waits for the public relay-info +/// endpoint. `Db::new_sqlite` in the relay applies its versioned SQLite +/// migrations before this endpoint can become available. +pub(crate) fn start( + app: &AppHandle, + keys: &Keys, + requested_port: Option, +) -> Result { + let relay_port = requested_port.unwrap_or(free_loopback_port()?); + let owner_pubkey = keys.public_key().to_hex(); + let config = LocalRelayConfig { + relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), relay_port), + health_port: free_loopback_port()?, + metrics_port: free_loopback_port()?, + data_dir: local_data_dir(app, &owner_pubkey)?, + owner_pubkey, + relay_private_key: keys.secret_key().to_secret_hex(), + }; + let url = config.url(); + let media_dir = config.data_dir.join("media"); + std::fs::create_dir_all(&media_dir) + .map_err(|e| format!("create local relay media dir: {e}"))?; + + let binary = relay_binary()?; + let mut command = Command::new(&binary); + command + .envs(config.environment()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = command + .spawn() + .map_err(|e| format!("start bundled buzz-relay at {}: {e}", binary.display()))?; + + let info_url = format!("http://{}/info", config.relay_addr); + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + if child + .try_wait() + .map_err(|e| format!("inspect local relay: {e}"))? + .is_some() + { + return Err("bundled buzz-relay exited before becoming ready".to_string()); + } + if reqwest::blocking::Client::new() + .get(&info_url) + .header("Accept", "application/nostr+json") + .send() + .is_ok_and(|response| response.status().is_success()) + { + return Ok(LocalRelayRuntime { + child, + url, + owner_pubkey: config.owner_pubkey, + }); + } + std::thread::sleep(POLL_INTERVAL); + } + + let _ = child.kill(); + let _ = child.wait(); + Err("bundled buzz-relay did not become ready within 15 seconds".to_string()) +} + +pub(crate) fn ensure_started( + app: &AppHandle, + runtime: &RuntimeState, + keys: &Keys, + requested_port: Option, +) -> Result { + let mut runtime = runtime.lock().map_err(|error| error.to_string())?; + if let Some(running) = runtime.as_ref() { + let same_port = requested_port.is_none_or(|port| running.url == local_relay_url(port)); + let same_owner = running.owner_pubkey == keys.public_key().to_hex(); + if same_port && same_owner { + return Ok(running.url().to_string()); + } + if !same_owner { + // An onboarding identity import changed the owner. Its local relay + // must use a separate identity-scoped nest, not retain the prior + // identity's private sidecar. + if let Some(mut previous) = runtime.take() { + previous.stop(); + } + } else { + return Err("a different local relay port is already running".to_string()); + } + } + let started = start(app, keys, requested_port)?; + let url = started.url().to_string(); + *runtime = Some(started); + Ok(url) +} + +pub(crate) fn stop(runtime: &RuntimeState) { + if let Ok(mut runtime) = runtime.lock() { + if let Some(mut runtime) = runtime.take() { + runtime.stop(); + } + } +} + +/// The persisted sentinel for the desktop-owned local workspace. It is never a +/// network endpoint: `apply_workspace` resolves it to the current supervised +/// loopback URL before any client or managed agent is restored. +pub(crate) const LOCAL_RELAY_SENTINEL: &str = "buzz-local://on-this-device"; + +pub(crate) fn is_local_relay_url(url: &str) -> bool { + url == LOCAL_RELAY_SENTINEL +} + +fn local_relay_url(port: u16) -> String { + format!("ws://127.0.0.1:{port}") +} + +pub(crate) fn relay_url(runtime: &RuntimeState) -> Option { + runtime + .lock() + .ok() + .and_then(|runtime| runtime.as_ref().map(|runtime| runtime.url().to_string())) +} + +fn local_data_dir(app: &AppHandle, owner_pubkey: &str) -> Result { + // Relay state is both durable and identity-scoped. A different desktop + // identity must never inherit this identity's SQLite membership or media. + app.path() + .app_data_dir() + .map(|path| path.join("local-relay").join(owner_pubkey)) + .map_err(|e| format!("resolve app data directory for local relay: {e}")) +} + +fn sqlite_url(path: &Path) -> String { + // sqlx accepts an absolute sqlite URL with three slashes. Path display is + // intentional: app-data paths come from the OS, not user relay input. + format!("sqlite://{}", path.display()) +} + +fn free_loopback_port() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").map_err(|e| format!("pick local relay port: {e}"))?; + listener + .local_addr() + .map(|address| address.port()) + .map_err(|e| format!("read local relay port: {e}")) +} + +fn relay_binary() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("resolve desktop executable: {e}"))?; + let parent = exe + .parent() + .ok_or("desktop executable has no parent directory")?; + let name = if cfg!(windows) { + "buzz-relay.exe" + } else { + "buzz-relay" + }; + let binary = parent.join(name); + if binary.is_file() { + Ok(binary) + } else { + Err(format!( + "bundled buzz-relay is missing at {}", + binary.display() + )) + } +} + +#[cfg(test)] +mod tests { + use super::{ + is_local_relay_url, local_relay_url, sqlite_url, LocalRelayConfig, LOCAL_RELAY_SENTINEL, + }; + use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, + }; + + #[test] + fn sqlite_url_is_absolute() { + assert_eq!( + sqlite_url(Path::new("/tmp/buzz.sqlite3")), + "sqlite:///tmp/buzz.sqlite3" + ); + } + + #[test] + fn local_relay_sentinel_is_the_only_managed_workspace_address() { + assert_eq!(local_relay_url(4317), "ws://127.0.0.1:4317"); + assert!(is_local_relay_url(LOCAL_RELAY_SENTINEL)); + assert!(!is_local_relay_url("ws://127.0.0.1:4317")); + assert!(!is_local_relay_url("ws://localhost:4317")); + assert!(!is_local_relay_url("wss://127.0.0.1:4317")); + } + + #[test] + fn local_relay_environment_uses_only_the_single_node_contract() { + let config = LocalRelayConfig { + relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4317), + health_port: 4318, + metrics_port: 4319, + data_dir: PathBuf::from("/tmp/buzz/local-relay"), + owner_pubkey: "owner".to_string(), + relay_private_key: "private".to_string(), + }; + let env: HashMap<_, _> = config.environment().into_iter().collect(); + + assert_eq!(env.get("BUZZ_PROFILE"), Some(&"single-node".to_string())); + assert_eq!( + env.get("BUZZ_BIND_ADDR"), + Some(&"127.0.0.1:4317".to_string()) + ); + assert_eq!( + env.get("RELAY_URL"), + Some(&"ws://127.0.0.1:4317".to_string()) + ); + assert_eq!( + env.get("BUZZ_LOCAL_DB"), + Some(&"sqlite:///tmp/buzz/local-relay/relay.sqlite3".to_string()) + ); + assert_eq!( + env.get("BUZZ_LOCAL_MEDIA_DIR"), + Some(&"/tmp/buzz/local-relay/media".to_string()) + ); + assert_eq!( + env.get("BUZZ_REQUIRE_RELAY_MEMBERSHIP"), + Some(&"true".to_string()) + ); + assert_eq!(env.get("RELAY_OWNER_PUBKEY"), Some(&"owner".to_string())); + assert_eq!( + env.get("BUZZ_RELAY_PRIVATE_KEY"), + Some(&"private".to_string()) + ); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 3f332ddbf..75d7c7c59 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -58,7 +58,8 @@ "binaries/buzz-backend-kubernetes", "binaries/buzz-dev-mcp", "binaries/git-credential-nostr", - "binaries/buzz" + "binaries/buzz", + "binaries/buzz-relay" ], "icon": [ "icons/32x32.png", diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs index fca190913..73d9c01e4 100644 --- a/desktop/src/features/communities/communityStorage.test.mjs +++ b/desktop/src/features/communities/communityStorage.test.mjs @@ -4,6 +4,9 @@ import test from "node:test"; import { clearCommunityStorage, initFirstCommunity, + isLocalCommunityRelayUrl, + LOCAL_COMMUNITY_NAME, + LOCAL_COMMUNITY_RELAY_URL, loadCommunities, loadCommunityDiscoveryAfterLeave, markCommunityDiscoveryAfterLeave, diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts index 6f99ce8d3..1cf2033be 100644 --- a/desktop/src/features/communities/communityStorage.ts +++ b/desktop/src/features/communities/communityStorage.ts @@ -10,6 +10,13 @@ const LEGACY_ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id"; const COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY = "buzz-community-discovery-after-leave"; +export const LOCAL_COMMUNITY_RELAY_URL = "buzz-local://on-this-device"; +export const LOCAL_COMMUNITY_NAME = "On this device"; + +export function isLocalCommunityRelayUrl(relayUrl: string): boolean { + return relayUrl === LOCAL_COMMUNITY_RELAY_URL; +} + /** * Expand a leading `~` to the user's home directory. The backend rejects * `~`-prefixed paths (`std::fs` does not expand the shell tilde), so the UI @@ -186,13 +193,16 @@ export function shouldAutoConnectDefaultRelay(relayUrl: string): boolean { } export function deriveCommunityName(relayUrl: string): string { + if (isLocalCommunityRelayUrl(relayUrl)) { + return LOCAL_COMMUNITY_NAME; + } try { const url = new URL( relayUrl.replace("ws://", "http://").replace("wss://", "https://"), ); const host = url.hostname; if (isLocalRelayHost(host)) { - return "Local Dev"; + return "On this device"; } const parts = host.split("."); // Detect staging environments (e.g. buzz-oss.stage.blox.sqprod.co) diff --git a/desktop/src/features/communities/types.ts b/desktop/src/features/communities/types.ts index 7087ca7ce..620b84056 100644 --- a/desktop/src/features/communities/types.ts +++ b/desktop/src/features/communities/types.ts @@ -17,6 +17,8 @@ export type Community = { * `REPOS` directory inside the nest. */ reposDir?: string; + /** True only for the relay supervised by this desktop instance. */ + local?: boolean; /** * @deprecated Never read. Kept on the type so old localStorage entries * deserialise without errors. New entries never set this field, and diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 530933acc..21859c653 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { Check, Copy } from "lucide-react"; +import { + LOCAL_COMMUNITY_NAME, + LOCAL_COMMUNITY_RELAY_URL, +} from "@/features/communities/communityStorage"; import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; @@ -92,6 +96,15 @@ export function WelcomeSetup({ [communityOnboarding, page], ); + const startLocalCommunity = React.useCallback(() => { + communityOnboarding.start({ + source: "first-community", + firstCommunityPage: "join", + communityName: LOCAL_COMMUNITY_NAME, + relayUrl: LOCAL_COMMUNITY_RELAY_URL, + }); + }, [communityOnboarding]); + const transitionDirection = transitionMode === "backward" ? "backward" : "forward"; const welcomeEffect = @@ -124,6 +137,19 @@ export function WelcomeSetup({

+ + +