mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add desktop local relay sidecar flow
Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
This commit is contained in:
parent
17c4ad00db
commit
fd168e7952
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user