feat(relay): add custom relay address handling and UI for selection

This commit is contained in:
cloudwithax 2026-02-14 22:14:43 -05:00
parent b4f75cd995
commit ed7e6d6239
8 changed files with 190 additions and 4 deletions

View File

@ -17,12 +17,20 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re
.as_ref()
.ok_or("no identity loaded, create one first")?;
// load custom relay address from settings if configured
let custom_relay = state
.storage
.load_settings()
.ok()
.and_then(|s| s.custom_relay_addr);
let handle = node::start(
id.keypair.clone(),
state.crdt_engine.clone(),
state.storage.clone(),
app,
state.voice_channels.clone(),
custom_relay,
)
.await?;

View File

@ -274,6 +274,42 @@ pub async fn discover_global_peers(state: State<'_, AppState>) -> Result<(), Str
Ok(())
}
// change relay address and restart the node
// used when default relay is unreachable or at capacity
#[tauri::command]
pub async fn set_relay_address(
app: tauri::AppHandle,
state: State<'_, AppState>,
relay_addr: String,
) -> Result<(), String> {
// validate the relay address format
let _ = relay_addr
.parse::<libp2p::Multiaddr>()
.map_err(|_| "invalid relay address format")?;
// stop the current node if running
{
let mut node_handle = state.node_handle.lock().await;
if let Some(handle) = node_handle.take() {
let _ = handle.command_tx.send(crate::node::NodeCommand::Shutdown).await;
let _ = handle.task.await;
}
}
// update settings with the new relay address
let mut settings = state.storage.load_settings().unwrap_or_default();
settings.custom_relay_addr = Some(relay_addr);
state
.storage
.save_settings(&settings)
.map_err(|e| format!("failed to save settings: {}", e))?;
// restart the node with the new relay
crate::commands::chat::start_node(app, state).await?;
Ok(())
}
// broadcast a revocation to all peers, stop the node, and wipe all local data
#[tauri::command]
pub async fn reset_identity(state: State<'_, AppState>) -> Result<(), String> {

View File

@ -90,6 +90,7 @@ pub fn run() {
commands::identity::add_friend,
commands::identity::remove_friend,
commands::identity::discover_global_peers,
commands::identity::set_relay_address,
commands::identity::reset_identity,
commands::chat::send_message,
commands::chat::get_messages,

View File

@ -27,11 +27,13 @@ const PENDING_QUEUE_TTL_SECS: u64 = 600;
// prevents banner flashing on transient disconnections
const RELAY_WARN_GRACE_SECS: u64 = 8;
// resolve the relay multiaddr from env or default
fn relay_addr() -> Option<libp2p::Multiaddr> {
// resolve the relay multiaddr from env var, custom setting, or default
// priority: DUSK_RELAY_ADDR env var > custom setting > DEFAULT_RELAY_ADDR
fn relay_addr(custom_addr: Option<&str>) -> Option<libp2p::Multiaddr> {
let addr_str = std::env::var("DUSK_RELAY_ADDR")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| custom_addr.map(|s| s.to_string()))
.or_else(|| {
if DEFAULT_RELAY_ADDR.is_empty() {
None
@ -186,6 +188,7 @@ pub async fn start(
storage: Arc<crate::storage::DiskStorage>,
app_handle: tauri::AppHandle,
voice_channels: VoiceChannelMap,
custom_relay_addr: Option<String>,
) -> Result<NodeHandle, String> {
let mut swarm_instance =
swarm::build_swarm(&keypair).map_err(|e| format!("failed to build swarm: {}", e))?;
@ -207,7 +210,7 @@ pub async fn start(
);
// resolve the relay address for WAN connectivity
let relay_multiaddr = relay_addr();
let relay_multiaddr = relay_addr(custom_relay_addr.as_deref());
let relay_peer_id = relay_multiaddr.as_ref().and_then(peer_id_from_multiaddr);
// if a relay is configured, dial it immediately
@ -218,7 +221,13 @@ pub async fn start(
log::info!("dialing relay at {}", addr);
if let Err(e) = swarm_instance.dial(addr.clone()) {
log::warn!("failed to dial relay: {}", e);
// emit disconnected status immediately if dial fails
let _ = app_handle.emit("dusk-event", DuskEvent::RelayStatus { connected: false });
}
} else {
// if relay address is invalid or not configured, emit disconnected status
log::warn!("no valid relay address configured, running in LAN-only mode");
let _ = app_handle.emit("dusk-event", DuskEvent::RelayStatus { connected: false });
}
let task = tauri::async_runtime::spawn(async move {

View File

@ -22,6 +22,8 @@ pub struct UserSettings {
pub allow_dms_from_anyone: bool,
pub message_display: String,
pub font_size: String,
#[serde(default)]
pub custom_relay_addr: Option<String>,
}
impl Default for UserSettings {
@ -36,6 +38,7 @@ impl Default for UserSettings {
show_online_status: true,
allow_dms_from_anyone: true,
message_display: "cozy".to_string(),
custom_relay_addr: None,
font_size: "default".to_string(),
}
}

View File

@ -5,9 +5,11 @@ import {
onMount,
Show,
onCleanup,
For,
} from "solid-js";
import type { PublicIdentity } from "../../lib/types";
import { checkInternetConnectivity } from "../../lib/tauri";
import { checkInternetConnectivity, setRelayAddress } from "../../lib/tauri";
import Button from "../common/Button";
interface SplashScreenProps {
onComplete: () => void;
@ -23,6 +25,24 @@ const SplashScreen: Component<SplashScreenProps> = (props) => {
const [retrying, setRetrying] = createSignal(false);
// null = not checked yet, true = internet works, false = no internet
const [hasInternet, setHasInternet] = createSignal<boolean | null>(null);
const [showRelayPicker, setShowRelayPicker] = createSignal(false);
const [customRelay, setCustomRelay] = createSignal("");
// alternative public relays (would be populated from a discovery service in production)
const alternativeRelays = [
{
name: "primary relay (default)",
addr: "/dns4/relay.duskchat.app/tcp/4001/p2p/12D3KooWGQkCkACcibJPKzus7Q6U1aYngfTuS4gwYwmJkJJtrSaw",
},
{
name: "us-west relay",
addr: "/dns4/relay-us-west.duskchat.app/tcp/4001/p2p/12D3KooWExample1",
},
{
name: "eu-central relay",
addr: "/dns4/relay-eu.duskchat.app/tcp/4001/p2p/12D3KooWExample2",
},
];
// refs for exit SMIL animations so we can trigger them programmatically
let exitOrangeCx: SVGAnimateElement | undefined;
@ -230,6 +250,108 @@ const SplashScreen: Component<SplashScreenProps> = (props) => {
{connectivityHint()}
</p>
</Show>
{/* show relay picker button if relay is unreachable but internet works */}
<Show when={retrying() && hasInternet()}>
<div class="flex flex-col items-center gap-3 mt-4">
<p class="text-white/50 text-xs font-sans">
the default relay may be at capacity or offline
</p>
<Button
variant="secondary"
size="sm"
onClick={() => setShowRelayPicker(true)}
>
choose a different relay
</Button>
</div>
</Show>
{/* relay picker modal */}
<Show when={showRelayPicker()}>
<div class="fixed inset-0 z-10000 bg-black/90 flex items-center justify-center">
<div class="bg-gray-900 border-2 border-white/20 p-6 max-w-md w-full mx-4">
<h2 class="text-white text-lg font-sans mb-4">select a relay</h2>
<p class="text-white/60 text-sm font-sans mb-6">
choose an alternative relay server or enter a custom address
</p>
<div class="space-y-2 mb-6">
<For each={alternativeRelays}>
{(relay) => (
<button
class="w-full text-left px-4 py-3 bg-gray-800 hover:bg-gray-700 border border-white/10 hover:border-accent transition-colors"
onClick={async () => {
try {
await setRelayAddress(relay.addr);
setShowRelayPicker(false);
// restart the connection cycle
setRetrying(false);
setHasInternet(null);
startCycle();
} catch (e) {
console.error("failed to switch relay:", e);
}
}}
>
<div class="text-white text-sm font-sans">{relay.name}</div>
<div class="text-white/40 text-xs font-mono mt-1 truncate">
{relay.addr}
</div>
</button>
)}
</For>
</div>
<div class="mb-4">
<label class="text-white/60 text-xs font-sans mb-2 block">
or enter custom relay address
</label>
<input
type="text"
class="w-full bg-gray-800 border border-white/10 px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-accent"
placeholder="/dns4/relay.example.com/tcp/4001/p2p/12D3..."
value={customRelay()}
onInput={(e) => setCustomRelay(e.currentTarget.value)}
/>
</div>
<div class="flex gap-3">
<Button
variant="secondary"
size="sm"
onClick={() => setShowRelayPicker(false)}
class="flex-1"
>
cancel
</Button>
<Button
variant="primary"
size="sm"
disabled={!customRelay().trim()}
onClick={async () => {
try {
await setRelayAddress(customRelay());
setShowRelayPicker(false);
setCustomRelay("");
// restart the connection cycle
setRetrying(false);
setHasInternet(null);
startCycle();
} catch (e) {
console.error("failed to switch to custom relay:", e);
alert(`Invalid relay address: ${e}`);
}
}}
class="flex-1"
>
connect
</Button>
</div>
</div>
</div>
</Show>
<Show when={showWelcome() && props.identity}>
<p class="text-white/60 text-sm font-sans">
connected to dusk chat, welcome {props.identity?.display_name}!

View File

@ -206,6 +206,10 @@ export async function discoverGlobalPeers(): Promise<void> {
return invoke("discover_global_peers");
}
export async function setRelayAddress(relayAddr: string): Promise<void> {
return invoke("set_relay_address", { relayAddr });
}
export async function resetIdentity(): Promise<void> {
return invoke("reset_identity");
}

View File

@ -65,6 +65,9 @@ export interface UserSettings {
// appearance
message_display: "cozy" | "compact";
font_size: "small" | "default" | "large";
// network
custom_relay_addr?: string;
}
export interface CommunityMeta {