diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs
index 88ea71a..cfe3cb0 100644
--- a/src-tauri/src/commands/chat.rs
+++ b/src-tauri/src/commands/chat.rs
@@ -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?;
diff --git a/src-tauri/src/commands/identity.rs b/src-tauri/src/commands/identity.rs
index 93fdef5..b0911fe 100644
--- a/src-tauri/src/commands/identity.rs
+++ b/src-tauri/src/commands/identity.rs
@@ -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::()
+ .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> {
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 77bd45c..73d07f1 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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,
diff --git a/src-tauri/src/node/mod.rs b/src-tauri/src/node/mod.rs
index 30e4c79..c3513d1 100644
--- a/src-tauri/src/node/mod.rs
+++ b/src-tauri/src/node/mod.rs
@@ -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 {
+// 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 {
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,
app_handle: tauri::AppHandle,
voice_channels: VoiceChannelMap,
+ custom_relay_addr: Option,
) -> Result {
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 {
diff --git a/src-tauri/src/storage/disk.rs b/src-tauri/src/storage/disk.rs
index 1b714c6..541c1a7 100644
--- a/src-tauri/src/storage/disk.rs
+++ b/src-tauri/src/storage/disk.rs
@@ -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,
}
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(),
}
}
diff --git a/src/components/auth/SplashScreen.tsx b/src/components/auth/SplashScreen.tsx
index bfc9385..60394e4 100644
--- a/src/components/auth/SplashScreen.tsx
+++ b/src/components/auth/SplashScreen.tsx
@@ -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 = (props) => {
const [retrying, setRetrying] = createSignal(false);
// null = not checked yet, true = internet works, false = no internet
const [hasInternet, setHasInternet] = createSignal(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 = (props) => {
{connectivityHint()}
+
+ {/* show relay picker button if relay is unreachable but internet works */}
+
+
+
+ the default relay may be at capacity or offline
+
+
+
+
+
+ {/* relay picker modal */}
+
+
+
+
select a relay
+
+ choose an alternative relay server or enter a custom address
+
+
+
+
+ {(relay) => (
+
+ )}
+
+
+
+
+
+ setCustomRelay(e.currentTarget.value)}
+ />
+
+
+
+
+
+
+
+
+
+
connected to dusk chat, welcome {props.identity?.display_name}!
diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts
index 12a9dd7..88e634c 100644
--- a/src/lib/tauri.ts
+++ b/src/lib/tauri.ts
@@ -206,6 +206,10 @@ export async function discoverGlobalPeers(): Promise {
return invoke("discover_global_peers");
}
+export async function setRelayAddress(relayAddr: string): Promise {
+ return invoke("set_relay_address", { relayAddr });
+}
+
export async function resetIdentity(): Promise {
return invoke("reset_identity");
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 2e5c19f..1982231 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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 {