Compare commits
5 Commits
31cdd4932b
...
044b5ca111
| Author | SHA1 | Date |
|---|---|---|
|
|
044b5ca111 | |
|
|
ef6114a751 | |
|
|
72a82c1a11 | |
|
|
cb45e2e463 | |
|
|
c01dc94338 |
|
|
@ -254,12 +254,15 @@ pub async fn search_directory(
|
|||
query: String,
|
||||
) -> Result<Vec<DirectoryEntry>, String> {
|
||||
ipc_log!("search_directory", {
|
||||
let query_trimmed = query.trim().to_string();
|
||||
|
||||
// local search first
|
||||
let entries = state
|
||||
.storage
|
||||
.load_directory()
|
||||
.map_err(|e| format!("failed to load directory: {}", e))?;
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_lower = query_trimmed.to_lowercase();
|
||||
let mut results: Vec<DirectoryEntry> = entries
|
||||
.into_values()
|
||||
.filter(|entry| {
|
||||
|
|
@ -267,8 +270,63 @@ pub async fn search_directory(
|
|||
|| entry.peer_id.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
|
||||
|
||||
// relay fallback when local results are sparse
|
||||
if results.len() < 5 && !query_trimmed.is_empty() {
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(crate::node::NodeCommand::DirectorySearch {
|
||||
query: query_trimmed.clone(),
|
||||
reply: tx,
|
||||
})
|
||||
.await;
|
||||
drop(node_handle);
|
||||
|
||||
// wait up to 5 seconds for relay response
|
||||
if let Ok(Ok(Ok(relay_entries))) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), rx).await
|
||||
{
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
for entry in relay_entries {
|
||||
// upsert as stub — empty bio/public_key means never directly connected
|
||||
let stub = DirectoryEntry {
|
||||
peer_id: entry.peer_id.clone(),
|
||||
display_name: entry.display_name,
|
||||
bio: String::new(),
|
||||
public_key: String::new(),
|
||||
last_seen: entry.last_seen.saturating_mul(1000).max(now - 86_400_000),
|
||||
is_friend: false,
|
||||
};
|
||||
// preserve existing local data if we already know this peer
|
||||
let _ = state.storage.save_directory_entry_if_new(&stub);
|
||||
}
|
||||
|
||||
// re-run local search to get merged results
|
||||
let entries2 = state
|
||||
.storage
|
||||
.load_directory()
|
||||
.unwrap_or_default();
|
||||
let mut results2: Vec<DirectoryEntry> = entries2
|
||||
.into_values()
|
||||
.filter(|entry| {
|
||||
entry.display_name.to_lowercase().contains(&query_lower)
|
||||
|| entry.peer_id.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.collect();
|
||||
results2.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
|
||||
return Ok(results2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
|
@ -329,6 +387,33 @@ pub async fn discover_global_peers(state: State<'_, AppState>) -> Result<(), Str
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// toggle relay discoverability at runtime and sync the setting to disk
|
||||
#[tauri::command]
|
||||
pub async fn set_relay_discoverable(
|
||||
state: State<'_, AppState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
ipc_log!("set_relay_discoverable", {
|
||||
// persist setting
|
||||
let mut settings = state.storage.load_settings().unwrap_or_default();
|
||||
settings.relay_discoverable = enabled;
|
||||
state
|
||||
.storage
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("failed to save settings: {}", e))?;
|
||||
|
||||
// notify running node
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(crate::node::NodeCommand::SetRelayDiscoverable { enabled })
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// change relay address and restart the node
|
||||
// used when default relay is unreachable or at capacity
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ pub fn run() {
|
|||
commands::identity::add_friend,
|
||||
commands::identity::remove_friend,
|
||||
commands::identity::discover_global_peers,
|
||||
commands::identity::set_relay_discoverable,
|
||||
commands::identity::set_relay_address,
|
||||
commands::identity::reset_identity,
|
||||
commands::identity::cache_avatar_icon,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::protocol::directory::{DirectoryRequest, DirectoryResponse};
|
||||
use crate::protocol::gif::{GifRequest, GifResponse};
|
||||
use libp2p::{
|
||||
gossipsub, identify, kad, mdns, ping, relay, rendezvous, request_response::cbor,
|
||||
|
|
@ -15,4 +16,6 @@ pub struct DuskBehaviour {
|
|||
pub ping: ping::Behaviour,
|
||||
// gif search: sends requests to the relay, receives responses
|
||||
pub gif_service: cbor::Behaviour<GifRequest, GifResponse>,
|
||||
// directory search: register/search/remove profiles on the relay
|
||||
pub directory_service: cbor::Behaviour<DirectoryRequest, DirectoryResponse>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,6 +208,21 @@ pub enum NodeCommand {
|
|||
request: crate::protocol::gif::GifRequest,
|
||||
reply: tokio::sync::oneshot::Sender<Result<crate::protocol::gif::GifResponse, String>>,
|
||||
},
|
||||
// register this peer's profile in the relay's persistent directory
|
||||
DirectoryRegister,
|
||||
// remove this peer's profile from the relay's directory
|
||||
DirectoryRemove,
|
||||
// search the relay's directory by display_name or peer_id
|
||||
DirectorySearch {
|
||||
query: String,
|
||||
reply: tokio::sync::oneshot::Sender<
|
||||
Result<Vec<crate::protocol::directory::DirectoryProfileEntry>, String>,
|
||||
>,
|
||||
},
|
||||
// update the relay_discoverable flag at runtime (from settings toggle)
|
||||
SetRelayDiscoverable {
|
||||
enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
// events emitted from the node to the tauri frontend
|
||||
|
|
@ -464,6 +479,20 @@ pub async fn start(
|
|||
tokio::sync::oneshot::Sender<Result<crate::protocol::gif::GifResponse, String>>,
|
||||
> = HashMap::new();
|
||||
|
||||
|
||||
// pending directory search replies keyed by request_response request id
|
||||
let mut pending_directory_replies: HashMap<
|
||||
libp2p::request_response::OutboundRequestId,
|
||||
tokio::sync::oneshot::Sender<
|
||||
Result<Vec<crate::protocol::directory::DirectoryProfileEntry>, String>,
|
||||
>,
|
||||
> = HashMap::new();
|
||||
|
||||
// relay_discoverable flag -- read from storage once at startup
|
||||
let mut relay_discoverable = storage
|
||||
.load_settings()
|
||||
.map(|s| s.relay_discoverable)
|
||||
.unwrap_or(true);
|
||||
// relay reconnection state with exponential backoff
|
||||
let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
|
||||
// deferred warning timer -- only notify the frontend after the grace
|
||||
|
|
@ -1035,6 +1064,18 @@ pub async fn start(
|
|||
// any WAN peers are reachable, so this ensures remote
|
||||
// peers learn about us once the relay mesh is live
|
||||
publish_profile(&mut swarm_instance, &node_keypair, &storage);
|
||||
|
||||
// register profile in relay's persistent directory if discoverable
|
||||
if relay_discoverable {
|
||||
let profile = storage.load_profile().unwrap_or_default();
|
||||
swarm_instance.behaviour_mut().directory_service.send_request(
|
||||
&relay_peer_id,
|
||||
crate::protocol::directory::DirectoryRequest::Register {
|
||||
display_name: profile.display_name,
|
||||
},
|
||||
);
|
||||
log::info!("directory: sent Register to relay");
|
||||
}
|
||||
}
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::RelayClient(event)) => {
|
||||
log::debug!("relay client event: {:?}", event);
|
||||
|
|
@ -1345,6 +1386,35 @@ pub async fn start(
|
|||
// ignore inbound requests (we only send outbound) and other events
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::GifService(_)) => {}
|
||||
|
||||
// directory service response from relay
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::DirectoryService(
|
||||
libp2p::request_response::Event::Message {
|
||||
message: libp2p::request_response::Message::Response { request_id, response },
|
||||
..
|
||||
}
|
||||
)) => {
|
||||
if let Some(reply) = pending_directory_replies.remove(&request_id) {
|
||||
match response {
|
||||
crate::protocol::directory::DirectoryResponse::Results(entries) => {
|
||||
let _ = reply.send(Ok(entries));
|
||||
}
|
||||
crate::protocol::directory::DirectoryResponse::Ok => {
|
||||
let _ = reply.send(Ok(vec![]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// directory service outbound failure
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::DirectoryService(
|
||||
libp2p::request_response::Event::OutboundFailure { request_id, error, .. }
|
||||
)) => {
|
||||
log::warn!("directory: outbound failure: {:?}", error);
|
||||
if let Some(reply) = pending_directory_replies.remove(&request_id) {
|
||||
let _ = reply.send(Err(format!("directory request failed: {:?}", error)));
|
||||
}
|
||||
}
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::DirectoryService(_)) => {}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1643,6 +1713,70 @@ pub async fn start(
|
|||
let _ = reply.send(Err("not connected to relay".to_string()));
|
||||
}
|
||||
}
|
||||
Some(NodeCommand::DirectoryRegister) => {
|
||||
if relay_reservation_active {
|
||||
if let Some(rp) = relay_peer {
|
||||
let profile = storage.load_profile().unwrap_or_default();
|
||||
swarm_instance.behaviour_mut().directory_service.send_request(
|
||||
&rp,
|
||||
crate::protocol::directory::DirectoryRequest::Register {
|
||||
display_name: profile.display_name,
|
||||
},
|
||||
);
|
||||
log::info!("directory: sent Register (command)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(NodeCommand::DirectoryRemove) => {
|
||||
if relay_reservation_active {
|
||||
if let Some(rp) = relay_peer {
|
||||
swarm_instance.behaviour_mut().directory_service.send_request(
|
||||
&rp,
|
||||
crate::protocol::directory::DirectoryRequest::Remove,
|
||||
);
|
||||
log::info!("directory: sent Remove (command)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(NodeCommand::DirectorySearch { query, reply }) => {
|
||||
if let Some(rp) = relay_peer {
|
||||
let request_id = swarm_instance
|
||||
.behaviour_mut()
|
||||
.directory_service
|
||||
.send_request(
|
||||
&rp,
|
||||
crate::protocol::directory::DirectoryRequest::Search { query },
|
||||
);
|
||||
pending_directory_replies.insert(request_id, reply);
|
||||
} else {
|
||||
let _ = reply.send(Err("relay not connected".to_string()));
|
||||
}
|
||||
}
|
||||
Some(NodeCommand::SetRelayDiscoverable { enabled }) => {
|
||||
relay_discoverable = enabled;
|
||||
if relay_reservation_active {
|
||||
if enabled {
|
||||
if let Some(rp) = relay_peer {
|
||||
let profile = storage.load_profile().unwrap_or_default();
|
||||
swarm_instance.behaviour_mut().directory_service.send_request(
|
||||
&rp,
|
||||
crate::protocol::directory::DirectoryRequest::Register {
|
||||
display_name: profile.display_name,
|
||||
},
|
||||
);
|
||||
log::info!("directory: registered after opt-in");
|
||||
}
|
||||
} else {
|
||||
if let Some(rp) = relay_peer {
|
||||
swarm_instance.behaviour_mut().directory_service.send_request(
|
||||
&rp,
|
||||
crate::protocol::directory::DirectoryRequest::Remove,
|
||||
);
|
||||
log::info!("directory: removed after opt-out");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use libp2p::{
|
|||
};
|
||||
|
||||
use super::behaviour::DuskBehaviour;
|
||||
use crate::protocol::directory::{DirectoryRequest, DirectoryResponse, DIRECTORY_PROTOCOL};
|
||||
use crate::protocol::gif::{GifRequest, GifResponse, GIF_PROTOCOL};
|
||||
|
||||
pub fn build_swarm(
|
||||
|
|
@ -85,6 +86,12 @@ pub fn build_swarm(
|
|||
request_response::Config::default()
|
||||
.with_request_timeout(Duration::from_secs(15)),
|
||||
),
|
||||
// directory search via request-response to the relay (outbound only)
|
||||
directory_service: cbor::Behaviour::<DirectoryRequest, DirectoryResponse>::new(
|
||||
[(DIRECTORY_PROTOCOL, ProtocolSupport::Outbound)],
|
||||
request_response::Config::default()
|
||||
.with_request_timeout(Duration::from_secs(15)),
|
||||
),
|
||||
}
|
||||
})?
|
||||
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300)))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
// directory protocol types for the relay-backed peer discovery service.
|
||||
// the client sends DirectoryRequests to the relay and receives DirectoryResponses.
|
||||
|
||||
use libp2p::StreamProtocol;
|
||||
|
||||
pub const DIRECTORY_PROTOCOL: StreamProtocol =
|
||||
StreamProtocol::new("/dusk/directory/1.0.0");
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub enum DirectoryRequest {
|
||||
Register { display_name: String },
|
||||
Search { query: String },
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub enum DirectoryResponse {
|
||||
Ok,
|
||||
Results(Vec<DirectoryProfileEntry>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DirectoryProfileEntry {
|
||||
pub peer_id: String,
|
||||
pub display_name: String,
|
||||
pub last_seen: u64,
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod community;
|
||||
pub mod directory;
|
||||
pub mod gif;
|
||||
pub mod identity;
|
||||
pub mod messages;
|
||||
|
|
|
|||
|
|
@ -27,8 +27,12 @@ pub struct UserSettings {
|
|||
pub font_size: String,
|
||||
#[serde(default)]
|
||||
pub custom_relay_addr: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub relay_discoverable: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
impl Default for UserSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -43,6 +47,7 @@ impl Default for UserSettings {
|
|||
message_display: "cozy".to_string(),
|
||||
custom_relay_addr: None,
|
||||
font_size: "default".to_string(),
|
||||
relay_discoverable: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -739,6 +744,29 @@ impl DiskStorage {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// upsert a directory entry from the relay — updates display_name and last_seen but preserves bio, public_key, and is_friend
|
||||
pub fn save_directory_entry_if_new(&self, entry: &DirectoryEntry) -> Result<(), io::Error> {
|
||||
let conn = self.open_conn()?;
|
||||
conn.execute(
|
||||
"INSERT INTO directory_entries (
|
||||
peer_id, display_name, bio, public_key, last_seen, is_friend
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(peer_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
last_seen = CASE WHEN excluded.last_seen > last_seen THEN excluded.last_seen ELSE last_seen END",
|
||||
params![
|
||||
entry.peer_id,
|
||||
entry.display_name,
|
||||
entry.bio,
|
||||
entry.public_key,
|
||||
entry.last_seen as i64,
|
||||
if entry.is_friend { 1_i64 } else { 0_i64 }
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_to_io_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// load the entire peer directory
|
||||
pub fn load_directory(&self) -> Result<HashMap<String, DirectoryEntry>, io::Error> {
|
||||
let conn = self.open_conn()?;
|
||||
|
|
|
|||
|
|
@ -167,6 +167,11 @@ const SignUpScreen: Component<SignUpScreenProps> = (props) => {
|
|||
an ed25519 keypair will be generated and stored locally on your
|
||||
device
|
||||
</p>
|
||||
<p class="text-[11px] font-mono text-white/30 text-center mt-2 leading-relaxed">
|
||||
your username will be stored on the relay server so others can
|
||||
find you even when you're offline.{" "}
|
||||
<span class="text-white/40">you can turn this off in settings.</span>
|
||||
</p>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Users,
|
||||
Copy,
|
||||
Check,
|
||||
WifiOff,
|
||||
} from "lucide-solid";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Button from "../common/Button";
|
||||
|
|
@ -263,7 +264,9 @@ const UserDirectoryModal: Component<UserDirectoryModalProps> = (props) => {
|
|||
<p class="text-[14px] text-white/20">
|
||||
{activeTab() === "friends"
|
||||
? "add friends from the all peers tab"
|
||||
: "peers will appear as you join communities"}
|
||||
: searchQuery().trim().length > 0
|
||||
? "try searching by peer id or check your relay connection"
|
||||
: "peers will appear as you join communities or search by name"}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -283,6 +286,12 @@ const UserDirectoryModal: Component<UserDirectoryModalProps> = (props) => {
|
|||
friend
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!peer.public_key}>
|
||||
<span class="flex items-center gap-1 text-[10px] font-mono uppercase tracking-[0.05em] text-white/30 px-1.5 py-0.5 border border-white/10 shrink-0">
|
||||
<WifiOff size={8} />
|
||||
offline
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={peer.bio}>
|
||||
<p class="text-[13px] text-white/40 truncate">
|
||||
|
|
|
|||
|
|
@ -21,11 +21,12 @@ import {
|
|||
toggleMessagePreview,
|
||||
toggleShowOnlineStatus,
|
||||
toggleAllowDMsFromAnyone,
|
||||
toggleRelayDiscoverable,
|
||||
setMessageDisplay,
|
||||
setFontSize,
|
||||
} from "../../stores/settings";
|
||||
import { identity, updateIdentity } from "../../stores/identity";
|
||||
import { updateProfile } from "../../lib/tauri";
|
||||
import { updateProfile, setRelayDiscoverable } from "../../lib/tauri";
|
||||
import type { UserStatus } from "../../lib/types";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Button from "../common/Button";
|
||||
|
|
@ -378,6 +379,19 @@ const PrivacySection: Component<{
|
|||
checked={current().allow_dms_from_anyone}
|
||||
onChange={toggleAllowDMsFromAnyone}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="discoverable on relay"
|
||||
description="lets others find you by username even when you're offline. turning this off removes your profile from the relay index."
|
||||
checked={current().relay_discoverable ?? true}
|
||||
onChange={async () => {
|
||||
toggleRelayDiscoverable();
|
||||
try {
|
||||
await setRelayDiscoverable(!current().relay_discoverable);
|
||||
} catch (e) {
|
||||
console.error("failed to set relay discoverable:", e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* danger zone */}
|
||||
<div class="mt-8 pt-6 border-t border-red-500/20">
|
||||
|
|
|
|||
|
|
@ -296,6 +296,10 @@ export async function discoverGlobalPeers(): Promise<void> {
|
|||
return invoke("discover_global_peers");
|
||||
}
|
||||
|
||||
export async function setRelayDiscoverable(enabled: boolean): Promise<void> {
|
||||
return invoke("set_relay_discoverable", { enabled });
|
||||
}
|
||||
|
||||
export async function setRelayAddress(relayAddr: string): Promise<void> {
|
||||
return invoke("set_relay_address", { relayAddr });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ export interface UserSettings {
|
|||
|
||||
// network
|
||||
custom_relay_addr?: string;
|
||||
|
||||
// discovery
|
||||
relay_discoverable: boolean;
|
||||
}
|
||||
|
||||
export interface CommunityMeta {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const defaultSettings: UserSettings = {
|
|||
enable_message_preview: true,
|
||||
show_online_status: true,
|
||||
allow_dms_from_anyone: true,
|
||||
relay_discoverable: true,
|
||||
message_display: "cozy",
|
||||
font_size: "default",
|
||||
};
|
||||
|
|
@ -86,6 +87,13 @@ export function toggleAllowDMsFromAnyone() {
|
|||
}));
|
||||
}
|
||||
|
||||
export function toggleRelayDiscoverable() {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
relay_discoverable: !prev.relay_discoverable,
|
||||
}));
|
||||
}
|
||||
|
||||
export function setMessageDisplay(mode: "cozy" | "compact") {
|
||||
updateSettings({ message_display: mode });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue