Add desktop local relay sidecar flow

Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
This commit is contained in:
npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh
2026-08-10 12:24:32 -04:00
committed by Brother Darryl
parent 17c4ad00db
commit fd168e7952
13 changed files with 414 additions and 16 deletions
+4 -3
View File
@@ -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
+5 -2
View File
@@ -51,8 +51,11 @@ pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String>
}
#[tauri::command]
pub fn get_default_relay_url() -> String {
relay::relay_ws_url()
pub fn get_default_relay_url(state: State<'_, AppState>) -> Result<String, String> {
// 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]
+25 -2
View File
@@ -135,7 +135,9 @@ pub async fn apply_workspace(
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
// ── 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::RuntimeState>();
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::<crate::local_relay::RuntimeState>());
}
// 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::<crate::local_relay::RuntimeState>())
.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).
+1
View File
@@ -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;
+323
View File
@@ -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<Option<LocalRelayRuntime>>;
#[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<u16>,
) -> Result<LocalRelayRuntime, String> {
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<u16>,
) -> Result<String, String> {
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<String> {
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<PathBuf, String> {
// 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<u16, String> {
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<PathBuf, String> {
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())
);
}
}
+2 -1
View File
@@ -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",
@@ -4,6 +4,9 @@ import test from "node:test";
import {
clearCommunityStorage,
initFirstCommunity,
isLocalCommunityRelayUrl,
LOCAL_COMMUNITY_NAME,
LOCAL_COMMUNITY_RELAY_URL,
loadCommunities,
loadCommunityDiscoveryAfterLeave,
markCommunityDiscoveryAfterLeave,
@@ -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)
@@ -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
@@ -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({
</p>
</div>
<div className="flex w-full flex-1 translate-y-16 flex-col items-center justify-center gap-20 py-8">
<Card
asChild
className={COMMUNITY_OPTION_CARD_CLASS}
variant="textured"
>
<button
data-testid="community-choice-local"
onClick={startLocalCommunity}
type="button"
>
Use this device
</button>
</Card>
<Card
asChild
className={COMMUNITY_OPTION_CARD_CLASS}
@@ -36,6 +36,7 @@ import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
import {
initFirstCommunity,
LOCAL_COMMUNITY_RELAY_URL,
shouldAutoConnectDefaultRelay,
} from "./communityStorage";
import type { Community } from "./types";
@@ -137,9 +138,8 @@ export function useCommunityInit(
const autoConnectDefaultRelay =
await autoConnectDefaultRelayEnabled();
// Internal builds explicitly opt into treating their reviewed default
// relay as the first community. Public builds retain community
// selection even when BUZZ_RELAY_URL is overridden at runtime.
// Hosted defaults retain their existing opt-in behavior. Local mode
// is created only through the explicit first-run choice.
if (
!suppressAutoConnect &&
(isSharedIdentity ||
@@ -226,7 +226,9 @@ export function useCommunityInit(
// legacy entries; this site refuses to apply one even if present.
try {
await applyCommunity(
activeCommunity.relayUrl,
activeCommunity.local
? LOCAL_COMMUNITY_RELAY_URL
: activeCommunity.relayUrl,
undefined,
activeCommunity.token,
activeCommunity.reposDir,
@@ -300,6 +302,7 @@ export function useCommunityInit(
activeCommunity?.relayUrl,
activeCommunity?.token,
activeCommunity?.reposDir,
activeCommunity?.local,
isSharedIdentity,
suppressAutoConnect,
communityKey,
@@ -1,5 +1,6 @@
import {
deriveCommunityName,
isLocalCommunityRelayUrl,
normalizeRelayUrl,
} from "@/features/communities/communityStorage";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
@@ -84,6 +85,7 @@ export type StartCommunityOnboardingInput = {
function canonicalRelayUrl(rawRelayUrl: string) {
const trimmed = rawRelayUrl.trim();
if (isLocalCommunityRelayUrl(trimmed)) return trimmed;
const withScheme = /^(ws|wss):\/\//i.test(trimmed)
? trimmed
: normalizeRelayUrl(trimmed);
+3 -3
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
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)
HOST=$(rustc -vV | sed -n 's|host: ||p')
TARGET=${1:-$HOST}
if [[ "$TARGET" != *windows* ]]; then
SIDECARS+=(buzz-backend-kubernetes)
BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli"
BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli -p buzz-relay"
else
BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli"
BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli -p buzz-relay"
fi
BINARIES_DIR="desktop/src-tauri/binaries"