remove "display name" from adding new friends

This commit is contained in:
2026-08-28 14:21:36 +08:00
parent a042945f5d
commit eb689210d4
17 changed files with 749 additions and 317 deletions
+9 -1
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use specta::Type;
pub const VERSION: u8 = 1;
pub const VERSION: u8 = 2;
pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024;
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
pub const MAX_IMAGE_DIMENSION: u32 = 480;
@@ -32,6 +32,10 @@ pub enum ClientMessage {
},
SyncFriendProfiles,
SyncFriendStatuses,
ResolveProfile {
request_id: String,
user_id: String,
},
Signed {
payload: String,
signature: String,
@@ -107,6 +111,10 @@ pub enum ServerMessage {
FriendProfiles {
profiles: Vec<Profile>,
},
ProfileResolved {
request_id: String,
profile: Option<Profile>,
},
FriendStatusChanged {
friend_id: String,
online: bool,
+29 -220
View File
@@ -13,19 +13,17 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{Mutex, mpsc};
use uuid::Uuid;
use wyd_common::{
ClientMessage, Profile, ServerMessage, message_bytes, profile_bytes, register_bytes,
};
use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes};
mod interactions;
mod presence;
mod profiles;
type Clients = Arc<Mutex<HashMap<String, Client>>>;
struct Client {
connection_id: Uuid,
key: VerifyingKey,
#[allow(dead_code)] // Used when presence and profile lookup are exposed.
profile: Profile,
friends: Vec<String>,
sender: mpsc::Sender<Message>,
@@ -102,6 +100,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
presence::disconnected(&clients, &public_key, connection_id).await;
return;
}
profiles::broadcast(&clients, &public_key, connection_id).await;
presence::connected(&clients, &public_key, previous_friends).await;
let (mut writer, mut reader) = socket.split();
@@ -148,7 +147,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
}
}
Ok(ClientMessage::ProfileUpdated { profile, signature }) => {
if !update_profile(&clients, &public_key, connection_id, profile, &signature).await {
if !profiles::update(&clients, &public_key, connection_id, profile, &signature).await {
break;
}
}
@@ -166,9 +165,16 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() {
break;
}
let Some(profiles) = profiles::snapshot(&clients, &public_key, connection_id).await else {
break;
};
let message = ServerMessage::FriendProfiles { profiles };
if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() {
break;
}
}
Ok(ClientMessage::SyncFriendProfiles) => {
let Some(profiles) = friend_profiles(&clients, &public_key, connection_id).await else {
let Some(profiles) = profiles::snapshot(&clients, &public_key, connection_id).await else {
break;
};
let message = ServerMessage::FriendProfiles { profiles };
@@ -185,6 +191,23 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
break;
}
}
Ok(ClientMessage::ResolveProfile { request_id, user_id }) => {
let Some(profile) = profiles::resolve(
&clients,
&public_key,
connection_id,
&user_id,
).await else {
break;
};
let message = ServerMessage::ProfileResolved {
request_id,
profile,
};
if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() {
break;
}
}
_ => break,
}
Some(Ok(Message::Ping(data))) => {
@@ -199,63 +222,6 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
presence::disconnected(&clients, &public_key, connection_id).await;
}
async fn update_profile(
clients: &Clients,
public_key: &str,
connection_id: Uuid,
profile: Profile,
signature: &str,
) -> bool {
let mut clients = clients.lock().await;
{
let Some(client) = clients
.get_mut(public_key)
.filter(|client| client.connection_id == connection_id)
else {
return false;
};
if profile.id != public_key || !verify(&client.key, &profile_bytes(&profile), signature) {
return false;
}
client.profile = profile.clone();
}
let recipients: Vec<_> = clients
.values()
.filter(|client| client.friends.iter().any(|friend| friend == public_key))
.map(|client| client.sender.clone())
.collect();
drop(clients);
let message = Message::Text(
serde_json::to_string(&ServerMessage::FriendProfileUpdated { profile })
.unwrap()
.into(),
);
for recipient in recipients {
let _ = recipient.send(message.clone()).await;
}
true
}
async fn friend_profiles(
clients: &Clients,
public_key: &str,
connection_id: Uuid,
) -> Option<Vec<Profile>> {
let clients = clients.lock().await;
let client = clients
.get(public_key)
.filter(|client| client.connection_id == connection_id)?;
let mut profiles: Vec<_> = client
.friends
.iter()
.filter_map(|friend| clients.get(friend).map(|client| client.profile.clone()))
.collect();
profiles.sort_by(|a, b| a.id.cmp(&b.id));
Some(profiles)
}
async fn relay_live_data(
clients: &Clients,
public_key: &str,
@@ -366,105 +332,6 @@ mod tests {
));
}
#[tokio::test]
async fn authenticated_profile_update_changes_the_registered_client() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
let connection_id = Uuid::new_v4();
let (sender, _receiver) = mpsc::channel(1);
let (friend_sender, mut friend_receiver) = mpsc::channel(1);
let clients = Clients::default();
{
let mut clients = clients.lock().await;
clients.insert(
public_key.clone(),
Client {
connection_id,
key: signing_key.verifying_key(),
profile: Profile {
id: public_key.clone(),
display_name: "Old".to_owned(),
},
friends: Vec::new(),
sender,
},
);
clients.insert(
"friend".to_owned(),
Client {
connection_id: Uuid::new_v4(),
key: signing_key.verifying_key(),
profile: Profile {
id: "friend".to_owned(),
display_name: "Friend".to_owned(),
},
friends: vec![public_key.clone()],
sender: friend_sender,
},
);
}
let profile = Profile {
id: public_key.clone(),
display_name: "New".to_owned(),
};
let signature =
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&profile)).to_bytes());
assert!(update_profile(&clients, &public_key, connection_id, profile, &signature,).await);
assert_eq!(
clients
.lock()
.await
.get(&public_key)
.unwrap()
.profile
.display_name,
"New"
);
let announcement = friend_receiver
.recv()
.await
.expect("friend receives update");
let Message::Text(announcement) = announcement else {
panic!("expected text announcement");
};
let ServerMessage::FriendProfileUpdated { profile } =
serde_json::from_str(&announcement).expect("decode announcement")
else {
panic!("expected friend profile update");
};
assert_eq!(profile.id, public_key);
assert_eq!(profile.display_name, "New");
let stale_profile = Profile {
id: public_key.clone(),
display_name: "Stale".to_owned(),
};
let stale_signature =
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&stale_profile)).to_bytes());
assert!(
!update_profile(
&clients,
&public_key,
Uuid::new_v4(),
stale_profile,
&stale_signature,
)
.await
);
assert!(friend_receiver.try_recv().is_err());
assert_eq!(
clients
.lock()
.await
.get(&public_key)
.unwrap()
.profile
.display_name,
"New"
);
}
#[tokio::test]
async fn authenticated_friend_update_changes_only_ids() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
@@ -506,64 +373,6 @@ mod tests {
);
}
#[tokio::test]
async fn profile_sync_returns_only_connected_friends_for_the_current_session() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let connection_id = Uuid::new_v4();
let (sender, _receiver) = mpsc::channel(1);
let clients = Clients::default();
let mut registry = clients.lock().await;
registry.insert(
"requester".to_owned(),
Client {
connection_id,
key: signing_key.verifying_key(),
profile: Profile {
id: "requester".to_owned(),
display_name: "Requester".to_owned(),
},
friends: vec![
"friend-b".to_owned(),
"offline".to_owned(),
"friend-a".to_owned(),
],
sender: sender.clone(),
},
);
for (id, display_name) in [("friend-a", "Alice"), ("friend-b", "Bob")] {
registry.insert(
id.to_owned(),
Client {
connection_id: Uuid::new_v4(),
key: signing_key.verifying_key(),
profile: Profile {
id: id.to_owned(),
display_name: display_name.to_owned(),
},
friends: Vec::new(),
sender: sender.clone(),
},
);
}
drop(registry);
let profiles = friend_profiles(&clients, "requester", connection_id)
.await
.expect("current session");
assert_eq!(
profiles
.iter()
.map(|profile| (profile.id.as_str(), profile.display_name.as_str()))
.collect::<Vec<_>>(),
[("friend-a", "Alice"), ("friend-b", "Bob")]
);
assert!(
friend_profiles(&clients, "requester", Uuid::new_v4())
.await
.is_none()
);
}
#[tokio::test]
async fn presence_sync_and_disconnect_broadcast_require_mutual_friendship() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
+374
View File
@@ -0,0 +1,374 @@
use axum::extract::ws::Message;
use tokio::sync::mpsc;
use uuid::Uuid;
use wyd_common::{Profile, ServerMessage, profile_bytes};
use super::{Client, Clients, verify};
pub(super) async fn update(
clients: &Clients,
public_key: &str,
connection_id: Uuid,
profile: Profile,
signature: &str,
) -> bool {
let mut clients = clients.lock().await;
let Some(client) = clients
.get_mut(public_key)
.filter(|client| client.connection_id == connection_id)
else {
return false;
};
if profile.id != public_key || !verify(&client.key, &profile_bytes(&profile), signature) {
return false;
}
client.profile = profile.clone();
let recipients = subscribers(&clients, public_key);
drop(clients);
send_update(profile, recipients).await;
true
}
pub(super) async fn broadcast(clients: &Clients, public_key: &str, connection_id: Uuid) {
let clients = clients.lock().await;
let Some(profile) = clients
.get(public_key)
.filter(|client| client.connection_id == connection_id)
.map(|client| client.profile.clone())
else {
return;
};
let recipients = subscribers(&clients, public_key);
drop(clients);
send_update(profile, recipients).await;
}
pub(super) async fn snapshot(
clients: &Clients,
public_key: &str,
connection_id: Uuid,
) -> Option<Vec<Profile>> {
let clients = clients.lock().await;
let client = clients
.get(public_key)
.filter(|client| client.connection_id == connection_id)?;
let mut profiles: Vec<_> = client
.friends
.iter()
.filter_map(|friend| clients.get(friend).map(|client| client.profile.clone()))
.collect();
profiles.sort_by(|a, b| a.id.cmp(&b.id));
Some(profiles)
}
pub(super) async fn resolve(
clients: &Clients,
requester_id: &str,
connection_id: Uuid,
user_id: &str,
) -> Option<Option<Profile>> {
let clients = clients.lock().await;
clients
.get(requester_id)
.filter(|client| client.connection_id == connection_id)?;
Some(clients.get(user_id).map(|client| client.profile.clone()))
}
fn subscribers(
clients: &std::collections::HashMap<String, Client>,
user_id: &str,
) -> Vec<mpsc::Sender<Message>> {
clients
.iter()
.filter(|(id, client)| {
id.as_str() != user_id && client.friends.iter().any(|friend| friend == user_id)
})
.map(|(_, client)| client.sender.clone())
.collect()
}
async fn send_update(profile: Profile, recipients: Vec<mpsc::Sender<Message>>) {
let message = Message::Text(
serde_json::to_string(&ServerMessage::FriendProfileUpdated { profile })
.expect("friend profile update serializes")
.into(),
);
for recipient in recipients {
let _ = recipient.send(message.clone()).await;
}
}
#[cfg(test)]
mod tests {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signer, SigningKey};
use super::*;
fn client(
signing_key: &SigningKey,
id: &str,
display_name: &str,
connection_id: Uuid,
friends: Vec<String>,
sender: mpsc::Sender<Message>,
) -> Client {
Client {
connection_id,
key: signing_key.verifying_key(),
profile: Profile {
id: id.to_owned(),
display_name: display_name.to_owned(),
},
friends,
sender,
}
}
#[tokio::test]
async fn authenticated_update_changes_the_registered_client() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
let connection_id = Uuid::new_v4();
let (sender, _receiver) = mpsc::channel(1);
let (friend_sender, mut friend_receiver) = mpsc::channel(1);
let clients = Clients::default();
{
let mut clients = clients.lock().await;
clients.insert(
public_key.clone(),
client(
&signing_key,
&public_key,
"Old",
connection_id,
Vec::new(),
sender,
),
);
clients.insert(
"friend".to_owned(),
client(
&signing_key,
"friend",
"Friend",
Uuid::new_v4(),
vec![public_key.clone()],
friend_sender,
),
);
}
let profile = Profile {
id: public_key.clone(),
display_name: "New".to_owned(),
};
let signature =
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&profile)).to_bytes());
assert!(update(&clients, &public_key, connection_id, profile, &signature).await);
assert_eq!(
clients
.lock()
.await
.get(&public_key)
.unwrap()
.profile
.display_name,
"New"
);
let Message::Text(announcement) = friend_receiver
.recv()
.await
.expect("friend receives update")
else {
panic!("expected text announcement");
};
let ServerMessage::FriendProfileUpdated { profile } =
serde_json::from_str(&announcement).expect("decode announcement")
else {
panic!("expected friend profile update");
};
assert_eq!(profile.id, public_key);
assert_eq!(profile.display_name, "New");
let stale_profile = Profile {
id: public_key.clone(),
display_name: "Stale".to_owned(),
};
let stale_signature =
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&stale_profile)).to_bytes());
assert!(
!update(
&clients,
&public_key,
Uuid::new_v4(),
stale_profile,
&stale_signature,
)
.await
);
assert!(friend_receiver.try_recv().is_err());
}
#[tokio::test]
async fn first_connection_announces_to_existing_subscribers() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
let connection_id = Uuid::new_v4();
let (source_sender, _source_receiver) = mpsc::channel(1);
let (friend_sender, mut friend_receiver) = mpsc::channel(1);
let (unrelated_sender, mut unrelated_receiver) = mpsc::channel(1);
let clients = Clients::default();
{
let mut clients = clients.lock().await;
clients.insert(
public_key.clone(),
client(
&signing_key,
&public_key,
"Newly connected",
connection_id,
Vec::new(),
source_sender,
),
);
clients.insert(
"friend".to_owned(),
client(
&signing_key,
"friend",
"Friend",
Uuid::new_v4(),
vec![public_key.clone()],
friend_sender,
),
);
clients.insert(
"unrelated".to_owned(),
client(
&signing_key,
"unrelated",
"Unrelated",
Uuid::new_v4(),
Vec::new(),
unrelated_sender,
),
);
}
broadcast(&clients, &public_key, connection_id).await;
let Message::Text(announcement) = friend_receiver
.recv()
.await
.expect("subscriber receives connected profile")
else {
panic!("expected text announcement");
};
let ServerMessage::FriendProfileUpdated { profile } =
serde_json::from_str(&announcement).expect("decode announcement")
else {
panic!("expected friend profile update");
};
assert_eq!(profile.id, public_key);
assert_eq!(profile.display_name, "Newly connected");
assert!(unrelated_receiver.try_recv().is_err());
}
#[tokio::test]
async fn snapshot_returns_only_connected_friends_for_the_current_session() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let connection_id = Uuid::new_v4();
let (sender, _receiver) = mpsc::channel(1);
let clients = Clients::default();
let mut registry = clients.lock().await;
registry.insert(
"requester".to_owned(),
client(
&signing_key,
"requester",
"Requester",
connection_id,
vec![
"friend-b".to_owned(),
"offline".to_owned(),
"friend-a".to_owned(),
],
sender.clone(),
),
);
for (id, display_name) in [("friend-a", "Alice"), ("friend-b", "Bob")] {
registry.insert(
id.to_owned(),
client(
&signing_key,
id,
display_name,
Uuid::new_v4(),
Vec::new(),
sender.clone(),
),
);
}
drop(registry);
let profiles = snapshot(&clients, "requester", connection_id)
.await
.expect("current session");
assert_eq!(
profiles
.iter()
.map(|profile| (profile.id.as_str(), profile.display_name.as_str()))
.collect::<Vec<_>>(),
[("friend-a", "Alice"), ("friend-b", "Bob")]
);
assert!(
snapshot(&clients, "requester", Uuid::new_v4())
.await
.is_none()
);
}
#[tokio::test]
async fn lookup_resolves_connected_users_for_only_the_current_session() {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let connection_id = Uuid::new_v4();
let (sender, _receiver) = mpsc::channel(1);
let clients = Clients::default();
{
let mut clients = clients.lock().await;
for (id, name, session) in [
("requester", "Requester", connection_id),
("target", "Resolved name", Uuid::new_v4()),
] {
clients.insert(
id.to_owned(),
client(&signing_key, id, name, session, Vec::new(), sender.clone()),
);
}
}
assert_eq!(
resolve(&clients, "requester", connection_id, "target")
.await
.expect("current requester session")
.expect("connected target")
.display_name,
"Resolved name"
);
assert!(
resolve(&clients, "requester", connection_id, "offline")
.await
.expect("current requester session")
.is_none()
);
assert!(
resolve(&clients, "requester", Uuid::new_v4(), "target")
.await
.is_none()
);
}
}
@@ -0,0 +1,13 @@
CREATE TABLE friends_with_optional_profiles (
id TEXT PRIMARY KEY NOT NULL,
display_name TEXT
);
-- Existing names were entered locally and cannot be distinguished from names
-- learned from a signed remote profile. Keep the relationship, then learn the
-- authoritative display name again from the remote.
INSERT INTO friends_with_optional_profiles (id)
SELECT id FROM friends;
DROP TABLE friends;
ALTER TABLE friends_with_optional_profiles RENAME TO friends;
+36 -15
View File
@@ -1,19 +1,30 @@
use crate::db::{self, AppDatabase};
use crate::user::User;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, State};
use tauri_specta::Event;
use crate::keypair::AppKeypair;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
/// A configured public-key relationship with optional cached remote metadata.
/// The display name remains absent until learned from a signed remote profile.
pub struct Friend {
pub id: String,
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct FriendsChanged {
pub friends: Vec<User>,
pub friends: Vec<Friend>,
}
pub(crate) async fn all(database: &AppDatabase) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, display_name FROM friends ORDER BY display_name COLLATE NOCASE, id",
pub(crate) async fn all(database: &AppDatabase) -> Result<Vec<Friend>, sqlx::Error> {
sqlx::query_as::<_, Friend>(
"SELECT id, display_name FROM friends \
ORDER BY display_name IS NULL, display_name COLLATE NOCASE, id",
)
.fetch_all(database.pool())
.await
@@ -35,7 +46,7 @@ async fn update_display_names(
let mut changed = false;
for profile in profiles {
let result = sqlx::query(
"UPDATE friends SET display_name = ?1 WHERE id = ?2 AND display_name != ?1",
"UPDATE friends SET display_name = ?1 WHERE id = ?2 AND display_name IS NOT ?1",
)
.bind(&profile.display_name)
.bind(&profile.id)
@@ -74,11 +85,21 @@ pub(crate) async fn apply_profile_sync(
pub async fn create_friend(
handle: AppHandle,
database: State<'_, AppDatabase>,
friend: User,
) -> Result<User, String> {
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, ?2)")
keypair: State<'_, AppKeypair>,
id: String,
) -> Result<Friend, String> {
let id = id.trim().to_owned();
crate::user::validate_id(&id)?;
if id == keypair.public_key() {
return Err("You cannot add your own identification key.".to_owned());
}
let friend = Friend {
id,
display_name: None,
};
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, NULL)")
.bind(&friend.id)
.bind(&friend.display_name)
.execute(database.pool())
.await
.map_err(db::command_error)?;
@@ -93,7 +114,7 @@ pub async fn create_friend(
pub async fn list_friends(
handle: AppHandle,
database: State<'_, AppDatabase>,
) -> Result<Vec<User>, String> {
) -> Result<Vec<Friend>, String> {
let friends = all(&database).await.map_err(db::command_error)?;
FriendsChanged {
@@ -110,8 +131,8 @@ pub async fn list_friends(
pub async fn get_friend(
database: State<'_, AppDatabase>,
id: String,
) -> Result<Option<User>, String> {
sqlx::query_as::<_, User>("SELECT id, display_name FROM friends WHERE id = ?1")
) -> Result<Option<Friend>, String> {
sqlx::query_as::<_, Friend>("SELECT id, display_name FROM friends WHERE id = ?1")
.bind(id)
.fetch_optional(database.pool())
.await
@@ -199,9 +220,9 @@ mod tests {
);
assert_eq!(
all(&database).await.unwrap(),
[User {
[Friend {
id: "friend-id".to_owned(),
display_name: "New".to_owned(),
display_name: Some("New".to_owned()),
}]
);
}
+1
View File
@@ -72,6 +72,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
keypair::get_public_key,
network::list_statuses,
network::list_friend_statuses,
network::resolve_friend_display_name,
images::pick_and_send_image,
images::send_image_bytes,
interactions::send_interaction,
+120 -30
View File
@@ -62,6 +62,7 @@ struct Connection {
remote: Remote,
sender: mpsc::Sender<String>,
interaction_sender: mpsc::Sender<InteractionRequest>,
profile_lookup_sender: mpsc::Sender<ProfileLookupRequest>,
task: tauri::async_runtime::JoinHandle<()>,
generation: u64,
}
@@ -73,6 +74,21 @@ struct InteractionRequest {
response: oneshot::Sender<InteractionDeliveryStatus>,
}
struct ProfileLookupRequest {
request_id: String,
user_id: String,
response: oneshot::Sender<Option<String>>,
}
struct ConnectionInputs {
profiles: watch::Receiver<crate::user::User>,
friends: watch::Receiver<Vec<String>>,
keypair: AppKeypair,
live_data: mpsc::Receiver<String>,
interactions: mpsc::Receiver<InteractionRequest>,
profile_lookups: mpsc::Receiver<ProfileLookupRequest>,
}
pub struct Network {
connections: Mutex<HashMap<String, Connection>>,
statuses: Statuses,
@@ -167,6 +183,45 @@ impl Network {
Err("Friend is no longer available".to_owned())
}
pub async fn resolve_profile(&self, user_id: String) -> Result<Option<String>, String> {
crate::user::validate_id(&user_id)?;
if user_id == self.keypair.public_key() {
return Err("You cannot add your own identification key.".to_owned());
}
let senders: Vec<_> = self
.connections
.lock()
.map_err(|error| error.to_string())?
.values()
.map(|connection| connection.profile_lookup_sender.clone())
.collect();
let request_id = uuid::Uuid::new_v4().to_string();
let mut responses = Vec::new();
for sender in senders {
let (response, receiver) = oneshot::channel();
let request = ProfileLookupRequest {
request_id: request_id.clone(),
user_id: user_id.clone(),
response,
};
if sender.try_send(request).is_ok() {
responses.push(receiver);
}
}
let mut pending: FuturesUnordered<_> = responses
.into_iter()
.map(|response| tokio::time::timeout(Duration::from_secs(3), response))
.collect();
while let Some(result) = pending.next().await {
if let Ok(Ok(Some(display_name))) = result {
return Ok(Some(display_name));
}
}
Ok(None)
}
fn sync_remotes(&self, handle: &AppHandle, remotes: Vec<Remote>) -> Result<(), String> {
let desired: HashMap<_, _> = remotes
.into_iter()
@@ -196,6 +251,7 @@ impl Network {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = mpsc::channel(32);
let (interaction_sender, interaction_receiver) = mpsc::channel(16);
let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16);
set_initial(
&self.statuses,
&remote,
@@ -208,11 +264,14 @@ impl Network {
self.friend_presence.clone(),
remote.clone(),
generation,
self.profile.subscribe(),
self.friends.subscribe(),
self.keypair.clone(),
receiver,
interaction_receiver,
ConnectionInputs {
profiles: self.profile.subscribe(),
friends: self.friends.subscribe(),
keypair: self.keypair.clone(),
live_data: receiver,
interactions: interaction_receiver,
profile_lookups: profile_lookup_receiver,
},
));
connections.insert(
remote.id.clone(),
@@ -220,6 +279,7 @@ impl Network {
remote,
sender,
interaction_sender,
profile_lookup_sender,
task,
generation,
},
@@ -280,11 +340,7 @@ async fn run(
friend_presence: Arc<FriendPresence>,
remote: Remote,
generation: u64,
mut profiles: watch::Receiver<crate::user::User>,
mut friends: watch::Receiver<Vec<String>>,
keypair: AppKeypair,
mut outgoing: mpsc::Receiver<String>,
mut active_outgoing: mpsc::Receiver<InteractionRequest>,
mut inputs: ConnectionInputs,
) {
loop {
changed(
@@ -300,11 +356,7 @@ async fn run(
&friend_presence,
&remote,
generation,
&mut profiles,
&mut friends,
&keypair,
&mut outgoing,
&mut active_outgoing,
&mut inputs,
)
.await
{
@@ -328,12 +380,16 @@ async fn connect(
friend_presence: &FriendPresence,
remote: &Remote,
generation: u64,
profiles: &mut watch::Receiver<crate::user::User>,
friends: &mut watch::Receiver<Vec<String>>,
keypair: &AppKeypair,
outgoing: &mut mpsc::Receiver<String>,
active_outgoing: &mut mpsc::Receiver<InteractionRequest>,
inputs: &mut ConnectionInputs,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ConnectionInputs {
profiles,
friends,
keypair,
live_data,
interactions: active_outgoing,
profile_lookups,
} = inputs;
let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?;
let (mut writer, mut reader) = socket.split();
@@ -366,7 +422,7 @@ async fn connect(
if !matches!(recv(&mut reader).await?, ServerMessage::Registered) {
return Err("server rejected registration".into());
}
while outgoing.try_recv().is_ok() {}
while live_data.try_recv().is_ok() {}
while let Ok(request) = active_outgoing.try_recv() {
let _ = request
.response
@@ -383,9 +439,11 @@ async fn connect(
);
let mut pending_interactions = HashMap::new();
let mut pending_profile_lookups: HashMap<String, (String, oneshot::Sender<Option<String>>)> =
HashMap::new();
loop {
tokio::select! {
payload = outgoing.recv() => {
payload = live_data.recv() => {
let payload = payload.ok_or("network sender closed")?;
send(&mut writer, &ClientMessage::Signed {
signature: keypair.sign(&message_bytes(&payload)),
@@ -407,6 +465,21 @@ async fn connect(
}).await?;
pending_interactions.insert(request.interaction_id, request.response);
}
request = profile_lookups.recv() => {
let request = request.ok_or("profile lookup sender closed")?;
if request.response.is_closed() {
continue;
}
pending_profile_lookups.retain(|_, (_, response)| !response.is_closed());
send(&mut writer, &ClientMessage::ResolveProfile {
request_id: request.request_id.clone(),
user_id: request.user_id.clone(),
}).await?;
pending_profile_lookups.insert(
request.request_id,
(request.user_id, request.response),
);
}
changed = profiles.changed() => {
changed.map_err(|_| "profile sender closed")?;
let current = profiles.borrow_and_update().clone();
@@ -481,6 +554,14 @@ async fn connect(
let _ = response.send(status);
}
}
ServerMessage::ProfileResolved { request_id, profile } => {
if let Some((user_id, response)) = pending_profile_lookups.remove(&request_id) {
let display_name = profile
.filter(|profile| profile.id == user_id)
.map(|profile| profile.display_name);
let _ = response.send(display_name);
}
}
_ => {}
},
Message::Ping(data) => writer.send(Message::Pong(data)).await?,
@@ -512,6 +593,15 @@ pub fn list_friend_statuses(network: State<'_, Network>) -> Result<Vec<String>,
network.friend_presence.snapshot()
}
#[tauri::command]
#[specta::specta]
pub async fn resolve_friend_display_name(
user_id: String,
network: State<'_, Network>,
) -> Result<Option<String>, String> {
network.resolve_profile(user_id.trim().to_owned()).await
}
fn update_friend_presence(
handle: &AppHandle,
presence: &FriendPresence,
@@ -658,7 +748,7 @@ fn url(remote: &Remote) -> String {
format!("{scheme}://{address}{port}/v1/ws")
}
fn friend_ids(friends: Vec<crate::user::User>, own_id: &str) -> Vec<String> {
fn friend_ids(friends: Vec<crate::friends::Friend>, own_id: &str) -> Vec<String> {
let mut ids: Vec<_> = friends
.into_iter()
.map(|friend| friend.id)
@@ -672,22 +762,22 @@ fn friend_ids(friends: Vec<crate::user::User>, own_id: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::friend_ids;
use crate::user::User;
use crate::friends::Friend;
#[test]
fn friend_ids_discards_display_names_and_normalizes_ids() {
let friends = vec![
User {
Friend {
id: "friend-b".to_owned(),
display_name: "Old name".to_owned(),
display_name: Some("Old name".to_owned()),
},
User {
Friend {
id: "self".to_owned(),
display_name: "Me".to_owned(),
display_name: None,
},
User {
Friend {
id: "friend-a".to_owned(),
display_name: "Any name".to_owned(),
display_name: Some("Any name".to_owned()),
},
];
+29
View File
@@ -1,3 +1,6 @@
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::VerifyingKey;
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -9,3 +12,29 @@ pub struct User {
pub id: String,
pub display_name: String,
}
pub(crate) fn validate_id(id: &str) -> Result<(), String> {
let bytes = URL_SAFE_NO_PAD
.decode(id)
.map_err(|_| "Identification key is not valid base64url.".to_owned())?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| "Identification key must encode a 32-byte public key.".to_owned())?;
VerifyingKey::from_bytes(&bytes)
.map(|_| ())
.map_err(|_| "Identification key is not a valid Ed25519 public key.".to_owned())
}
#[cfg(test)]
mod tests {
use ed25519_dalek::SigningKey;
use super::*;
#[test]
fn id_must_be_an_ed25519_public_key() {
let valid = URL_SAFE_NO_PAD.encode(SigningKey::from_bytes(&[7; 32]).verifying_key());
assert!(validate_id(&valid).is_ok());
assert!(validate_id("not a public key").is_err());
}
}
+13 -5
View File
@@ -5,13 +5,13 @@
export const commands = {
async createFriend(friend: User) : Promise<User> {
return await TAURI_INVOKE("create_friend", { friend });
async createFriend(id: string) : Promise<Friend> {
return await TAURI_INVOKE("create_friend", { id });
},
async listFriends() : Promise<User[]> {
async listFriends() : Promise<Friend[]> {
return await TAURI_INVOKE("list_friends");
},
async getFriend(id: string) : Promise<User | null> {
async getFriend(id: string) : Promise<Friend | null> {
return await TAURI_INVOKE("get_friend", { id });
},
async deleteFriend(id: string) : Promise<boolean> {
@@ -47,6 +47,9 @@ async listStatuses() : Promise<ConnectionStatus[]> {
async listFriendStatuses() : Promise<string[]> {
return await TAURI_INVOKE("list_friend_statuses");
},
async resolveFriendDisplayName(userId: string) : Promise<string | null> {
return await TAURI_INVOKE("resolve_friend_display_name", { userId });
},
async pickAndSendImage(recipientId: string) : Promise<boolean> {
return await TAURI_INVOKE("pick_and_send_image", { recipientId });
},
@@ -113,10 +116,15 @@ raw: CursorPosition;
*/
mapped: CursorPosition }
export type ForegroundAppChanged = { meta: AppMeta }
/**
* A configured public-key relationship with optional cached remote metadata.
* The display name remains absent until learned from a signed remote profile.
*/
export type Friend = { id: string; displayName: string | null }
export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta }
export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent }
export type FriendStatusesChanged = { friendIds: string[] }
export type FriendsChanged = { friends: User[] }
export type FriendsChanged = { friends: Friend[] }
export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
export type ProfileChanged = { profile: User }
@@ -1,5 +1,5 @@
<script lang="ts">
import { friends } from "$lib/listeners/friends";
import { friendName, friends } from "$lib/listeners/friends";
import {
liveMetadata,
liveMetadataListenerError,
@@ -25,9 +25,9 @@
function nameFor(id: string) {
if (id === $liveMetadata.localId) return "You";
return (
$friends.find((friend) => friend.id === id)?.displayName ??
"Unknown friend"
return friendName(
$friends.find((friend) => friend.id === id),
"Unknown friend",
);
}
@@ -4,10 +4,15 @@
friendStatusesListenerError,
onlineFriendIds,
} from "$lib/listeners/friend-statuses";
import { friends, friendsListenerError } from "$lib/listeners/friends";
import {
friendName,
friends,
friendsListenerError,
} from "$lib/listeners/friends";
import { onMount } from "svelte";
import PanelMessage from "./panel-message.svelte";
import type { RegisterPanel } from "./types";
import Info from "$lib/icons/info.svelte";
let { register }: { register: RegisterPanel } = $props();
@@ -85,8 +90,9 @@
{#if mode === "remove" && selected}
<PanelMessage kind="warning">
Applying will remove <strong>{selected.displayName}</strong>. They will
immediately go offline and stop receiving your activity.
Applying will remove <strong
>{friendName(selected, "Unknown friend")}</strong
>. They will immediately go offline and stop receiving your activity.
</PanelMessage>
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
>Keep friend</button
@@ -126,7 +132,23 @@
ondblclick={() => (selectedId = friend.id)}
>
<span class="min-w-0">
<strong class="block truncate">{friend.displayName}</strong>
<div class="flex flex-row gap-1.5">
<strong
class:italic={!friend.displayName}
class="block truncate"
>{friendName(friend, "Unknown friend")}</strong
>
{#if !friend.displayName}
<div
class="tooltip tooltip-primary"
data-tip={"Will be resolved when they're online"}
>
<div class="*:size-3 opacity-50">
<Info />
</div>
</div>
{/if}
</div>
<span class="block truncate font-mono text-[9px] opacity-65"
>{friend.id}</span
>
@@ -3,7 +3,11 @@
friendStatusesListenerError,
onlineFriendIds,
} from "$lib/listeners/friend-statuses";
import { friends, friendsListenerError } from "$lib/listeners/friends";
import {
friendName,
friends,
friendsListenerError,
} from "$lib/listeners/friends";
</script>
<section>
@@ -20,7 +24,7 @@
{#each $friends as friend (friend.id)}
{@const online = $onlineFriendIds.has(friend.id)}
<li>
<strong>{friend.displayName}</strong>: {online ? "Online" : "Offline"}
<strong>{friendName(friend, "Unknown friend")}</strong>: {online ? "Online" : "Offline"}
<small>({friend.id})</small>
</li>
{/each}
+7 -13
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import { commands } from "$lib/bindings";
import { friends, friendsListenerError } from "$lib/listeners/friends";
import {
friendName,
friends,
friendsListenerError,
} from "$lib/listeners/friends";
let error = "";
@@ -11,10 +15,7 @@
error = "";
try {
await commands.createFriend({
id: String(data.get("id") ?? ""),
displayName: String(data.get("displayName") ?? ""),
});
await commands.createFriend(String(data.get("id") ?? ""));
form.reset();
} catch (err) {
error = String(err);
@@ -43,13 +44,6 @@
</label>
</p>
<p>
<label>
Display name
<input name="displayName" required />
</label>
</p>
<button type="submit">Create friend</button>
</form>
@@ -63,7 +57,7 @@
<ul>
{#each $friends as friend (friend.id)}
<li>
<span>{friend.displayName} ({friend.id})</span>
<span>{friendName(friend, "Unknown friend")} ({friend.id})</span>
<button type="button" onclick={() => deleteFriend(friend.id)}>Delete</button>
</li>
{/each}
+8 -2
View File
@@ -1,7 +1,13 @@
import { writable } from "svelte/store";
import { commands, events, type User } from "$lib/bindings";
import { commands, events, type Friend } from "$lib/bindings";
export const friends = writable<User[]>([]);
export const UNRESOLVED_FRIEND_NAME = "Name pending resolution";
export function friendName(friend: Friend | undefined, fallback: string) {
return friend?.displayName ?? (friend ? UNRESOLVED_FRIEND_NAME : fallback);
}
export const friends = writable<Friend[]>([]);
export const friendsListenerError = writable("");
export async function initFriendsListener() {
@@ -1,13 +1,24 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { onDestroy } from "svelte";
import { commands } from "$lib/bindings";
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
import Info from "$lib/icons/info.svelte";
let displayName = $state("");
let friendId = $state("");
let busy = $state(false);
let error = $state("");
let preview = $state<{
status: "idle" | "loading" | "resolved" | "unresolved" | "unavailable";
text: string;
}>({ status: "idle", text: "" });
let lookupTimer: ReturnType<typeof setTimeout> | undefined;
let lookupGeneration = 0;
onDestroy(() => {
lookupGeneration += 1;
if (lookupTimer) clearTimeout(lookupTimer);
});
async function closeWindow() {
await getCurrentWindow().close();
@@ -15,23 +26,52 @@
async function submit(event: SubmitEvent) {
event.preventDefault();
const name = displayName.trim();
const id = friendId.trim();
if (!name || !id) {
error = "Both display name and public key are required.";
if (!id) {
error = "Identification key is required.";
return;
}
busy = true;
error = "";
try {
await commands.createFriend({ id, displayName: name });
await commands.createFriend(id);
await closeWindow();
} catch (cause) {
error = String(cause);
busy = false;
}
}
function scheduleProfilePreview(event: Event) {
const id = (event.currentTarget as HTMLTextAreaElement).value.trim();
const generation = ++lookupGeneration;
if (lookupTimer) clearTimeout(lookupTimer);
if (!id) {
preview = { status: "idle", text: "" };
return;
}
preview = { status: "loading", text: "Resolving…" };
lookupTimer = setTimeout(
() => void resolveProfilePreview(id, generation),
250,
);
}
async function resolveProfilePreview(id: string, generation: number) {
try {
const displayName = await commands.resolveFriendDisplayName(id);
if (generation !== lookupGeneration) return;
preview = displayName
? { status: "resolved", text: displayName }
: { status: "unresolved", text: "Unresolved user" };
} catch {
if (generation === lookupGeneration) {
preview = { status: "unavailable", text: "Unresolved" };
}
}
}
</script>
<svelte:head><title>Add Friend</title></svelte:head>
@@ -46,18 +86,14 @@
<div class="min-h-0 flex-1 space-y-2">
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
<PanelMessage>
Their display name will appear after Wyd learns their profile from a
shared server.
</PanelMessage>
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Friend identity</legend>
<label class="fieldset-label" for="friend-name">Display name</label>
<input
id="friend-name"
class="input w-full"
bind:value={displayName}
maxlength="64"
autocomplete="off"
required
/>
<label class="fieldset-label mt-1" for="friend-key"
<label class="fieldset-label" for="friend-key"
>Identification Key <div
class="tooltip tooltip-primary"
data-tip="Verify with your friend before adding."
@@ -71,7 +107,20 @@
id="friend-key"
class="textarea h-20 w-full resize-none font-mono text-[10px]"
bind:value={friendId}
autocomplete="off"
oninput={scheduleProfilePreview}
required></textarea>
<label class="fieldset-label mt-1" for="friend-display-name"
>Display name</label
>
<input
id="friend-display-name"
class:italic={preview.status !== "resolved"}
class="input w-full"
value={preview.text}
aria-busy={preview.status === "loading"}
readonly
/>
</fieldset>
</div>
+5 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import Popover from "$lib/components/popover.svelte";
import { friends } from "$lib/listeners/friends";
import { friendName, friends } from "$lib/listeners/friends";
import { onlineFriendIds } from "$lib/listeners/friend-statuses";
import { incomingInteraction } from "$lib/listeners/interactions";
import { liveMetadata } from "$lib/listeners/live-metadata";
@@ -44,8 +44,10 @@
function displayName(userId: string) {
return $profile?.id === userId
? $profile.displayName
: ($friends.find((friend) => friend.id === userId)?.displayName ??
"Unknown user");
: friendName(
$friends.find((friend) => friend.id === userId),
"Unknown user",
);
}
function dismissInteraction(interactionId: string) {
@@ -4,7 +4,7 @@
type AppMeta,
type InteractionContent,
} from "$lib/bindings";
import { friends } from "$lib/listeners/friends";
import { friendName, friends } from "$lib/listeners/friends";
import { profile } from "$lib/listeners/profile";
type Props = {
@@ -33,8 +33,10 @@
let username = $derived(
$profile?.id === userId
? $profile.displayName
: ($friends.find((friend) => friend.id === userId)?.displayName ??
"Unknown user"),
: friendName(
$friends.find((friend) => friend.id === userId),
"Unknown user",
),
);
function selectMode(nextMode: "message" | "image") {