consolidate remote & local source into one

This commit is contained in:
2026-08-14 18:58:19 +08:00
parent 747e0840f4
commit 828ce5465d
10 changed files with 157 additions and 558 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"description": "Capability for Wyd UI windows",
"windows": ["debug", "scene"],
"permissions": [
"core:default",
"opener:default"
+60 -34
View File
@@ -2,6 +2,7 @@ use device_query::{DeviceEvents, DeviceEventsHandler};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::RwLock;
use std::time::Duration;
@@ -27,18 +28,23 @@ pub struct CursorPositions {
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
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,
pub positions: HashMap<String, CursorPositions>,
}
#[derive(Default)]
pub struct CursorState(RwLock<CursorPositions>);
pub struct CursorState(RwLock<HashMap<String, CursorPositions>>);
impl CursorState {
fn update(
&self,
user_id: String,
positions: CursorPositions,
) -> Result<HashMap<String, CursorPositions>, String> {
let mut positions_by_user = self.0.write().map_err(|error| error.to_string())?;
positions_by_user.insert(user_id, positions);
Ok(positions_by_user.clone())
}
}
// Was private, but for some reason LSP
// complains even when there's no external references.
@@ -92,37 +98,28 @@ pub fn init(app: &AppHandle) {
#[inline]
fn update_cursor_position(handle: &AppHandle, positions: CursorPositions) {
let state = handle.state::<CursorState>();
let mut guard = state.0.write().expect("Cursor Position lock failed");
*guard = positions.clone();
drop(guard);
if let Err(error) = (CursorPositionChanged {
positions: positions.clone(),
})
.emit(handle)
{
eprintln!("Failed to emit cursor position change: {error}");
}
let user_id = handle
.state::<crate::keypair::AppKeypair>()
.public_key()
.to_owned();
emit_position(handle, user_id, positions.clone());
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}");
pub(crate) fn emit_position(handle: &AppHandle, user_id: String, positions: CursorPositions) {
let positions = match handle.state::<CursorState>().update(user_id, positions) {
Ok(positions) => positions,
Err(error) => {
eprintln!("Failed to update cursor positions: {error}");
return;
}
};
if let Err(error) = (CursorPositionChanged { positions }).emit(handle) {
eprintln!("Failed to emit cursor position change: {error}");
}
}
@@ -156,6 +153,35 @@ fn transform_coords(pos: &CursorPosition, to_normalized: bool, w: f64, h: f64) -
}
}
#[cfg(test)]
mod tests {
use super::*;
fn positions(x: f64) -> CursorPositions {
CursorPositions {
raw: CursorPosition { x, y: x },
mapped: CursorPosition { x, y: x },
}
}
#[test]
fn cursor_state_tracks_and_replaces_positions_by_user_id() {
let state = CursorState::default();
let snapshot = state.update("local".to_owned(), positions(1.0)).unwrap();
assert_eq!(snapshot.len(), 1);
assert_eq!(snapshot["local"].raw.x, 1.0);
let snapshot = state.update("friend".to_owned(), positions(2.0)).unwrap();
assert_eq!(snapshot.len(), 2);
assert_eq!(snapshot["friend"].raw.x, 2.0);
let snapshot = state.update("local".to_owned(), positions(3.0)).unwrap();
assert_eq!(snapshot.len(), 2);
assert_eq!(snapshot["local"].raw.x, 3.0);
}
}
/// Stop cursor tracking and unregister all listeners.
#[allow(dead_code)] // TODO: get rid of this when app teardown sequence is introduced.
pub async fn stop_cursor_tracking() {
-3
View File
@@ -9,7 +9,6 @@ mod remotes;
mod ufa;
mod ui;
mod user;
mod windowing;
use tauri::Manager;
@@ -22,7 +21,6 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
app.manage(cursor::CursorState::default());
ufa::init(handle);
cursor::init(handle);
// windowing::init(handle);
ui::init(handle);
Ok(())
}
@@ -76,7 +74,6 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
profile::ProfileChanged,
network::NetworkStatusChanged,
cursor::CursorPositionChanged,
cursor::FriendCursorPositionChanged,
ufa::ForegroundAppChanged,
ufa::FriendForegroundAppChanged,
])
+1 -1
View File
@@ -327,7 +327,7 @@ async fn connect(
ServerMessage::FriendLiveData { friend_id, payload } => {
match serde_json::from_str(&payload) {
Ok(LiveData::Cursor { positions }) => {
crate::cursor::emit_friend_position(handle, friend_id, positions);
crate::cursor::emit_position(handle, friend_id, positions);
}
Ok(LiveData::ForegroundApp { meta }) => {
crate::ufa::emit_friend_app(handle, friend_id, meta);
@@ -1,6 +1,6 @@
use tauri::{AppHandle, Manager, WebviewUrl};
const WINDOW_LABEL: &str = "main";
const WINDOW_LABEL: &str = "debug";
pub fn init(handle: &AppHandle) {
if let Some(window) = handle.get_webview_window(WINDOW_LABEL) {
@@ -14,7 +14,7 @@ pub fn init(handle: &AppHandle) {
let builder =
tauri::WebviewWindowBuilder::new(handle, WINDOW_LABEL, WebviewUrl::App("/".into()))
.title("Wyd")
.title("Wyd Debug")
.inner_size(800.0, 600.0)
.min_inner_size(500.0, 400.0)
.visible(true);
+4
View File
@@ -1,7 +1,11 @@
use tauri::AppHandle;
#[cfg(debug_assertions)]
mod debug;
mod scene;
pub fn init(app_handle: &AppHandle) {
#[cfg(debug_assertions)]
debug::init(app_handle);
scene::init(app_handle);
}
+1 -4
View File
@@ -52,7 +52,6 @@ async listStatuses() : Promise<ConnectionStatus[]> {
export const events = __makeEvents__<{
cursorPositionChanged: CursorPositionChanged,
foregroundAppChanged: ForegroundAppChanged,
friendCursorPositionChanged: FriendCursorPositionChanged,
friendForegroundAppChanged: FriendForegroundAppChanged,
friendsChanged: FriendsChanged,
networkStatusChanged: NetworkStatusChanged,
@@ -61,7 +60,6 @@ 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",
@@ -82,10 +80,9 @@ export type AppMeta = { local: string | null; unlocal: string | null; ico: strin
export type ConnectionState = "connecting" | "connected" | "disconnected"
export type ConnectionStatus = { remoteId: string; address: string; name: string | null; state: ConnectionState }
export type CursorPosition = { x: number; y: number }
export type CursorPositionChanged = { positions: CursorPositions }
export type CursorPositionChanged = { positions: Partial<{ [key in string]: 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[] }
-469
View File
@@ -1,469 +0,0 @@
<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>
+85 -34
View File
@@ -1,45 +1,81 @@
<script lang="ts">
import { onMount } from "svelte";
import {
commands,
events,
type AppMeta,
type CursorPositions,
} from "$lib/bindings";
let cursor: CursorPositions | null = null;
let foregroundApp: AppMeta | null = null;
let localId = "";
let cursorPositions: Partial<Record<string, CursorPositions>> = {};
let foregroundApps = new Map<string, AppMeta>();
let pendingLocalForegroundApp: AppMeta | null = null;
let error = "";
function updateForegroundApp(userId: string, meta: AppMeta) {
foregroundApps = new Map(foregroundApps).set(userId, meta);
}
function liveUserIds() {
return [
...new Set([
localId,
...Object.keys(cursorPositions),
...foregroundApps.keys(),
]),
].filter(Boolean);
}
function compactId(id: string) {
return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-6)}` : id;
}
onMount(() => {
let disposed = false;
let stopCursor: (() => void) | undefined;
let stopForegroundApp: (() => void) | undefined;
let stopListeners: (() => void)[] = [];
async function initialize() {
const [cursorUnlisten, foregroundAppUnlisten] = await Promise.all([
const listeners = await Promise.all([
events.cursorPositionChanged.listen((event) => {
cursor = event.payload.positions;
if (!disposed) cursorPositions = event.payload.positions;
}),
events.foregroundAppChanged.listen((event) => {
foregroundApp = event.payload.meta;
if (disposed) return;
if (localId) {
updateForegroundApp(localId, event.payload.meta);
} else {
pendingLocalForegroundApp = event.payload.meta;
}
}),
events.friendForegroundAppChanged.listen((event) => {
if (!disposed) {
updateForegroundApp(event.payload.friendId, event.payload.meta);
}
}),
]);
if (disposed) {
cursorUnlisten();
foregroundAppUnlisten();
listeners.forEach((stop) => stop());
return;
}
stopListeners = listeners;
stopCursor = cursorUnlisten;
stopForegroundApp = foregroundAppUnlisten;
localId = await commands.getPublicKey();
if (disposed) return;
if (pendingLocalForegroundApp) {
updateForegroundApp(localId, pendingLocalForegroundApp);
pendingLocalForegroundApp = null;
}
}
initialize();
initialize().catch((err) => {
if (!disposed) error = String(err);
});
return () => {
disposed = true;
stopCursor?.();
stopForegroundApp?.();
stopListeners.forEach((stop) => stop());
};
});
</script>
@@ -47,27 +83,42 @@
<section>
<h1>Live metadata</h1>
<h2>Cursor</h2>
{#if cursor}
<p>Raw: {cursor.raw.x}, {cursor.raw.y}</p>
<p>Mapped: {cursor.mapped.x}, {cursor.mapped.y}</p>
{#if error}
<p>{error}</p>
{:else if liveUserIds().length === 0}
<p>Waiting for live metadata...</p>
{:else}
<p>Waiting for cursor data...</p>
{/if}
{#each liveUserIds() as userId (userId)}
{@const cursor = cursorPositions[userId]}
{@const foregroundApp = foregroundApps.get(userId)}
<article>
<h2>{userId === localId ? "Local user" : "Remote user"}</h2>
<p title={userId}>ID: {compactId(userId)}</p>
<h2>Foreground app</h2>
{#if foregroundApp}
{#if foregroundApp.ico}
<img
src={`data:image/png;base64,${foregroundApp.ico}`}
alt=""
width="64"
height="64"
/>
{/if}
<p>Localized name: {foregroundApp.local ?? "Unavailable"}</p>
<p>Executable name: {foregroundApp.unlocal ?? "Unavailable"}</p>
{:else}
<p>Waiting for foreground app data...</p>
<h3>Cursor</h3>
{#if cursor}
<p>Raw: {cursor.raw.x}, {cursor.raw.y}</p>
<p>Mapped: {cursor.mapped.x}, {cursor.mapped.y}</p>
{:else}
<p>Waiting for cursor data...</p>
{/if}
<h3>Foreground app</h3>
{#if foregroundApp}
{#if foregroundApp.ico}
<img
src={`data:image/png;base64,${foregroundApp.ico}`}
alt=""
width="64"
height="64"
/>
{/if}
<p>Localized name: {foregroundApp.local ?? "Unavailable"}</p>
<p>Executable name: {foregroundApp.unlocal ?? "Unavailable"}</p>
{:else}
<p>Waiting for foreground app data...</p>
{/if}
</article>
{/each}
{/if}
</section>
+2 -9
View File
@@ -1,6 +1,5 @@
<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";
@@ -11,11 +10,8 @@
<div>
<Profile />
</div>
<div class="friends-area">
<div>
<FriendsList />
</div>
<FriendsLiveData />
<div>
<FriendsList />
</div>
<div>
<RemotesList />
@@ -27,6 +23,3 @@
<LiveMetadata />
</div>
</main>
<style>
</style>