data between friends (with some crazy fancy UI)

This commit is contained in:
2026-08-10 02:07:51 +08:00
parent 8bc1d074d4
commit 4c0cbc9c85
10 changed files with 757 additions and 20 deletions
+1
View File
@@ -39,6 +39,7 @@ pub enum ServerMessage {
Registered,
FriendProfileUpdated { profile: Profile },
FriendProfiles { profiles: Vec<Profile> },
FriendLiveData { friend_id: String, payload: String },
}
pub fn register_bytes(challenge: &str, profile: &Profile, friends: &[String]) -> Vec<u8> {
+151 -6
View File
@@ -108,12 +108,9 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
message = reader.next() => match message {
Some(Ok(Message::Text(text))) => match serde_json::from_str(&text) {
Ok(ClientMessage::Signed { payload, signature }) => {
let registered_key = clients.lock().await.get(&public_key)
.filter(|client| client.connection_id == connection_id)
.map(|client| client.key);
let Some(registered_key) = registered_key else { break };
if !verify(&registered_key, &message_bytes(&payload), &signature) { break; }
// The message is authenticated. Domain routing comes next.
if !relay_live_data(&clients, &public_key, connection_id, payload, &signature).await {
break;
}
}
Ok(ClientMessage::ProfileUpdated { profile, signature }) => {
if !update_profile(&clients, &public_key, connection_id, profile, &signature).await {
@@ -226,6 +223,49 @@ async fn friend_profiles(
Some(profiles)
}
async fn relay_live_data(
clients: &Clients,
public_key: &str,
connection_id: Uuid,
payload: String,
signature: &str,
) -> bool {
let clients = clients.lock().await;
let Some(source) = clients
.get(public_key)
.filter(|client| client.connection_id == connection_id)
else {
return false;
};
if !verify(&source.key, &message_bytes(&payload), signature) {
return false;
}
let recipients: Vec<_> = clients
.iter()
.filter(|(recipient_id, recipient)| {
recipient_id.as_str() != public_key
&& source.friends.iter().any(|friend| friend == *recipient_id)
&& recipient.friends.iter().any(|friend| friend == public_key)
})
.map(|(_, recipient)| recipient.sender.clone())
.collect();
drop(clients);
let message = Message::Text(
serde_json::to_string(&ServerMessage::FriendLiveData {
friend_id: public_key.to_owned(),
payload,
})
.unwrap()
.into(),
);
for recipient in recipients {
let _ = recipient.try_send(message.clone());
}
true
}
async fn remove(clients: &Clients, public_key: &str, connection_id: Uuid) {
let mut clients = clients.lock().await;
if clients
@@ -499,4 +539,109 @@ mod tests {
.is_none()
);
}
#[tokio::test]
async fn live_data_relay_requires_valid_session_signature_and_mutual_friendship() {
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 (mutual_sender, mut mutual_receiver) = mpsc::channel(1);
let (sender_only_sender, mut sender_only_receiver) = mpsc::channel(1);
let (recipient_only_sender, mut recipient_only_receiver) = mpsc::channel(1);
let clients = Clients::default();
let mut registry = clients.lock().await;
registry.insert(
public_key.clone(),
Client {
connection_id,
key: signing_key.verifying_key(),
profile: Profile {
id: public_key.clone(),
display_name: "Source".to_owned(),
},
friends: vec!["mutual".to_owned(), "sender-only".to_owned()],
sender: source_sender,
},
);
for (id, friends, sender) in [
("mutual", vec![public_key.clone()], mutual_sender),
("sender-only", Vec::new(), sender_only_sender),
(
"recipient-only",
vec![public_key.clone()],
recipient_only_sender,
),
] {
registry.insert(
id.to_owned(),
Client {
connection_id: Uuid::new_v4(),
key: signing_key.verifying_key(),
profile: Profile {
id: id.to_owned(),
display_name: id.to_owned(),
},
friends,
sender,
},
);
}
drop(registry);
let payload = r#"{"type":"cursor","positions":{}}"#.to_owned();
let signature =
URL_SAFE_NO_PAD.encode(signing_key.sign(&message_bytes(&payload)).to_bytes());
assert!(
relay_live_data(
&clients,
&public_key,
connection_id,
payload.clone(),
&signature,
)
.await
);
let Message::Text(message) = mutual_receiver
.recv()
.await
.expect("mutual friend receives")
else {
panic!("expected text live data");
};
let ServerMessage::FriendLiveData {
friend_id,
payload: received_payload,
} = serde_json::from_str(&message).expect("decode live data")
else {
panic!("expected friend live data");
};
assert_eq!(friend_id, public_key);
assert_eq!(received_payload, payload);
assert!(sender_only_receiver.try_recv().is_err());
assert!(recipient_only_receiver.try_recv().is_err());
assert!(
!relay_live_data(
&clients,
&public_key,
Uuid::new_v4(),
payload.clone(),
&signature,
)
.await
);
assert!(
!relay_live_data(
&clients,
&public_key,
connection_id,
"tampered".to_owned(),
&signature,
)
.await
);
assert!(mutual_receiver.try_recv().is_err());
}
}
+31 -1
View File
@@ -30,6 +30,13 @@ pub struct CursorPositionChanged {
pub positions: CursorPositions,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct FriendCursorPositionChanged {
pub friend_id: String,
pub positions: CursorPositions,
}
#[derive(Default)]
pub struct CursorState(RwLock<CursorPositions>);
@@ -91,9 +98,32 @@ fn update_cursor_position(handle: &AppHandle, positions: CursorPositions) {
*guard = positions.clone();
drop(guard);
if let Err(error) = (CursorPositionChanged { positions }).emit(handle) {
if let Err(error) = (CursorPositionChanged {
positions: positions.clone(),
})
.emit(handle)
{
eprintln!("Failed to emit cursor position change: {error}");
}
handle
.state::<crate::network::Network>()
.send_live_data(crate::live_data::LiveData::Cursor { positions });
}
pub(crate) fn emit_friend_position(
handle: &AppHandle,
friend_id: String,
positions: CursorPositions,
) {
if let Err(error) = (FriendCursorPositionChanged {
friend_id,
positions,
})
.emit(handle)
{
eprintln!("Failed to emit friend cursor position change: {error}");
}
}
/// Convert absolute to normalized coordinates (0.12, 0.78), or normalized to absolute (1234, 567)
+3
View File
@@ -2,6 +2,7 @@ mod cursor;
mod db;
mod friends;
mod keypair;
mod live_data;
mod network;
mod profile;
mod remotes;
@@ -73,7 +74,9 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
profile::ProfileChanged,
network::NetworkStatusChanged,
cursor::CursorPositionChanged,
cursor::FriendCursorPositionChanged,
ufa::ForegroundAppChanged,
ufa::FriendForegroundAppChanged,
])
}
+37
View File
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
use crate::cursor::CursorPositions;
use crate::ufa::AppMeta;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum LiveData {
Cursor { positions: CursorPositions },
ForegroundApp { meta: AppMeta },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cursor::{CursorPosition, CursorPositions};
#[test]
fn live_data_is_internally_tagged_for_client_side_dispatch() {
let payload = serde_json::to_string(&LiveData::Cursor {
positions: CursorPositions {
raw: CursorPosition { x: 120.0, y: 80.0 },
mapped: CursorPosition { x: 0.25, y: 0.5 },
},
})
.unwrap();
assert_eq!(
payload,
r#"{"type":"cursor","positions":{"raw":{"x":120.0,"y":80.0},"mapped":{"x":0.25,"y":0.5}}}"#
);
assert!(matches!(
serde_json::from_str(&payload).unwrap(),
LiveData::Cursor { .. }
));
}
}
+32 -10
View File
@@ -18,6 +18,7 @@ use wyd_common::{
use crate::db::AppDatabase;
use crate::friends::{self, FriendsChanged};
use crate::keypair::AppKeypair;
use crate::live_data::LiveData;
use crate::remotes::{self, Remote, RemotesChanged};
type Statuses = Arc<Mutex<HashMap<String, (u64, ConnectionStatus)>>>;
@@ -62,16 +63,23 @@ pub struct Network {
}
impl Network {
#[allow(dead_code)] // Ready for the first domain message sender.
pub fn send(&self, remote_id: &str, payload: String) -> Result<(), String> {
let connections = self.connections.lock().map_err(|error| error.to_string())?;
let sender = connections
.get(remote_id)
.map(|connection| &connection.sender)
.ok_or_else(|| "remote is not configured".to_string())?;
sender
.try_send(payload)
.map_err(|error| format!("remote is not ready: {error}"))
pub fn send_live_data(&self, data: LiveData) {
let Ok(payload) = serde_json::to_string(&data) else {
eprintln!("failed to serialize live data");
return;
};
let Ok(connections) = self.connections.lock() else {
eprintln!("failed to lock remote connections for live data");
return;
};
for connection in connections.values() {
match connection.sender.try_send(payload.clone()) {
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
Err(mpsc::error::TrySendError::Closed(_)) => {
eprintln!("remote {} cannot accept live data", connection.remote.id);
}
}
}
}
pub fn update_profile(&self, profile: crate::user::User) {
@@ -263,6 +271,7 @@ async fn connect(
if !matches!(recv(&mut reader).await?, ServerMessage::Registered) {
return Err("server rejected registration".into());
}
while outgoing.try_recv().is_ok() {}
send(&mut writer, &ClientMessage::SyncFriendProfiles).await?;
changed(
handle,
@@ -315,6 +324,19 @@ async fn connect(
eprintln!("failed to synchronize friend profiles: {error}");
}
}
ServerMessage::FriendLiveData { friend_id, payload } => {
match serde_json::from_str(&payload) {
Ok(LiveData::Cursor { positions }) => {
crate::cursor::emit_friend_position(handle, friend_id, positions);
}
Ok(LiveData::ForegroundApp { meta }) => {
crate::ufa::emit_friend_app(handle, friend_id, meta);
}
Err(error) => {
eprintln!("failed to decode friend live data: {error}");
}
}
}
_ => {}
},
Message::Ping(data) => writer.send(Message::Pong(data)).await?,
+18 -1
View File
@@ -19,6 +19,13 @@ pub struct ForegroundAppChanged {
pub meta: AppMeta,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct FriendForegroundAppChanged {
pub friend_id: String,
pub meta: AppMeta,
}
#[derive(Default)]
pub struct ForegroundAppState(RwLock<AppMeta>);
@@ -67,8 +74,18 @@ pub fn init(handle: &AppHandle) {
*current = meta.clone();
drop(current);
if let Err(error) = (ForegroundAppChanged { meta }).emit(&handle) {
if let Err(error) = (ForegroundAppChanged { meta: meta.clone() }).emit(&handle) {
eprintln!("Failed to emit foreground app change: {error}");
}
handle
.state::<crate::network::Network>()
.send_live_data(crate::live_data::LiveData::ForegroundApp { meta });
});
}
pub(crate) fn emit_friend_app(handle: &AppHandle, friend_id: String, meta: AppMeta) {
if let Err(error) = (FriendForegroundAppChanged { friend_id, meta }).emit(handle) {
eprintln!("Failed to emit friend foreground app change: {error}");
}
}
+6
View File
@@ -52,6 +52,8 @@ async listStatuses() : Promise<ConnectionStatus[]> {
export const events = __makeEvents__<{
cursorPositionChanged: CursorPositionChanged,
foregroundAppChanged: ForegroundAppChanged,
friendCursorPositionChanged: FriendCursorPositionChanged,
friendForegroundAppChanged: FriendForegroundAppChanged,
friendsChanged: FriendsChanged,
networkStatusChanged: NetworkStatusChanged,
profileChanged: ProfileChanged,
@@ -59,6 +61,8 @@ remotesChanged: RemotesChanged
}>({
cursorPositionChanged: "cursor-position-changed",
foregroundAppChanged: "foreground-app-changed",
friendCursorPositionChanged: "friend-cursor-position-changed",
friendForegroundAppChanged: "friend-foreground-app-changed",
friendsChanged: "friends-changed",
networkStatusChanged: "network-status-changed",
profileChanged: "profile-changed",
@@ -81,6 +85,8 @@ export type CursorPosition = { x: number; y: number }
export type CursorPositionChanged = { positions: CursorPositions }
export type CursorPositions = { raw: CursorPosition; mapped: CursorPosition }
export type ForegroundAppChanged = { meta: AppMeta }
export type FriendCursorPositionChanged = { friendId: string; positions: CursorPositions }
export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta }
export type FriendsChanged = { friends: User[] }
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
export type ProfileChanged = { profile: User }
+469
View File
@@ -0,0 +1,469 @@
<script lang="ts">
import { onMount } from "svelte";
import {
commands,
events,
type AppMeta,
type CursorPositions,
type User,
} from "$lib/bindings";
type FriendLiveData = {
cursor: CursorPositions | null;
foregroundApp: AppMeta | null;
};
let friends: User[] = [];
let liveData = new Map<string, FriendLiveData>();
let error = "";
const emptyLiveData = (): FriendLiveData => ({
cursor: null,
foregroundApp: null,
});
function applyFriends(next: User[]) {
const friendIds = new Set(next.map((friend) => friend.id));
friends = next;
liveData = new Map(
[...liveData].filter(([friendId]) => friendIds.has(friendId)),
);
}
function updateCursor(friendId: string, cursor: CursorPositions) {
liveData = new Map(liveData).set(friendId, {
...(liveData.get(friendId) ?? emptyLiveData()),
cursor,
});
}
function updateForegroundApp(friendId: string, foregroundApp: AppMeta) {
liveData = new Map(liveData).set(friendId, {
...(liveData.get(friendId) ?? emptyLiveData()),
foregroundApp,
});
}
function initials(name: string) {
return name.trim().slice(0, 1).toUpperCase() || "?";
}
function compactId(id: string) {
return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-6)}` : id;
}
function cursorStyle(cursor: CursorPositions) {
const x = Math.min(1, Math.max(0, cursor.mapped.x)) * 100;
const y = Math.min(1, Math.max(0, cursor.mapped.y)) * 100;
return `--cursor-x: ${x}%; --cursor-y: ${y}%;`;
}
onMount(() => {
let disposed = false;
let stopListeners: (() => void)[] = [];
async function initialize() {
const listeners = await Promise.all([
events.friendsChanged.listen((event) => {
if (!disposed) applyFriends(event.payload.friends);
}),
events.friendCursorPositionChanged.listen((event) => {
if (!disposed) {
updateCursor(event.payload.friendId, event.payload.positions);
}
}),
events.friendForegroundAppChanged.listen((event) => {
if (!disposed) {
updateForegroundApp(event.payload.friendId, event.payload.meta);
}
}),
]);
if (disposed) {
listeners.forEach((stop) => stop());
return;
}
stopListeners = listeners;
try {
const snapshot = await commands.listFriends();
if (!disposed) applyFriends(snapshot);
} catch (err) {
if (!disposed) error = String(err);
}
}
initialize();
return () => {
disposed = true;
stopListeners.forEach((stop) => stop());
};
});
</script>
<section class="signal-console" aria-labelledby="friend-signals-title">
<header>
<div>
<p class="eyebrow">Mutual connections</p>
<h1 id="friend-signals-title">Friend signals</h1>
</div>
<span class="live-indicator"><i></i> Live</span>
</header>
{#if error}
<p class="error" role="alert">{error}</p>
{:else if friends.length === 0}
<div class="empty-state">
<span aria-hidden="true">⌁</span>
<p>Add a friend to watch their live activity arrive here.</p>
</div>
{:else}
<ul>
{#each friends as friend (friend.id)}
{@const data = liveData.get(friend.id) ?? emptyLiveData()}
<li>
<div class="identity">
<span class="avatar" aria-hidden="true">{initials(friend.displayName)}</span>
<div>
<h2>{friend.displayName}</h2>
<code title={friend.id}>{compactId(friend.id)}</code>
</div>
</div>
<div class="signals">
<div class="app-signal">
{#if data.foregroundApp}
{#if data.foregroundApp.ico}
<img
src={`data:image/png;base64,${data.foregroundApp.ico}`}
alt=""
width="36"
height="36"
/>
{:else}
<span class="app-placeholder" aria-hidden="true">◆</span>
{/if}
<div>
<small>Foreground</small>
<strong>
{data.foregroundApp.local ??
data.foregroundApp.unlocal ??
"Unknown app"}
</strong>
</div>
{:else}
<span class="app-placeholder muted" aria-hidden="true">◇</span>
<div>
<small>Foreground</small>
<span class="waiting">Waiting for activity</span>
</div>
{/if}
</div>
<div class="cursor-signal">
<div class="cursor-label">
<small>Cursor</small>
{#if data.cursor}
<span>
{Math.round(data.cursor.mapped.x * 100)} ·
{Math.round(data.cursor.mapped.y * 100)}
</span>
{:else}
<span>— · —</span>
{/if}
</div>
<div
class:active={data.cursor !== null}
class="cursor-field"
style={data.cursor ? cursorStyle(data.cursor) : undefined}
aria-label={data.cursor
? `Cursor at ${Math.round(data.cursor.mapped.x * 100)} percent horizontal and ${Math.round(data.cursor.mapped.y * 100)} percent vertical`
: "Waiting for cursor position"}
>
<i></i>
</div>
</div>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.signal-console {
--ink: #172019;
--muted: #69736b;
--paper: #f5f3e9;
--line: #c8c8b9;
--signal: #c9ff3d;
--hot: #ff6846;
box-sizing: border-box;
min-height: 100%;
padding: 1.15rem;
overflow: hidden;
color: var(--ink);
font-family: "Avenir Next Condensed", "DIN Alternate", sans-serif;
background-color: var(--paper);
border: 1px solid var(--ink);
box-shadow: 6px 6px 0 var(--ink);
}
header,
.identity,
.signals,
.app-signal,
.cursor-label {
display: flex;
align-items: center;
}
header {
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.9rem;
border-bottom: 1px solid var(--ink);
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(1.5rem, 3vw, 2.1rem);
line-height: 0.95;
letter-spacing: -0.04em;
}
.eyebrow,
small,
.live-indicator,
code {
font-family: Menlo, Monaco, monospace;
}
.eyebrow {
margin-bottom: 0.25rem;
color: var(--muted);
font-size: 0.63rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.live-indicator {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
font-size: 0.65rem;
text-transform: uppercase;
border: 1px solid var(--ink);
background: var(--signal);
}
.live-indicator i {
width: 0.45rem;
height: 0.45rem;
background: var(--ink);
border-radius: 50%;
animation: pulse 1.8s ease-in-out infinite;
}
ul {
display: grid;
gap: 0.75rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
li {
padding: 0.8rem;
background: rgba(255, 255, 255, 0.42);
border: 1px solid var(--line);
animation: enter 280ms ease-out both;
}
.identity {
gap: 0.65rem;
margin-bottom: 0.8rem;
}
.avatar {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
flex: 0 0 auto;
font-weight: 800;
background: var(--hot);
border: 1px solid var(--ink);
}
h2 {
font-size: 1rem;
line-height: 1.05;
}
code {
color: var(--muted);
font-size: 0.62rem;
}
.signals {
align-items: stretch;
gap: 0.65rem;
}
.app-signal,
.cursor-signal {
min-width: 0;
flex: 1 1 0;
}
.app-signal {
gap: 0.55rem;
}
.app-signal img,
.app-placeholder {
width: 2.25rem;
height: 2.25rem;
flex: 0 0 auto;
border: 1px solid var(--ink);
}
.app-signal img {
object-fit: contain;
background: white;
}
.app-placeholder {
display: grid;
place-items: center;
background: var(--signal);
}
.app-placeholder.muted {
color: var(--muted);
background: transparent;
border-color: var(--line);
}
small {
display: block;
color: var(--muted);
font-size: 0.58rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
strong,
.waiting {
display: block;
max-width: 10rem;
overflow: hidden;
font-size: 0.75rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.waiting {
color: var(--muted);
}
.cursor-label {
justify-content: space-between;
margin-bottom: 0.25rem;
color: var(--muted);
font-family: Menlo, Monaco, monospace;
font-size: 0.58rem;
}
.cursor-field {
position: relative;
height: 2rem;
overflow: hidden;
background-image: linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 25% 50%;
border: 1px solid var(--line);
}
.cursor-field i {
position: absolute;
top: var(--cursor-y, 50%);
left: var(--cursor-x, 50%);
width: 0.45rem;
height: 0.45rem;
opacity: 0;
background: var(--hot);
border: 1px solid var(--ink);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: top 180ms linear, left 180ms linear;
}
.cursor-field.active i {
opacity: 1;
}
.empty-state,
.error {
margin-top: 1rem;
padding: 1.2rem;
text-align: center;
border: 1px dashed var(--line);
}
.empty-state span {
display: block;
margin-bottom: 0.35rem;
font-size: 1.5rem;
}
.empty-state p,
.error {
color: var(--muted);
font-size: 0.8rem;
}
.error {
color: #9a2410;
}
@keyframes pulse {
50% {
opacity: 0.35;
transform: scale(0.75);
}
}
@keyframes enter {
from {
opacity: 0;
transform: translateY(5px);
}
}
@media (max-width: 540px) {
.signals {
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
.live-indicator i,
li {
animation: none;
}
.cursor-field i {
transition: none;
}
}
</style>
+9 -2
View File
@@ -1,5 +1,6 @@
<script>
import FriendsList from "$lib/components/friends-list.svelte";
import FriendsLiveData from "$lib/components/friends-live-data.svelte";
import ConnectionStatus from "$lib/components/connection-status.svelte";
import Profile from "$lib/components/profile.svelte";
import RemotesList from "$lib/components/remotes-list.svelte";
@@ -10,8 +11,11 @@
<div>
<Profile />
</div>
<div>
<FriendsList />
<div class="friends-area">
<div>
<FriendsList />
</div>
<FriendsLiveData />
</div>
<div>
<RemotesList />
@@ -23,3 +27,6 @@
<LiveMetadata />
</div>
</main>
<style>
</style>