data between friends (with some crazy fancy UI)
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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(®istered_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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user