server priority

This commit is contained in:
2026-08-31 14:45:48 +08:00
parent f82d1041ae
commit c435881944
11 changed files with 681 additions and 154 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
pub const VERSION: u8 = 3; pub const VERSION: u8 = 4;
pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024; pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024;
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024; pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
pub const MAX_SKIN_B64_SIZE: usize = 96 * 1024; pub const MAX_SKIN_B64_SIZE: usize = 96 * 1024;
@@ -0,0 +1,14 @@
ALTER TABLE remotes ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
UPDATE remotes AS remote
SET priority = (
SELECT COUNT(*)
FROM remotes AS preceding
WHERE COALESCE(preceding.name, preceding.address) COLLATE NOCASE
< COALESCE(remote.name, remote.address) COLLATE NOCASE
OR (
COALESCE(preceding.name, preceding.address) COLLATE NOCASE
= COALESCE(remote.name, remote.address) COLLATE NOCASE
AND preceding.id < remote.id
)
);
+1
View File
@@ -73,6 +73,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
remotes::get_remote, remotes::get_remote,
remotes::update_remote, remotes::update_remote,
remotes::delete_remote, remotes::delete_remote,
remotes::reorder_remotes,
profile::get_profile, profile::get_profile,
profile::update_profile, profile::update_profile,
profile::reset_profile_skin, profile::reset_profile_skin,
+41 -3
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::hash::Hash;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
@@ -7,6 +8,27 @@ use tauri::{AppHandle, Manager};
use crate::cursor::CursorPositions; use crate::cursor::CursorPositions;
use crate::ufa::AppMeta; use crate::ufa::AppMeta;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum LiveDataKind {
Cursor,
ForegroundApp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveDataEnvelope {
pub session_id: String,
pub sequence: u64,
#[serde(flatten)]
pub data: LiveData,
}
impl LiveDataEnvelope {
pub(crate) fn kind(&self) -> LiveDataKind {
self.data.kind()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
pub enum LiveData { pub enum LiveData {
@@ -14,6 +36,15 @@ pub enum LiveData {
ForegroundApp { meta: AppMeta }, ForegroundApp { meta: AppMeta },
} }
impl LiveData {
pub(crate) fn kind(&self) -> LiveDataKind {
match self {
Self::Cursor { .. } => LiveDataKind::Cursor,
Self::ForegroundApp { .. } => LiveDataKind::ForegroundApp,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LiveDataSnapshot { pub struct LiveDataSnapshot {
@@ -58,21 +89,28 @@ mod tests {
#[test] #[test]
fn live_data_is_internally_tagged_for_client_side_dispatch() { fn live_data_is_internally_tagged_for_client_side_dispatch() {
let payload = serde_json::to_string(&LiveData::Cursor { let payload = serde_json::to_string(&LiveDataEnvelope {
session_id: "session".to_owned(),
sequence: 7,
data: LiveData::Cursor {
positions: CursorPositions { positions: CursorPositions {
raw: CursorPosition { x: 120.0, y: 80.0 }, raw: CursorPosition { x: 120.0, y: 80.0 },
mapped: CursorPosition { x: 0.25, y: 0.5 }, mapped: CursorPosition { x: 0.25, y: 0.5 },
}, },
},
}) })
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
payload, payload,
r#"{"type":"cursor","positions":{"raw":{"x":120.0,"y":80.0},"mapped":{"x":0.25,"y":0.5}}}"# r#"{"sessionId":"session","sequence":7,"type":"cursor","positions":{"raw":{"x":120.0,"y":80.0},"mapped":{"x":0.25,"y":0.5}}}"#
); );
assert!(matches!( assert!(matches!(
serde_json::from_str(&payload).unwrap(), serde_json::from_str(&payload).unwrap(),
LiveData::Cursor { .. } LiveDataEnvelope {
data: LiveData::Cursor { .. },
..
}
)); ));
} }
} }
+33 -17
View File
@@ -18,7 +18,6 @@ use crate::db::AppDatabase;
use crate::friends; use crate::friends;
use crate::interactions; use crate::interactions;
use crate::keypair::AppKeypair; use crate::keypair::AppKeypair;
use crate::live_data::LiveData;
use crate::remotes::Remote; use crate::remotes::Remote;
pub(super) struct InteractionRequest { pub(super) struct InteractionRequest {
@@ -45,7 +44,8 @@ pub(super) struct ConnectionInputs {
pub(super) profiles: watch::Receiver<crate::user::User>, pub(super) profiles: watch::Receiver<crate::user::User>,
pub(super) friends: watch::Receiver<Vec<String>>, pub(super) friends: watch::Receiver<Vec<String>>,
pub(super) keypair: AppKeypair, pub(super) keypair: AppKeypair,
pub(super) live_data: mpsc::Receiver<String>, pub(super) cursor_data: watch::Receiver<Option<String>>,
pub(super) foreground_app_data: watch::Receiver<Option<String>>,
pub(super) interactions: mpsc::Receiver<InteractionRequest>, pub(super) interactions: mpsc::Receiver<InteractionRequest>,
pub(super) profile_lookups: mpsc::Receiver<ProfileLookupRequest>, pub(super) profile_lookups: mpsc::Receiver<ProfileLookupRequest>,
pub(super) skin_lookups: mpsc::Receiver<SkinLookupRequest>, pub(super) skin_lookups: mpsc::Receiver<SkinLookupRequest>,
@@ -103,7 +103,8 @@ async fn connect(
profiles, profiles,
friends, friends,
keypair, keypair,
live_data, cursor_data,
foreground_app_data,
interactions: active_outgoing, interactions: active_outgoing,
profile_lookups, profile_lookups,
skin_lookups, skin_lookups,
@@ -141,7 +142,8 @@ async fn connect(
if !matches!(recv(&mut reader).await?, ServerMessage::Registered) { if !matches!(recv(&mut reader).await?, ServerMessage::Registered) {
return Err("server rejected registration".into()); return Err("server rejected registration".into());
} }
while live_data.try_recv().is_ok() {} cursor_data.borrow_and_update();
foreground_app_data.borrow_and_update();
while let Ok(request) = active_outgoing.try_recv() { while let Ok(request) = active_outgoing.try_recv() {
let _ = request let _ = request
.response .response
@@ -166,13 +168,26 @@ async fn connect(
> = HashMap::new(); > = HashMap::new();
loop { loop {
tokio::select! { tokio::select! {
payload = live_data.recv() => { changed = cursor_data.changed() => {
let payload = payload.ok_or("network sender closed")?; changed.map_err(|_| "cursor sender closed")?;
let payload = { cursor_data.borrow_and_update().clone() };
if let Some(payload) = payload {
send(&mut writer, &ClientMessage::Signed { send(&mut writer, &ClientMessage::Signed {
signature: keypair.sign(&message_bytes(&payload)), signature: keypair.sign(&message_bytes(&payload)),
payload, payload,
}).await?; }).await?;
} }
}
changed = foreground_app_data.changed() => {
changed.map_err(|_| "foreground-app sender closed")?;
let payload = { foreground_app_data.borrow_and_update().clone() };
if let Some(payload) = payload {
send(&mut writer, &ClientMessage::Signed {
signature: keypair.sign(&message_bytes(&payload)),
payload,
}).await?;
}
}
request = active_outgoing.recv() => { request = active_outgoing.recv() => {
let request = request.ok_or("interaction sender closed")?; let request = request.ok_or("interaction sender closed")?;
let signature = keypair.sign(&interaction_bytes( let signature = keypair.sign(&interaction_bytes(
@@ -243,12 +258,19 @@ async fn connect(
message = reader.next() => match message.ok_or("server closed the socket")?? { message = reader.next() => match message.ok_or("server closed the socket")?? {
Message::Text(text) => match serde_json::from_str(&text)? { Message::Text(text) => match serde_json::from_str(&text)? {
ServerMessage::FriendProfileUpdated { profile } => { ServerMessage::FriendProfileUpdated { profile } => {
if handle.state::<super::Network>().accept_source(&remote.id, &profile.id) {
let database = handle.state::<AppDatabase>(); let database = handle.state::<AppDatabase>();
if let Err(error) = friends::apply_profile_update(handle, &database, profile).await { if let Err(error) = friends::apply_profile_update(handle, &database, profile).await {
eprintln!("failed to update friend profile: {error}"); eprintln!("failed to update friend profile: {error}");
} }
} }
}
ServerMessage::FriendProfiles { profiles } => { ServerMessage::FriendProfiles { profiles } => {
let network = handle.state::<super::Network>();
let profiles = profiles
.into_iter()
.filter(|profile| network.accept_source(&remote.id, &profile.id))
.collect();
let database = handle.state::<AppDatabase>(); let database = handle.state::<AppDatabase>();
if let Err(error) = friends::apply_profile_sync(handle, &database, profiles).await { if let Err(error) = friends::apply_profile_sync(handle, &database, profiles).await {
eprintln!("failed to synchronize friend profiles: {error}"); eprintln!("failed to synchronize friend profiles: {error}");
@@ -270,17 +292,11 @@ async fn connect(
); );
} }
ServerMessage::FriendLiveData { friend_id, payload } => { ServerMessage::FriendLiveData { friend_id, payload } => {
match serde_json::from_str(&payload) { handle.state::<super::Network>().receive_live_data(
Ok(LiveData::Cursor { positions }) => { handle,
crate::cursor::emit_position(handle, friend_id, positions); friend_id,
} &payload,
Ok(LiveData::ForegroundApp { meta }) => { );
crate::ufa::emit_friend_app(handle, friend_id, meta);
}
Err(error) => {
eprintln!("failed to decode friend live data: {error}");
}
}
} }
ServerMessage::FriendInteraction { ServerMessage::FriendInteraction {
interaction_id, interaction_id,
+181 -70
View File
@@ -3,8 +3,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use futures_util::StreamExt;
use futures_util::stream::FuturesUnordered;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
@@ -15,18 +13,20 @@ use wyd_common::{InteractionContent, InteractionDeliveryStatus};
use crate::db::AppDatabase; use crate::db::AppDatabase;
use crate::friends::{self, FriendsChanged}; use crate::friends::{self, FriendsChanged};
use crate::keypair::AppKeypair; use crate::keypair::AppKeypair;
use crate::live_data::LiveData; use crate::live_data::{LiveData, LiveDataEnvelope, LiveDataKind};
use crate::remotes::{self, Remote, RemotesChanged}; use crate::remotes::{self, Remote, RemotesChanged};
mod connection; mod connection;
mod presence; mod presence;
mod routing;
use connection::{ConnectionInputs, InteractionRequest, ProfileLookupRequest, SkinLookupRequest}; use connection::{ConnectionInputs, InteractionRequest, ProfileLookupRequest, SkinLookupRequest};
use presence::{Change as FriendPresenceChange, FriendPresence}; use presence::{Change as FriendPresenceChange, FriendPresence};
use routing::SequenceTracker;
type Statuses = Arc<Mutex<HashMap<String, (u64, ConnectionStatus)>>>; type Statuses = Arc<Mutex<HashMap<String, (u64, ConnectionStatus)>>>;
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum ConnectionState { pub enum ConnectionState {
Connecting, Connecting,
@@ -57,7 +57,8 @@ pub struct FriendStatusesChanged {
struct Connection { struct Connection {
remote: Remote, remote: Remote,
sender: mpsc::Sender<String>, cursor_sender: watch::Sender<Option<String>>,
foreground_app_sender: watch::Sender<Option<String>>,
interaction_sender: mpsc::Sender<InteractionRequest>, interaction_sender: mpsc::Sender<InteractionRequest>,
profile_lookup_sender: mpsc::Sender<ProfileLookupRequest>, profile_lookup_sender: mpsc::Sender<ProfileLookupRequest>,
skin_lookup_sender: mpsc::Sender<SkinLookupRequest>, skin_lookup_sender: mpsc::Sender<SkinLookupRequest>,
@@ -65,6 +66,15 @@ struct Connection {
generation: u64, generation: u64,
} }
#[derive(Clone)]
struct ConnectionSenders {
remote_id: String,
priority: i32,
interaction: mpsc::Sender<InteractionRequest>,
profile_lookup: mpsc::Sender<ProfileLookupRequest>,
skin_lookup: mpsc::Sender<SkinLookupRequest>,
}
pub struct Network { pub struct Network {
connections: Mutex<HashMap<String, Connection>>, connections: Mutex<HashMap<String, Connection>>,
statuses: Statuses, statuses: Statuses,
@@ -73,6 +83,10 @@ pub struct Network {
friends: watch::Sender<Vec<String>>, friends: watch::Sender<Vec<String>>,
keypair: AppKeypair, keypair: AppKeypair,
next_generation: AtomicU64, next_generation: AtomicU64,
live_session_id: String,
next_cursor_sequence: AtomicU64,
next_foreground_app_sequence: AtomicU64,
received_sequences: SequenceTracker,
} }
impl Network { impl Network {
@@ -81,7 +95,18 @@ impl Network {
} }
pub fn send_live_data(&self, data: LiveData) { pub fn send_live_data(&self, data: LiveData) {
let Ok(payload) = serde_json::to_string(&data) else { let sequence = match data.kind() {
LiveDataKind::Cursor => self.next_cursor_sequence.fetch_add(1, Ordering::Relaxed),
LiveDataKind::ForegroundApp => self
.next_foreground_app_sequence
.fetch_add(1, Ordering::Relaxed),
};
let envelope = LiveDataEnvelope {
session_id: self.live_session_id.clone(),
sequence,
data,
};
let Ok(payload) = serde_json::to_string(&envelope) else {
eprintln!("failed to serialize live data"); eprintln!("failed to serialize live data");
return; return;
}; };
@@ -90,11 +115,63 @@ impl Network {
return; return;
}; };
for connection in connections.values() { for connection in connections.values() {
match connection.sender.try_send(payload.clone()) { match envelope.kind() {
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} LiveDataKind::Cursor => {
Err(mpsc::error::TrySendError::Closed(_)) => { connection.cursor_sender.send_replace(Some(payload.clone()));
eprintln!("remote {} cannot accept live data", connection.remote.id);
} }
LiveDataKind::ForegroundApp => {
connection
.foreground_app_sender
.send_replace(Some(payload.clone()));
}
}
}
}
fn preferred_remote(
&self,
friend_id: &str,
incoming_remote_id: Option<&str>,
) -> Option<String> {
let mut remote_ids = self.friend_presence.remotes_for(friend_id).ok()?;
if let Some(remote_id) = incoming_remote_id {
remote_ids.insert(remote_id.to_owned());
}
let connections = self.connections.lock().ok()?;
let priorities = connections
.iter()
.map(|(remote_id, connection)| (remote_id.clone(), connection.remote.priority))
.collect();
routing::preferred_remote(&remote_ids, &priorities)
}
pub(super) fn accept_source(&self, remote_id: &str, friend_id: &str) -> bool {
self.preferred_remote(friend_id, Some(remote_id))
.is_some_and(|preferred| preferred == remote_id)
}
fn receive_live_data(&self, handle: &AppHandle, friend_id: String, payload: &str) {
let envelope = match serde_json::from_str::<LiveDataEnvelope>(payload) {
Ok(envelope) => envelope,
Err(error) => {
eprintln!("failed to decode friend live data: {error}");
return;
}
};
match self.received_sequences.accept(&friend_id, &envelope) {
Ok(true) => {}
Ok(false) => return,
Err(error) => {
eprintln!("failed to track friend live data: {error}");
return;
}
}
match envelope.data {
LiveData::Cursor { positions } => {
crate::cursor::emit_position(handle, friend_id, positions);
}
LiveData::ForegroundApp { meta } => {
crate::ufa::emit_friend_app(handle, friend_id, meta);
} }
} }
} }
@@ -103,6 +180,35 @@ impl Network {
self.profile.send_replace(profile); self.profile.send_replace(profile);
} }
fn ordered_senders(&self, friend_id: Option<&str>) -> Result<Vec<ConnectionSenders>, String> {
let routes = friend_id
.map(|friend_id| self.friend_presence.remotes_for(friend_id))
.transpose()?
.unwrap_or_default();
let connections = self.connections.lock().map_err(|error| error.to_string())?;
let statuses = self.statuses.lock().map_err(|error| error.to_string())?;
let mut senders = connections
.values()
.filter(|connection| {
statuses
.get(&connection.remote.id)
.is_some_and(|(_, status)| status.state == ConnectionState::Connected)
})
.filter(|connection| routes.is_empty() || routes.contains(&connection.remote.id))
.map(|connection| ConnectionSenders {
remote_id: connection.remote.id.clone(),
priority: connection.remote.priority,
interaction: connection.interaction_sender.clone(),
profile_lookup: connection.profile_lookup_sender.clone(),
skin_lookup: connection.skin_lookup_sender.clone(),
})
.collect::<Vec<_>>();
senders.sort_by(|left, right| {
(left.priority, &left.remote_id).cmp(&(right.priority, &right.remote_id))
});
Ok(senders)
}
pub async fn send_interaction( pub async fn send_interaction(
&self, &self,
recipient_id: String, recipient_id: String,
@@ -113,18 +219,13 @@ impl Network {
} }
let payload = serde_json::to_string(&content).map_err(|error| error.to_string())?; let payload = serde_json::to_string(&content).map_err(|error| error.to_string())?;
let interaction_id = uuid::Uuid::new_v4().to_string(); let interaction_id = uuid::Uuid::new_v4().to_string();
let senders: Vec<_> = self let senders = self.ordered_senders(Some(&recipient_id))?;
.connections
.lock()
.map_err(|error| error.to_string())?
.values()
.map(|connection| connection.interaction_sender.clone())
.collect();
if senders.is_empty() { if senders.is_empty() {
return Err("No relay connections are configured".to_owned()); return Err("No connected relay can currently reach this friend".to_owned());
} }
let mut responses = Vec::new(); let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let mut statuses = Vec::new();
for sender in senders { for sender in senders {
let (response, receiver) = oneshot::channel(); let (response, receiver) = oneshot::channel();
let request = InteractionRequest { let request = InteractionRequest {
@@ -133,20 +234,15 @@ impl Network {
payload: payload.clone(), payload: payload.clone(),
response, response,
}; };
if sender.try_send(request).is_ok() { if sender.interaction.try_send(request).is_err() {
responses.push(receiver); continue;
} }
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
} }
if responses.is_empty() { let result =
return Err("Relay connections are busy or disconnected".to_owned()); tokio::time::timeout(remaining.min(Duration::from_secs(2)), receiver).await;
}
let mut pending: FuturesUnordered<_> = responses
.into_iter()
.map(|response| tokio::time::timeout(Duration::from_secs(5), response))
.collect();
let mut statuses = Vec::new();
while let Some(result) = pending.next().await {
if let Ok(Ok(status)) = result { if let Ok(Ok(status)) = result {
if status == InteractionDeliveryStatus::Delivered { if status == InteractionDeliveryStatus::Delivered {
return Ok(()); return Ok(());
@@ -169,15 +265,9 @@ impl Network {
return Err("You cannot add your own identification key.".to_owned()); return Err("You cannot add your own identification key.".to_owned());
} }
let senders: Vec<_> = self let senders = self.ordered_senders(None)?;
.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 request_id = uuid::Uuid::new_v4().to_string();
let mut responses = Vec::new(); let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
for sender in senders { for sender in senders {
let (response, receiver) = oneshot::channel(); let (response, receiver) = oneshot::channel();
let request = ProfileLookupRequest { let request = ProfileLookupRequest {
@@ -185,16 +275,15 @@ impl Network {
user_id: user_id.clone(), user_id: user_id.clone(),
response, response,
}; };
if sender.try_send(request).is_ok() { if sender.profile_lookup.try_send(request).is_err() {
responses.push(receiver); continue;
} }
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
} }
let result =
let mut pending: FuturesUnordered<_> = responses tokio::time::timeout(remaining.min(Duration::from_secs(1)), receiver).await;
.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 { if let Ok(Ok(Some(display_name))) = result {
return Ok(Some(display_name)); return Ok(Some(display_name));
} }
@@ -211,35 +300,29 @@ impl Network {
return Ok(None); return Ok(None);
} }
let senders: Vec<_> = self let senders = self.ordered_senders(Some(&user_id))?;
.connections
.lock()
.map_err(|error| error.to_string())?
.values()
.map(|connection| connection.skin_lookup_sender.clone())
.collect();
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
let mut responses = Vec::new(); let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
for sender in senders { for sender in senders {
let (response, receiver) = oneshot::channel(); let (response, receiver) = oneshot::channel();
if sender if sender
.skin_lookup
.try_send(SkinLookupRequest { .try_send(SkinLookupRequest {
request_id: request_id.clone(), request_id: request_id.clone(),
user_id: user_id.clone(), user_id: user_id.clone(),
skin_hash: skin_hash.clone(), skin_hash: skin_hash.clone(),
response, response,
}) })
.is_ok() .is_err()
{ {
responses.push(receiver); continue;
} }
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
} }
let result =
let mut pending: FuturesUnordered<_> = responses tokio::time::timeout(remaining.min(Duration::from_secs(1)), receiver).await;
.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(data))) = result if let Ok(Ok(Some(data))) = result
&& let Some(bytes) = crate::skins::decode_response(&data, &skin_hash) && let Some(bytes) = crate::skins::decode_response(&data, &skin_hash)
{ {
@@ -258,7 +341,11 @@ impl Network {
let stale: Vec<_> = connections let stale: Vec<_> = connections
.iter() .iter()
.filter(|(id, connection)| desired.get(*id) != Some(&connection.remote)) .filter(|(id, connection)| {
desired
.get(*id)
.is_none_or(|remote| !same_connection_configuration(remote, &connection.remote))
})
.map(|(id, _)| id.clone()) .map(|(id, _)| id.clone())
.collect(); .collect();
let mut presence_changes = Vec::new(); let mut presence_changes = Vec::new();
@@ -271,13 +358,17 @@ impl Network {
} }
} }
let mut priority_changed = false;
for remote in desired.into_values() { for remote in desired.into_values() {
if connections.contains_key(&remote.id) { if let Some(connection) = connections.get_mut(&remote.id) {
priority_changed |= connection.remote.priority != remote.priority;
connection.remote = remote;
continue; continue;
} }
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = mpsc::channel(32); let (cursor_sender, cursor_receiver) = watch::channel(None);
let (foreground_app_sender, foreground_app_receiver) = watch::channel(None);
let (interaction_sender, interaction_receiver) = mpsc::channel(16); let (interaction_sender, interaction_receiver) = mpsc::channel(16);
let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16); let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16);
let (skin_lookup_sender, skin_lookup_receiver) = mpsc::channel(16); let (skin_lookup_sender, skin_lookup_receiver) = mpsc::channel(16);
@@ -297,7 +388,8 @@ impl Network {
profiles: self.profile.subscribe(), profiles: self.profile.subscribe(),
friends: self.friends.subscribe(), friends: self.friends.subscribe(),
keypair: self.keypair.clone(), keypair: self.keypair.clone(),
live_data: receiver, cursor_data: cursor_receiver,
foreground_app_data: foreground_app_receiver,
interactions: interaction_receiver, interactions: interaction_receiver,
profile_lookups: profile_lookup_receiver, profile_lookups: profile_lookup_receiver,
skin_lookups: skin_lookup_receiver, skin_lookups: skin_lookup_receiver,
@@ -307,7 +399,8 @@ impl Network {
remote.id.clone(), remote.id.clone(),
Connection { Connection {
remote, remote,
sender, cursor_sender,
foreground_app_sender,
interaction_sender, interaction_sender,
profile_lookup_sender, profile_lookup_sender,
skin_lookup_sender, skin_lookup_sender,
@@ -321,6 +414,9 @@ impl Network {
for change in presence_changes { for change in presence_changes {
apply_friend_presence_change(handle, change); apply_friend_presence_change(handle, change);
} }
if priority_changed && let Err(error) = crate::live_data::publish_current(handle) {
eprintln!("failed to republish live data after server reorder: {error}");
}
emit_statuses(handle, &self.statuses) emit_statuses(handle, &self.statuses)
} }
} }
@@ -340,6 +436,10 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
friends, friends,
keypair, keypair,
next_generation: AtomicU64::new(1), next_generation: AtomicU64::new(1),
live_session_id: uuid::Uuid::new_v4().to_string(),
next_cursor_sequence: AtomicU64::new(1),
next_foreground_app_sequence: AtomicU64::new(1),
received_sequences: SequenceTracker::default(),
}; };
handle.manage(network); handle.manage(network);
handle handle
@@ -365,6 +465,9 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>>
*current = ids.clone(); *current = ids.clone();
true true
}); });
if let Err(error) = network.received_sequences.retain(&ids) {
eprintln!("failed to prune live-data sequences: {error}");
}
apply_friend_presence_change(&listener_handle, network.friend_presence.retain(&ids)); apply_friend_presence_change(&listener_handle, network.friend_presence.retain(&ids));
}); });
Ok(()) Ok(())
@@ -423,12 +526,13 @@ fn apply_friend_presence_change(
{ {
eprintln!("failed to remove offline foreground apps: {error}"); eprintln!("failed to remove offline foreground apps: {error}");
} }
if !change.came_online.is_empty() if change.route_added
&& let Err(error) = crate::live_data::publish_current(handle) && let Err(error) = crate::live_data::publish_current(handle)
{ {
eprintln!("failed to publish current live data: {error}"); eprintln!("failed to publish current live data: {error}");
} }
if let Err(error) = (FriendStatusesChanged { if change.online_changed
&& let Err(error) = (FriendStatusesChanged {
friend_ids: change.online, friend_ids: change.online,
}) })
.emit(handle) .emit(handle)
@@ -522,6 +626,13 @@ fn friend_ids(friends: Vec<crate::friends::Friend>, own_id: &str) -> Vec<String>
ids ids
} }
fn same_connection_configuration(left: &Remote, right: &Remote) -> bool {
left.id == right.id
&& left.address == right.address
&& left.name == right.name
&& left.port == right.port
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::friend_ids; use super::friend_ids;
+53 -11
View File
@@ -6,19 +6,30 @@ pub(super) struct Change {
pub(super) online: Vec<String>, pub(super) online: Vec<String>,
pub(super) came_online: Vec<String>, pub(super) came_online: Vec<String>,
pub(super) went_offline: Vec<String>, pub(super) went_offline: Vec<String>,
pub(super) online_changed: bool,
pub(super) route_added: bool,
} }
#[derive(Default)] #[derive(Default)]
pub(super) struct FriendPresence(Mutex<HashMap<String, HashSet<String>>>); pub(super) struct FriendPresence(Mutex<HashMap<String, HashSet<String>>>);
impl FriendPresence { impl FriendPresence {
pub(super) fn remotes_for(&self, friend_id: &str) -> Result<HashSet<String>, String> {
let by_remote = self.0.lock().map_err(|error| error.to_string())?;
Ok(by_remote
.iter()
.filter(|(_, friend_ids)| friend_ids.contains(friend_id))
.map(|(remote_id, _)| remote_id.clone())
.collect())
}
pub(super) fn replace( pub(super) fn replace(
&self, &self,
remote_id: &str, remote_id: &str,
friend_ids: Vec<String>, friend_ids: Vec<String>,
) -> Result<Option<Change>, String> { ) -> Result<Option<Change>, String> {
let mut by_remote = self.0.lock().map_err(|error| error.to_string())?; let mut by_remote = self.0.lock().map_err(|error| error.to_string())?;
let before = aggregate(&by_remote); let before = by_remote.clone();
let friend_ids = friend_ids.into_iter().collect::<HashSet<_>>(); let friend_ids = friend_ids.into_iter().collect::<HashSet<_>>();
if friend_ids.is_empty() { if friend_ids.is_empty() {
by_remote.remove(remote_id); by_remote.remove(remote_id);
@@ -35,7 +46,7 @@ impl FriendPresence {
online: bool, online: bool,
) -> Result<Option<Change>, String> { ) -> Result<Option<Change>, String> {
let mut by_remote = self.0.lock().map_err(|error| error.to_string())?; let mut by_remote = self.0.lock().map_err(|error| error.to_string())?;
let before = aggregate(&by_remote); let before = by_remote.clone();
if online { if online {
by_remote by_remote
.entry(remote_id.to_owned()) .entry(remote_id.to_owned())
@@ -52,7 +63,7 @@ impl FriendPresence {
pub(super) fn remove(&self, remote_id: &str) -> Result<Option<Change>, String> { pub(super) fn remove(&self, remote_id: &str) -> Result<Option<Change>, String> {
let mut by_remote = self.0.lock().map_err(|error| error.to_string())?; let mut by_remote = self.0.lock().map_err(|error| error.to_string())?;
let before = aggregate(&by_remote); let before = by_remote.clone();
by_remote.remove(remote_id); by_remote.remove(remote_id);
Ok(diff(before, &by_remote)) Ok(diff(before, &by_remote))
} }
@@ -60,7 +71,7 @@ impl FriendPresence {
pub(super) fn retain(&self, known_friend_ids: &[String]) -> Result<Option<Change>, String> { pub(super) fn retain(&self, known_friend_ids: &[String]) -> Result<Option<Change>, String> {
let known_friend_ids = known_friend_ids.iter().collect::<HashSet<_>>(); let known_friend_ids = known_friend_ids.iter().collect::<HashSet<_>>();
let mut by_remote = self.0.lock().map_err(|error| error.to_string())?; let mut by_remote = self.0.lock().map_err(|error| error.to_string())?;
let before = aggregate(&by_remote); let before = by_remote.clone();
by_remote.retain(|_, friend_ids| { by_remote.retain(|_, friend_ids| {
friend_ids.retain(|friend_id| known_friend_ids.contains(friend_id)); friend_ids.retain(|friend_id| known_friend_ids.contains(friend_id));
!friend_ids.is_empty() !friend_ids.is_empty()
@@ -83,11 +94,21 @@ fn aggregate(by_remote: &HashMap<String, HashSet<String>>) -> HashSet<String> {
.collect() .collect()
} }
fn diff(before: HashSet<String>, by_remote: &HashMap<String, HashSet<String>>) -> Option<Change> { fn diff(
let after = aggregate(by_remote); before_by_remote: HashMap<String, HashSet<String>>,
if before == after { by_remote: &HashMap<String, HashSet<String>>,
) -> Option<Change> {
if before_by_remote == *by_remote {
return None; return None;
} }
let before = aggregate(&before_by_remote);
let after = aggregate(by_remote);
let online_changed = before != after;
let route_added = by_remote.iter().any(|(remote_id, friend_ids)| {
before_by_remote
.get(remote_id)
.is_none_or(|before| !friend_ids.is_subset(before))
});
let mut went_offline = before.difference(&after).cloned().collect::<Vec<_>>(); let mut went_offline = before.difference(&after).cloned().collect::<Vec<_>>();
let mut came_online = after.difference(&before).cloned().collect::<Vec<_>>(); let mut came_online = after.difference(&before).cloned().collect::<Vec<_>>();
let mut online = after.into_iter().collect::<Vec<_>>(); let mut online = after.into_iter().collect::<Vec<_>>();
@@ -98,6 +119,8 @@ fn diff(before: HashSet<String>, by_remote: &HashMap<String, HashSet<String>>) -
online, online,
came_online, came_online,
went_offline, went_offline,
online_changed,
route_added,
}) })
} }
@@ -116,21 +139,40 @@ mod tests {
online: vec!["friend".to_owned()], online: vec!["friend".to_owned()],
came_online: vec!["friend".to_owned()], came_online: vec!["friend".to_owned()],
went_offline: Vec::new(), went_offline: Vec::new(),
online_changed: true,
route_added: true,
}) })
); );
assert!( assert_eq!(
presence presence
.replace("remote-b", vec!["friend".to_owned()]) .replace("remote-b", vec!["friend".to_owned()])
.unwrap() .unwrap(),
.is_none() Some(Change {
online: vec!["friend".to_owned()],
came_online: Vec::new(),
went_offline: Vec::new(),
online_changed: false,
route_added: true,
})
);
assert_eq!(
presence.remove("remote-a").unwrap(),
Some(Change {
online: vec!["friend".to_owned()],
came_online: Vec::new(),
went_offline: Vec::new(),
online_changed: false,
route_added: false,
})
); );
assert!(presence.remove("remote-a").unwrap().is_none());
assert_eq!( assert_eq!(
presence.remove("remote-b").unwrap(), presence.remove("remote-b").unwrap(),
Some(Change { Some(Change {
online: Vec::new(), online: Vec::new(),
came_online: Vec::new(), came_online: Vec::new(),
went_offline: vec!["friend".to_owned()], went_offline: vec!["friend".to_owned()],
online_changed: true,
route_added: false,
}) })
); );
} }
+158
View File
@@ -0,0 +1,158 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Mutex;
use crate::live_data::{LiveDataEnvelope, LiveDataKind};
const RETIRED_SESSION_LIMIT: usize = 4;
pub(super) fn preferred_remote(
remote_ids: &HashSet<String>,
priorities: &HashMap<String, i32>,
) -> Option<String> {
remote_ids
.iter()
.filter_map(|remote_id| {
priorities
.get(remote_id)
.map(|priority| (*priority, remote_id))
})
.min()
.map(|(_, remote_id)| remote_id.clone())
}
#[derive(Debug, Hash, PartialEq, Eq)]
struct StreamKey {
friend_id: String,
kind: LiveDataKind,
}
#[derive(Debug)]
struct StreamSequence {
session_id: String,
sequence: u64,
retired_sessions: VecDeque<String>,
}
#[derive(Default)]
pub(super) struct SequenceTracker(Mutex<HashMap<StreamKey, StreamSequence>>);
impl SequenceTracker {
pub(super) fn accept(
&self,
friend_id: &str,
envelope: &LiveDataEnvelope,
) -> Result<bool, String> {
if envelope.sequence == 0 || uuid::Uuid::parse_str(&envelope.session_id).is_err() {
return Ok(false);
}
let key = StreamKey {
friend_id: friend_id.to_owned(),
kind: envelope.kind(),
};
let mut streams = self.0.lock().map_err(|error| error.to_string())?;
let Some(current) = streams.get_mut(&key) else {
streams.insert(
key,
StreamSequence {
session_id: envelope.session_id.clone(),
sequence: envelope.sequence,
retired_sessions: VecDeque::new(),
},
);
return Ok(true);
};
if current.session_id == envelope.session_id {
if envelope.sequence <= current.sequence {
return Ok(false);
}
current.sequence = envelope.sequence;
return Ok(true);
}
if current.retired_sessions.contains(&envelope.session_id) {
return Ok(false);
}
current
.retired_sessions
.push_back(current.session_id.clone());
if current.retired_sessions.len() > RETIRED_SESSION_LIMIT {
current.retired_sessions.pop_front();
}
current.session_id = envelope.session_id.clone();
current.sequence = envelope.sequence;
Ok(true)
}
pub(super) fn retain(&self, friend_ids: &[String]) -> Result<(), String> {
let mut streams = self.0.lock().map_err(|error| error.to_string())?;
streams.retain(|key, _| friend_ids.contains(&key.friend_id));
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use super::{SequenceTracker, preferred_remote};
use crate::cursor::{CursorPosition, CursorPositions};
use crate::live_data::{LiveData, LiveDataEnvelope};
fn envelope(session_id: &str, sequence: u64) -> LiveDataEnvelope {
LiveDataEnvelope {
session_id: session_id.to_owned(),
sequence,
data: LiveData::Cursor {
positions: CursorPositions {
raw: CursorPosition::default(),
mapped: CursorPosition::default(),
},
},
}
}
#[test]
fn rejects_duplicate_out_of_order_and_retired_session_packets() {
let tracker = SequenceTracker::default();
let first = uuid::Uuid::new_v4().to_string();
let second = uuid::Uuid::new_v4().to_string();
assert!(tracker.accept("friend", &envelope(&first, 2)).unwrap());
assert!(!tracker.accept("friend", &envelope(&first, 2)).unwrap());
assert!(!tracker.accept("friend", &envelope(&first, 1)).unwrap());
assert!(tracker.accept("friend", &envelope(&second, 1)).unwrap());
assert!(!tracker.accept("friend", &envelope(&first, 3)).unwrap());
assert!(tracker.accept("friend", &envelope(&second, 2)).unwrap());
}
#[test]
fn relay_duplicates_do_not_block_a_newer_stream_packet() {
let tracker = SequenceTracker::default();
let session = uuid::Uuid::new_v4().to_string();
assert!(tracker.accept("friend", &envelope(&session, 7)).unwrap());
assert!(!tracker.accept("friend", &envelope(&session, 7)).unwrap());
assert!(tracker.accept("friend", &envelope(&session, 8)).unwrap());
}
#[test]
fn preferred_remote_uses_priority_then_stable_id() {
let sources = HashSet::from([
"lower".to_owned(),
"higher-b".to_owned(),
"higher-a".to_owned(),
]);
let priorities = HashMap::from([
("lower".to_owned(), 3),
("higher-b".to_owned(), 1),
("higher-a".to_owned(), 1),
]);
assert_eq!(
preferred_remote(&sources, &priorities).as_deref(),
Some("higher-a")
);
}
}
+123 -18
View File
@@ -12,6 +12,7 @@ pub struct Remote {
pub address: String, pub address: String,
pub name: Option<String>, pub name: Option<String>,
pub port: Option<u16>, pub port: Option<u16>,
pub priority: i32,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -30,36 +31,43 @@ pub struct RemotesChanged {
pub async fn all(database: &AppDatabase) -> Result<Vec<Remote>, sqlx::Error> { pub async fn all(database: &AppDatabase) -> Result<Vec<Remote>, sqlx::Error> {
sqlx::query_as::<_, Remote>( sqlx::query_as::<_, Remote>(
"SELECT id, address, name, port FROM remotes \ "SELECT id, address, name, port, priority FROM remotes \
ORDER BY COALESCE(name, address) COLLATE NOCASE, id", ORDER BY priority, id",
) )
.fetch_all(database.pool()) .fetch_all(database.pool())
.await .await
} }
async fn get(database: &AppDatabase, id: &str) -> Result<Option<Remote>, sqlx::Error> { async fn get(database: &AppDatabase, id: &str) -> Result<Option<Remote>, sqlx::Error> {
sqlx::query_as::<_, Remote>("SELECT id, address, name, port FROM remotes WHERE id = ?1") sqlx::query_as::<_, Remote>(
"SELECT id, address, name, port, priority FROM remotes WHERE id = ?1",
)
.bind(id) .bind(id)
.fetch_optional(database.pool()) .fetch_optional(database.pool())
.await .await
} }
async fn insert(database: &AppDatabase, input: RemoteInput) -> Result<Remote, sqlx::Error> { async fn insert(database: &AppDatabase, input: RemoteInput) -> Result<Remote, sqlx::Error> {
let id = Uuid::new_v4().to_string();
let priority = sqlx::query_scalar::<_, i32>(
"INSERT INTO remotes (id, address, name, port, priority) \
SELECT ?1, ?2, ?3, ?4, COALESCE(MAX(priority) + 1, 0) FROM remotes \
RETURNING priority",
)
.bind(&id)
.bind(&input.address)
.bind(&input.name)
.bind(input.port)
.fetch_one(database.pool())
.await?;
let remote = Remote { let remote = Remote {
id: Uuid::new_v4().to_string(), id,
address: input.address, address: input.address,
name: input.name, name: input.name,
port: input.port, port: input.port,
priority,
}; };
sqlx::query("INSERT INTO remotes (id, address, name, port) VALUES (?1, ?2, ?3, ?4)")
.bind(&remote.id)
.bind(&remote.address)
.bind(&remote.name)
.bind(remote.port)
.execute(database.pool())
.await?;
Ok(remote) Ok(remote)
} }
@@ -80,12 +88,41 @@ async fn update(
return Ok(None); return Ok(None);
} }
Ok(Some(Remote { get(database, id).await
id: id.to_owned(), }
address: input.address,
name: input.name, async fn reorder(database: &AppDatabase, ids: &[String]) -> Result<bool, String> {
port: input.port, let existing = all(database).await.map_err(db::command_error)?;
})) if ids.len() != existing.len() {
return Err("Server order must contain every configured server exactly once".to_owned());
}
let mut expected = existing
.into_iter()
.map(|remote| remote.id)
.collect::<Vec<_>>();
let mut provided = ids.to_vec();
expected.sort_unstable();
provided.sort_unstable();
provided.dedup();
if provided != expected {
return Err("Server order contains an unknown or duplicate server".to_owned());
}
let mut transaction = database.pool().begin().await.map_err(db::command_error)?;
let mut changed = false;
for (priority, id) in ids.iter().enumerate() {
let result =
sqlx::query("UPDATE remotes SET priority = ?1 WHERE id = ?2 AND priority != ?1")
.bind(priority as i32)
.bind(id)
.execute(&mut *transaction)
.await
.map_err(db::command_error)?;
changed |= result.rows_affected() > 0;
}
transaction.commit().await.map_err(db::command_error)?;
Ok(changed)
} }
async fn delete(database: &AppDatabase, id: &str) -> Result<bool, sqlx::Error> { async fn delete(database: &AppDatabase, id: &str) -> Result<bool, sqlx::Error> {
@@ -178,6 +215,19 @@ pub async fn delete_remote(
Ok(changed) Ok(changed)
} }
#[tauri::command]
#[specta::specta]
pub async fn reorder_remotes(
handle: AppHandle,
database: State<'_, AppDatabase>,
ids: Vec<String>,
) -> Result<Vec<Remote>, String> {
if reorder(&database, &ids).await? {
emit_changed(&handle, &database).await?;
}
all(&database).await.map_err(db::command_error)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
@@ -216,6 +266,7 @@ mod tests {
assert_eq!(remote.address, "play.example.com"); assert_eq!(remote.address, "play.example.com");
assert_eq!(remote.name, None); assert_eq!(remote.name, None);
assert_eq!(remote.port, None); assert_eq!(remote.port, None);
assert_eq!(remote.priority, 0);
assert_eq!(get(&database, &remote.id).await.unwrap(), Some(remote)); assert_eq!(get(&database, &remote.id).await.unwrap(), Some(remote));
} }
@@ -250,6 +301,7 @@ mod tests {
assert_eq!(updated.address, "new.example.com"); assert_eq!(updated.address, "new.example.com");
assert_eq!(updated.name.as_deref(), Some("New name")); assert_eq!(updated.name.as_deref(), Some("New name"));
assert_eq!(updated.port, None); assert_eq!(updated.port, None);
assert_eq!(updated.priority, created.priority);
assert_eq!(all(&database).await.unwrap(), vec![updated.clone()]); assert_eq!(all(&database).await.unwrap(), vec![updated.clone()]);
assert!(delete(&database, &updated.id).await.unwrap()); assert!(delete(&database, &updated.id).await.unwrap());
assert!(!delete(&database, &updated.id).await.unwrap()); assert!(!delete(&database, &updated.id).await.unwrap());
@@ -274,4 +326,57 @@ mod tests {
assert_eq!(updated, None); assert_eq!(updated, None);
} }
#[tokio::test]
async fn reorder_validates_and_persists_the_complete_priority_order() {
let database = database().await;
let first = insert(
&database,
RemoteInput {
address: "first.example.com".to_owned(),
name: None,
port: None,
},
)
.await
.unwrap();
let second = insert(
&database,
RemoteInput {
address: "second.example.com".to_owned(),
name: None,
port: None,
},
)
.await
.unwrap();
assert!(
reorder(&database, &[second.id.clone(), first.id.clone()])
.await
.unwrap()
);
let ordered = all(&database).await.unwrap();
assert_eq!(
ordered.iter().map(|remote| &remote.id).collect::<Vec<_>>(),
[&second.id, &first.id]
);
assert_eq!(
ordered
.iter()
.map(|remote| remote.priority)
.collect::<Vec<_>>(),
[0, 1]
);
assert!(
!reorder(&database, &[second.id.clone(), first.id.clone()])
.await
.unwrap()
);
assert!(
reorder(&database, std::slice::from_ref(&first.id))
.await
.is_err()
);
}
} }
+4 -1
View File
@@ -32,6 +32,9 @@ async updateRemote(id: string, remote: RemoteInput) : Promise<Remote | null> {
async deleteRemote(id: string) : Promise<boolean> { async deleteRemote(id: string) : Promise<boolean> {
return await TAURI_INVOKE("delete_remote", { id }); return await TAURI_INVOKE("delete_remote", { id });
}, },
async reorderRemotes(ids: string[]) : Promise<Remote[]> {
return await TAURI_INVOKE("reorder_remotes", { ids });
},
async getProfile() : Promise<User> { async getProfile() : Promise<User> {
return await TAURI_INVOKE("get_profile"); return await TAURI_INVOKE("get_profile");
}, },
@@ -154,7 +157,7 @@ export type ProfileChanged = { profile: User }
export type PuppetMovementMode = "free" | "bottom" export type PuppetMovementMode = "free" | "bottom"
export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean } export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean }
export type PuppetStatesChanged = { puppets: PuppetState[] } export type PuppetStatesChanged = { puppets: PuppetState[] }
export type Remote = { id: string; address: string; name: string | null; port: number | null } export type Remote = { id: string; address: string; name: string | null; port: number | null; priority: number }
export type RemoteInput = { address: string; name: string | null; port: number | null } export type RemoteInput = { address: string; name: string | null; port: number | null }
export type RemotesChanged = { remotes: Remote[] } export type RemotesChanged = { remotes: Remote[] }
export type SceneConfiguration = { puppetScale: number; puppetOpacity: number; puppetMovementMode: PuppetMovementMode } export type SceneConfiguration = { puppetScale: number; puppetOpacity: number; puppetMovementMode: PuppetMovementMode }
@@ -67,6 +67,24 @@
} }
} }
async function moveRemote(id: string, offset: -1 | 1) {
const index = $remotes.findIndex((remote) => remote.id === id);
const target = index + offset;
if (index < 0 || target < 0 || target >= $remotes.length) return;
const ordered = [...$remotes];
[ordered[index], ordered[target]] = [ordered[target], ordered[index]];
busy = true;
error = "";
try {
await commands.reorderRemotes(ordered.map((remote) => remote.id));
} catch (cause) {
error = String(cause);
} finally {
busy = false;
}
}
function reset() { function reset() {
mode = "browse"; mode = "browse";
error = ""; error = "";
@@ -119,13 +137,13 @@
class="flex min-h-0 flex-1 flex-col border border-base-300 bg-base-100" class="flex min-h-0 flex-1 flex-col border border-base-300 bg-base-100"
> >
<div <div
class="grid grid-cols-[1fr_5.5rem] border-b border-base-300 bg-base-200 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-base-content/60" class="grid grid-cols-[1fr_5.5rem_3.5rem] border-b border-base-300 bg-base-200 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-base-content/60"
> >
<span>Server</span><span>Connection</span> <span>Server</span><span>Connection</span><span>Priority</span>
</div> </div>
<div <div
class="min-h-0 flex-1 overflow-y-auto" class="min-h-0 flex-1 overflow-y-auto"
role="listbox" role="group"
aria-label="Configured servers" aria-label="Configured servers"
> >
{#if $remotes.length === 0} {#if $remotes.length === 0}
@@ -138,17 +156,20 @@
</p> </p>
</div> </div>
{:else} {:else}
{#each $remotes as remote (remote.id)} {#each $remotes as remote, index (remote.id)}
{@const state = statusFor(remote.id)} {@const state = statusFor(remote.id)}
<div
class="grid w-full grid-cols-[1fr_5.5rem_3.5rem] items-center border-b border-base-200 px-2 py-1 text-xs hover:bg-base-200"
class:bg-primary={selectedId === remote.id}
class:text-primary-content={selectedId === remote.id}
>
<button <button
type="button" type="button"
role="option" class="min-w-0 py-0.5 text-left"
aria-selected={selectedId === remote.id} aria-pressed={selectedId === remote.id}
class="grid w-full grid-cols-[1fr_5.5rem] items-center border-b border-base-200 px-2 py-1.5 text-left text-xs hover:bg-base-200 aria-selected:bg-primary aria-selected:text-primary-content"
onclick={() => (selectedId = remote.id)} onclick={() => (selectedId = remote.id)}
ondblclick={() => openEditWindow(remote.id)} ondblclick={() => openEditWindow(remote.id)}
> >
<span class="min-w-0">
<strong class="block truncate" <strong class="block truncate"
>{remote.name ?? remote.address}</strong >{remote.name ?? remote.address}</strong
> >
@@ -157,7 +178,7 @@
? `:${remote.port}` ? `:${remote.port}`
: ""}</span : ""}</span
> >
</span> </button>
<span class="flex items-center gap-1.5 capitalize"> <span class="flex items-center gap-1.5 capitalize">
<span <span
class:status-success={state === "connected"} class:status-success={state === "connected"}
@@ -166,7 +187,25 @@
></span> ></span>
{state} {state}
</span> </span>
</button> <span class="flex justify-end gap-0.5">
<button
class="btn btn-xs px-1"
type="button"
aria-label={`Increase priority for ${remote.name ?? remote.address}`}
title="Move up"
disabled={busy || index === 0}
onclick={() => moveRemote(remote.id, -1)}>↑</button
>
<button
class="btn btn-xs px-1"
type="button"
aria-label={`Decrease priority for ${remote.name ?? remote.address}`}
title="Move down"
disabled={busy || index === $remotes.length - 1}
onclick={() => moveRemote(remote.id, 1)}>↓</button
>
</span>
</div>
{/each} {/each}
{/if} {/if}
</div> </div>