From 25a0b8a85e54b9dd0dbf247f01295a7776500735 Mon Sep 17 00:00:00 2001 From: Tien Do Nam Date: Fri, 31 Jul 2026 16:04:30 +0200 Subject: [PATCH] feat: bind cli to core/discovery --- cli/src/app/devices.rs | 38 ++- cli/src/app/discovery.rs | 123 ++++------ cli/src/app/mod.rs | 112 ++++----- cli/src/app/receive.rs | 16 +- cli/src/app/sending.rs | 39 ++- cli/src/device_list.rs | 59 ++++- cli/src/devices.rs | 90 ------- cli/src/main.rs | 2 +- cli/src/send_task.rs | 34 +-- cli/src/slots.rs | 52 ++++ packages/core/src/discovery/mod.rs | 61 +++-- packages/core/src/discovery/store.rs | 251 +++++++++++++++----- packages/core/src/model/discovery.rs | 2 +- packages/core/tests/discovery.rs | 73 +++++- server/Cargo.lock | 341 +++++++++++++++------------ 15 files changed, 762 insertions(+), 531 deletions(-) delete mode 100644 cli/src/devices.rs create mode 100644 cli/src/slots.rs diff --git a/cli/src/app/devices.rs b/cli/src/app/devices.rs index 19bff8a2..374169c2 100644 --- a/cli/src/app/devices.rs +++ b/cli/src/app/devices.rs @@ -5,6 +5,7 @@ use super::App; use crate::device_list::{DeviceList, DeviceListOutcome, DeviceRow, Row}; use crate::ui::Category; use crossterm::event::KeyEvent; +use localsend::discovery::StatefulDevice; impl App { pub(super) fn open_device_list(&mut self) { @@ -28,14 +29,15 @@ impl App { let mut empty = true; for (fingerprint, paired) in self.storage.paired.iter() { empty = false; - let discovered = self.registry.by_fingerprint(fingerprint); + let discovered = self.discovery.device_by_fingerprint(fingerprint); rows.push(Row::Device(DeviceRow { fingerprint: fingerprint.clone(), alias: discovered - .map(|device| device.alias.clone()) + .as_ref() + .map(|stored| stored.device.alias.clone()) .unwrap_or_else(|| paired.alias.clone()), - slot: discovered.and_then(|device| device.slot), - host: discovered.map(|device| device.host.clone()), + slot: self.slots.get(fingerprint), + hosts: discovered.as_ref().map(channel_hosts).unwrap_or_default(), paired: true, })); } @@ -46,16 +48,16 @@ impl App { rows.push(Row::Spacer); rows.push(Row::Header("Discovered")); let mut empty = true; - for device in self.registry.devices() { - if self.storage.paired.contains(&device.fingerprint) { + for stored in self.discovery.devices() { + if self.storage.paired.contains(&stored.device.fingerprint) { continue; } empty = false; rows.push(Row::Device(DeviceRow { - fingerprint: device.fingerprint.clone(), - alias: device.alias.clone(), - slot: device.slot, - host: Some(device.host.clone()), + fingerprint: stored.device.fingerprint.clone(), + alias: stored.device.alias.clone(), + slot: self.slots.get(&stored.device.fingerprint), + hosts: channel_hosts(&stored), paired: false, })); } @@ -81,7 +83,7 @@ impl App { self.close_device_list(); if !self.preselected.is_empty() { self.start_send(&fingerprint, self.preselected.clone()); - } else if let Some(device) = self.registry.by_fingerprint(&fingerprint).cloned() { + } else if let Some(device) = self.discovery.device_by_fingerprint(&fingerprint) { self.open_picker(device); } } @@ -102,10 +104,10 @@ impl App { /// device moves up into "Paired"). The log line shows up once the list /// is closed. fn pair(&mut self, fingerprint: &str) { - let Some(device) = self.registry.by_fingerprint(fingerprint) else { + let Some(stored) = self.discovery.device_by_fingerprint(fingerprint) else { return; }; - let alias = device.alias.clone(); + let alias = stored.device.alias; match self .storage .paired @@ -151,3 +153,13 @@ impl App { } } } + +/// The addresses a stored device can be dialed at, best first. +fn channel_hosts(stored: &StatefulDevice) -> Vec { + stored + .get_ranked_channels() + .into_iter() + .filter_map(|channel| channel.http()) + .map(|http| http.host.clone()) + .collect() +} diff --git a/cli/src/app/discovery.rs b/cli/src/app/discovery.rs index 4abbffb2..5bf389e9 100644 --- a/cli/src/app/discovery.rs +++ b/cli/src/app/discovery.rs @@ -1,91 +1,54 @@ -//! Device discovery: multicast announcements are answered with an HTTP -//! register request, and confirmed devices get a slot in the registry. +//! Device discovery: the core discovery answers multicast announcements with +//! an HTTP register request and stores confirmed devices; the CLI assigns a +//! hotkey slot to every device entering the store. -use super::{App, AppEvent}; +use super::App; +use crate::slots::slot_label; use crate::ui::Category; -use localsend::http::client::v2::LsHttpClientV2; -use localsend::http::dto::ProtocolType; -use localsend::http::dto_v2::ProtocolTypeV2; -use localsend::multicast::MulticastEvent; -use std::time::Duration; +use localsend::discovery::{DeviceChannel, DiscoveredDevice, DiscoveryEvent, HttpChannel}; +use localsend::http::dto_v2::RegisterDtoV2; impl App { - pub(super) fn handle_multicast(&mut self, event: MulticastEvent) { - let MulticastEvent::Discovered { - ip, - scope_id, - message, - } = event; - if message.fingerprint == self.storage.identity.fingerprint { + pub(super) fn handle_discovery(&mut self, event: DiscoveryEvent) { + let DiscoveryEvent::Discovered { device } = event else { + // Re-confirmations update the store silently; multi-homed peers + // re-announce with a different address all the time. + return; + }; + let slot = self.slots.assign(&device.fingerprint); + let host = device + .http() + .map(|http| http.host.as_str()) + .unwrap_or("unknown address"); + self.ui.log( + Category::Discovery, + &format!("[{}] {} ({host})", slot_label(slot), device.alias), + ); + } + + /// Feeds a device confirmed outside of discovery — it registered with our + /// HTTP server, or it sent a transfer request — into the discovery store; + /// a device new to the store comes back as a `Discovered` event. + pub(super) fn device_confirmed(&self, host: String, info: RegisterDtoV2) { + if info.fingerprint == self.storage.identity.fingerprint { return; } - let host = match scope_id { - Some(scope_id) => format!("{ip}%{scope_id}"), - None => ip.to_string(), + let device = DiscoveredDevice { + alias: info.alias, + version: info.version, + device_model: info.device_model, + device_type: info.device_type, + fingerprint: info.fingerprint, + channel: DeviceChannel::Http(HttpChannel { + host, + port: info.port, + protocol: info.protocol, + }), + download: info.download, }; - - // Answer the announcement with an HTTP register request; the device - // is only shown once that request succeeds. - let identity = self.storage.identity.clone(); - let events_tx = self.events_tx.clone(); + let discovery = self.discovery.clone(); tokio::spawn(async move { - let expected_fingerprint = match message.protocol { - ProtocolTypeV2::Https => Some(message.fingerprint.clone()), - ProtocolTypeV2::Http => None, - }; - let Ok(client) = LsHttpClientV2::try_new( - &identity.key_pem, - &identity.cert_pem, - expected_fingerprint, - Some(Duration::from_secs(5)), - ) else { - return; - }; - let protocol = match message.protocol { - ProtocolTypeV2::Http => ProtocolType::Http, - ProtocolTypeV2::Https => ProtocolType::Https, - }; - let result = client - .register(protocol, &host, message.port, identity.register_dto()) - .await; - if let Ok(response) = result { - let _ = events_tx - .send(AppEvent::DeviceUp { - alias: response.body.alias, - host, - port: message.port, - protocol: message.protocol, - fingerprint: message.fingerprint, - }) - .await; - } + discovery.add_device(device).await; }); } - - pub(super) fn device_up( - &mut self, - alias: String, - host: String, - port: u16, - protocol: ProtocolTypeV2, - fingerprint: String, - ) { - if fingerprint == self.storage.identity.fingerprint { - return; - } - if let Some(device) = self - .registry - .upsert(alias, host, port, protocol, fingerprint) - { - self.ui.log( - Category::Discovery, - &format!( - "[{}] {} ({})", - device.slot_label(), - device.alias, - device.host - ), - ); - } - } } diff --git a/cli/src/app/mod.rs b/cli/src/app/mod.rs index c76e5fe5..813efbf8 100644 --- a/cli/src/app/mod.rs +++ b/cli/src/app/mod.rs @@ -6,17 +6,18 @@ mod status; use crate::Args; use crate::device_list::DeviceList; -use crate::devices::DeviceRegistry; use crate::picker::Picker; +use crate::slots::Slots; use crate::storage; use crate::ui::{Category, Ui}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use localsend::http::dto_v2::ProtocolTypeV2; +use localsend::discovery::{ + DEFAULT_DISCOVERY_TIMEOUT, DeviceIdentity, DiscoveryConfig, DiscoveryEvent, DiscoveryHandle, +}; use localsend::http::server::v2::ServerEventV2; use localsend::http::server::{ServerConfigV2, ServerHandle, start_with_port}; use localsend::multicast::{ - self, DEFAULT_MULTICAST_GROUP, DEFAULT_MULTICAST_GROUP_V6, DEFAULT_PORT, InterfaceFilter, - MulticastConfig, MulticastEvent, + DEFAULT_MULTICAST_GROUP, DEFAULT_MULTICAST_GROUP_V6, DEFAULT_PORT, InterfaceFilter, }; use receive::{Answer, PendingReceive, ReceiveSession}; use sending::SendState; @@ -30,16 +31,6 @@ pub enum AppEvent { /// A key was pressed. Key(KeyEvent), - /// A device was confirmed reachable (it registered with us, or it - /// announced itself and answered our register request). - DeviceUp { - alias: String, - host: String, - port: u16, - protocol: ProtocolTypeV2, - fingerprint: String, - }, - /// A file of the active receive session finished (or failed). ReceiveFileResult { session_id: String, @@ -63,7 +54,13 @@ pub enum AppEvent { struct App { ui: Ui, server: Arc, - registry: DeviceRegistry, + + /// The core discovery: the store of confirmed devices, and the multicast + /// side when it could be started. + discovery: Arc, + + /// The hotkeys (1-9) of the devices in the discovery store. + slots: Slots, /// Config, identity and paired devices, see [`storage::Repository`]. storage: storage::Repository, @@ -106,34 +103,37 @@ pub async fn run(args: Args) -> anyhow::Result<()> { .await?; let server = Arc::new(server); - // Multicast discovery. Failure is not fatal: transfers to this device - // still work for peers that know its address. - let (multicast_tx, multicast_rx) = mpsc::channel::(16); - let (multicast_stop_tx, multicast_stop_rx) = oneshot::channel::<()>(); - let multicast = multicast::start( - MulticastConfig { - group: DEFAULT_MULTICAST_GROUP, - group_v6: Some(DEFAULT_MULTICAST_GROUP_V6), - port: DEFAULT_PORT, - interface_filter: InterfaceFilter::default(), - device: identity.multicast_device(), - event_tx: multicast_tx, - }, - multicast_stop_rx, - ) - .await; - let multicast = match multicast { - Ok(handle) => Some(Arc::new(handle)), - Err(err) => { - eprintln!("Multicast discovery unavailable: {err:#}"); - None - } - }; - let mut multicast_rx = multicast.is_some().then_some(multicast_rx); - if let Some(handle) = &multicast { + // Discovery: multicast, plus the register requests answering other + // devices' announcements. Multicast failure is not fatal: the store keeps + // collecting the devices that contact this device over HTTP. + let (discovery_tx, mut discovery_rx) = mpsc::channel::(16); + let (discovery_stop_tx, discovery_stop_rx) = oneshot::channel::<()>(); + let discovery = Arc::new( + localsend::discovery::start( + DiscoveryConfig { + group: DEFAULT_MULTICAST_GROUP, + group_v6: Some(DEFAULT_MULTICAST_GROUP_V6), + port: DEFAULT_PORT, + interface_filter: InterfaceFilter::default(), + device: identity.multicast_device(), + identity: DeviceIdentity { + cert_pem: identity.cert_pem.clone(), + private_key_pem: identity.key_pem.clone(), + }, + timeout: DEFAULT_DISCOVERY_TIMEOUT, + event_tx: Some(discovery_tx), + }, + discovery_stop_rx, + ) + .await, + ); + if let Some(err) = discovery.multicast_error() { + eprintln!("Multicast unavailable: {err:#}"); + } + { // Announce this device; peers answer with an HTTP register request. - let handle = handle.clone(); - tokio::spawn(async move { handle.announce().await }); + let discovery = discovery.clone(); + tokio::spawn(async move { discovery.announce().await }); } crossterm::terminal::enable_raw_mode()?; @@ -159,7 +159,8 @@ pub async fn run(args: Args) -> anyhow::Result<()> { let mut app = App { ui: Ui::new(), server: server.clone(), - registry: DeviceRegistry::new(), + discovery: discovery.clone(), + slots: Slots::new(), storage, pending: None, receive: None, @@ -187,8 +188,8 @@ pub async fn run(args: Args) -> anyhow::Result<()> { Some(event) = server_rx.recv() => { app.handle_server_event(event); } - Some(event) = recv_opt(&mut multicast_rx) => { - app.handle_multicast(event); + Some(event) = discovery_rx.recv() => { + app.handle_discovery(event); } _ = tick.tick() => { app.tick(); @@ -206,34 +207,17 @@ pub async fn run(args: Args) -> anyhow::Result<()> { app.ui.set_status(None); let _ = crossterm::terminal::disable_raw_mode(); let _ = server_stop_tx.send(()); - let _ = multicast_stop_tx.send(()); + let _ = discovery_stop_tx.send(()); let _ = tokio::time::timeout(Duration::from_secs(1), server.wait_stopped()).await; - if let Some(multicast) = &multicast { - let _ = tokio::time::timeout(Duration::from_secs(1), multicast.wait_stopped()).await; - } + let _ = tokio::time::timeout(Duration::from_secs(1), discovery.wait_stopped()).await; Ok(()) } -/// Receives from an optional channel, pending forever when there is none. -async fn recv_opt(rx: &mut Option>) -> Option { - match rx { - Some(rx) => rx.recv().await, - None => std::future::pending().await, - } -} - impl App { /// Handles an event; returns `true` when the application should quit. async fn handle_event(&mut self, event: AppEvent) -> bool { match event { AppEvent::Key(key) => return self.handle_key(key), - AppEvent::DeviceUp { - alias, - host, - port, - protocol, - fingerprint, - } => self.device_up(alias, host, port, protocol, fingerprint), AppEvent::ReceiveFileResult { session_id, file_id, diff --git a/cli/src/app/receive.rs b/cli/src/app/receive.rs index ddd3ca44..62db5a5e 100644 --- a/cli/src/app/receive.rs +++ b/cli/src/app/receive.rs @@ -99,13 +99,7 @@ impl App { pub(super) fn handle_server_event(&mut self, event: ServerEventV2) { match event { ServerEventV2::Register { ip, info } => { - self.device_up( - info.alias, - ip.to_string(), - info.port, - info.protocol, - info.fingerprint, - ); + self.device_confirmed(ip.to_string(), info); } ServerEventV2::PrepareUpload { session_id, @@ -116,13 +110,7 @@ impl App { decision_tx, } => { // The sender is clearly reachable; make sure it has a slot. - self.device_up( - info.alias.clone(), - ip.to_string(), - info.port, - info.protocol, - info.fingerprint.clone(), - ); + self.device_confirmed(ip.to_string(), info.clone()); let sender = SenderTarget { host: ip.to_string(), diff --git a/cli/src/app/sending.rs b/cli/src/app/sending.rs index b3fcb456..7549d5e7 100644 --- a/cli/src/app/sending.rs +++ b/cli/src/app/sending.rs @@ -2,12 +2,12 @@ //! transfer driven by the [`crate::send_task`]. use super::App; -use crate::devices::Device; use crate::picker::{Picker, PickerOutcome}; use crate::send_task; use crate::ui::Category; use crate::util::SpeedMeter; use crossterm::event::KeyEvent; +use localsend::discovery::StatefulDevice; use localsend::model::transfer::FileDto; use std::collections::HashMap; use std::path::PathBuf; @@ -28,24 +28,31 @@ pub(super) struct SendState { impl App { pub(super) fn start_picking(&mut self, slot: u8) { - let Some(device) = self.registry.by_slot(slot).cloned() else { + let device = self + .slots + .fingerprint_by_slot(slot) + .and_then(|fingerprint| self.discovery.device_by_fingerprint(fingerprint)); + let Some(device) = device else { self.ui .log(Category::Send, &format!("No device on [{slot}]")); return; }; if !self.preselected.is_empty() { - self.start_send(&device.fingerprint, self.preselected.clone()); + self.start_send(&device.device.fingerprint, self.preselected.clone()); return; } self.open_picker(device); } - pub(super) fn open_picker(&mut self, device: Device) { + pub(super) fn open_picker(&mut self, device: StatefulDevice) { if self.send.is_some() { self.ui.log(Category::Send, "A send is already in progress"); return; } - match Picker::open(device.fingerprint, device.alias.clone()) { + match Picker::open( + device.device.fingerprint.clone(), + device.device.alias.clone(), + ) { Ok(picker) => { self.ui.suspend(); self.picker = Some(picker); @@ -53,7 +60,10 @@ impl App { Err(err) => { self.ui.log( Category::Send, - &format!("{}: could not open the file picker: {err}", device.alias), + &format!( + "{}: could not open the file picker: {err}", + device.device.alias + ), ); } } @@ -85,7 +95,18 @@ impl App { self.ui.log(Category::Send, "A send is already in progress"); return; } - let Some(device) = self.registry.by_fingerprint(fingerprint).cloned() else { + let Some(device) = self.discovery.device_by_fingerprint(fingerprint) else { + return; + }; + let Some(host) = device + .get_best_channel() + .and_then(|channel| channel.http()) + .map(|http| http.host.clone()) + else { + self.ui.log( + Category::Send, + &format!("{}: No dialable address", device.device.alias), + ); return; }; @@ -134,8 +155,8 @@ impl App { let cancel = send_task::SendCancel::new(); self.send = Some(SendState { session_id: None, - alias: device.alias.clone(), - host: device.host.clone(), + alias: device.device.alias.clone(), + host, total_bytes, sent: progress.clone(), cancel: cancel.clone(), diff --git a/cli/src/device_list.rs b/cli/src/device_list.rs index c0f15a37..953c6158 100644 --- a/cli/src/device_list.rs +++ b/cli/src/device_list.rs @@ -30,9 +30,10 @@ pub struct DeviceRow { /// The send hotkey (1-9) of the discovered device, if one was free. pub slot: Option, - /// Where the device was last seen; `None` for a paired device that has - /// not been discovered in this run and therefore cannot be sent to. - pub host: Option, + /// The addresses the device was seen on, the best first; empty for a paired + /// device that has not been discovered in this run and therefore cannot + /// be sent to. + pub hosts: Vec, /// Whether the device is paired; the pair hotkey toggles it. pub paired: bool, @@ -112,7 +113,7 @@ impl DeviceList { KeyCode::Enter => { // A paired device that was never discovered has no address. if let Some(device) = self.selected_device() - && device.host.is_some() + && !device.hosts.is_empty() { return DeviceListOutcome::Send { fingerprint: device.fingerprint.clone(), @@ -221,11 +222,17 @@ fn row_item(row: &Row) -> ListItem<'_> { Some(slot) => slot.to_string(), None => "-".to_string(), }; - match &device.host { - Some(host) => { - ListItem::new(Span::raw(format!(" [{slot}] {} ({host})", device.alias))) + match device.hosts.is_empty() { + false => { + let hosts: Vec = + device.hosts.iter().map(|host| shorten_host(host)).collect(); + ListItem::new(Span::raw(format!( + " [{slot}] {} ({})", + device.alias, + hosts.join(", ") + ))) } - None => ListItem::new(Span::styled( + true => ListItem::new(Span::styled( format!(" [{slot}] {} (offline)", device.alias), Style::default().fg(Color::DarkGray), )), @@ -233,3 +240,39 @@ fn row_item(row: &Row) -> ListItem<'_> { } } } + +/// Shortens a host for the row: an IPv6 address is reduced to its last group +/// (keeping the scope), so `fe80::6888:8aff:febd:8578%15` renders as +/// `::8578%15`. Display only; sending always uses the full address. +fn shorten_host(host: &str) -> String { + let (address, scope) = match host.split_once('%') { + Some((address, scope)) => (address, Some(scope)), + None => (host, None), + }; + if !address.contains(':') { + return host.to_string(); + } + let last_group = address.rsplit(':').next().unwrap_or_default(); + match scope { + Some(scope) => format!("::{last_group}%{scope}"), + None => format!("::{last_group}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_shorten_host() { + assert_eq!(shorten_host("fe80::6888:8aff:febd:8578%15"), "::8578%15"); + assert_eq!(shorten_host("fe80::6888:8aff:febd:8578%7"), "::8578%7"); + assert_eq!(shorten_host("fe80::1%3"), "::1%3"); + assert_eq!(shorten_host("2a00:1450:4001:829::200e"), "::200e"); + assert_eq!( + shorten_host("192.168.178.183"), + "192.168.178.183", + "IPv4 must stay untouched" + ); + } +} diff --git a/cli/src/devices.rs b/cli/src/devices.rs deleted file mode 100644 index be1db70e..00000000 --- a/cli/src/devices.rs +++ /dev/null @@ -1,90 +0,0 @@ -use localsend::http::dto_v2::ProtocolTypeV2; - -/// A discovered LocalSend device. -#[derive(Clone)] -pub struct Device { - /// The hotkey (1-9) assigned to this device, if one was free. - pub slot: Option, - pub alias: String, - - /// The host to dial the device at: an IP address, or the scoped form - /// `fe80::1%3` for link-local IPv6 (the HTTP client accepts both). - pub host: String, - pub port: u16, - pub protocol: ProtocolTypeV2, - pub fingerprint: String, -} - -impl Device { - pub fn slot_label(&self) -> String { - match self.slot { - Some(slot) => slot.to_string(), - None => "-".to_string(), - } - } -} - -/// All devices seen in this run, identified by fingerprint. -pub struct DeviceRegistry { - devices: Vec, -} - -impl DeviceRegistry { - pub fn new() -> Self { - Self { - devices: Vec::new(), - } - } - - /// Adds or updates a device. Returns the device only when it is new - /// (i.e. should be logged): a known device is updated silently, because - /// multi-homed peers re-announce with a different address all the time. - pub fn upsert( - &mut self, - alias: String, - host: String, - port: u16, - protocol: ProtocolTypeV2, - fingerprint: String, - ) -> Option { - if let Some(device) = self - .devices - .iter_mut() - .find(|device| device.fingerprint == fingerprint) - { - device.alias = alias; - device.host = host; - device.port = port; - device.protocol = protocol; - return None; - } - - let slot = - (1..=9u8).find(|slot| !self.devices.iter().any(|device| device.slot == Some(*slot))); - let device = Device { - slot, - alias, - host, - port, - protocol, - fingerprint, - }; - self.devices.push(device.clone()); - Some(device) - } - - pub fn by_slot(&self, slot: u8) -> Option<&Device> { - self.devices.iter().find(|device| device.slot == Some(slot)) - } - - pub fn by_fingerprint(&self, fingerprint: &str) -> Option<&Device> { - self.devices - .iter() - .find(|device| device.fingerprint == fingerprint) - } - - /// All devices in discovery order. - pub fn devices(&self) -> &[Device] { - &self.devices - } -} diff --git a/cli/src/main.rs b/cli/src/main.rs index 521367e6..47201098 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,9 +1,9 @@ mod app; mod banner; mod device_list; -mod devices; mod picker; mod send_task; +mod slots; mod storage; mod ui; mod util; diff --git a/cli/src/send_task.rs b/cli/src/send_task.rs index 7dfeeb61..88a3f592 100644 --- a/cli/src/send_task.rs +++ b/cli/src/send_task.rs @@ -1,10 +1,10 @@ use crate::app::AppEvent; -use crate::devices::Device; use crate::storage::Identity; use crate::ui::Category; use crate::util; use bytes::Bytes; use futures_util::StreamExt; +use localsend::discovery::StatefulDevice; use localsend::http::client::ClientError; use localsend::http::client::v2::LsHttpClientV2; use localsend::http::dto::ProtocolType; @@ -47,7 +47,7 @@ impl SendCancel { /// Always ends by emitting [AppEvent::SendEnded]. pub async fn run_send( identity: Arc, - device: Device, + device: StatefulDevice, files: HashMap, paths: HashMap, progress: Arc, @@ -60,14 +60,14 @@ pub async fn run_send( async fn send_inner( identity: Arc, - device: Device, + device: StatefulDevice, files: HashMap, paths: HashMap, progress: Arc, cancel: SendCancel, events: &mpsc::Sender, ) { - let alias = device.alias.clone(); + let alias = device.device.alias.clone(); let log = |text: String| { let events = events.clone(); async move { @@ -80,12 +80,16 @@ async fn send_inner( } }; - let protocol = match device.protocol { + let Some(http) = device.get_best_channel().and_then(|channel| channel.http()) else { + log(format!("{alias}: No dialable address")).await; + return; + }; + let protocol = match http.protocol { ProtocolTypeV2::Http => ProtocolType::Http, ProtocolTypeV2::Https => ProtocolType::Https, }; - let expected_fingerprint = match device.protocol { - ProtocolTypeV2::Https => Some(device.fingerprint.clone()), + let expected_fingerprint = match http.protocol { + ProtocolTypeV2::Https => Some(device.device.fingerprint.clone()), ProtocolTypeV2::Http => None, }; let client = match LsHttpClientV2::try_new( @@ -109,8 +113,8 @@ async fn send_inner( let prepared = match client .prepare_upload( protocol.clone(), - &device.host, - device.port, + &http.host, + http.port, None, payload, None, @@ -193,8 +197,8 @@ async fn send_inner( match client .upload( protocol.clone(), - &device.host, - device.port, + &http.host, + http.port, None, &response.session_id, file_id, @@ -220,8 +224,8 @@ async fn send_inner( let _ = client .cancel( protocol.clone(), - &device.host, - device.port, + &http.host, + http.port, &response.session_id, ) .await; @@ -238,8 +242,8 @@ async fn send_inner( let _ = client .cancel( protocol.clone(), - &device.host, - device.port, + &http.host, + http.port, &response.session_id, ) .await; diff --git a/cli/src/slots.rs b/cli/src/slots.rs new file mode 100644 index 00000000..44dbc383 --- /dev/null +++ b/cli/src/slots.rs @@ -0,0 +1,52 @@ +/// The hotkeys (1-9) assigned to discovered devices, in discovery order. +pub struct Slots { + /// One entry per discovered fingerprint. A device beyond the ninth gets + /// `None` and keeps it: slots are never released, so a hotkey means the + /// same device for the whole run. + assigned: Vec<(String, Option)>, +} + +impl Slots { + pub fn new() -> Self { + Self { + assigned: Vec::new(), + } + } + + /// Assigns the next free slot to a fingerprint; returns the assigned + /// slot, also when the fingerprint already has one. + pub fn assign(&mut self, fingerprint: &str) -> Option { + if let Some((_, slot)) = self.assigned.iter().find(|(known, _)| known == fingerprint) { + return *slot; + } + let slot = (1..=9u8).find(|slot| { + !self + .assigned + .iter() + .any(|(_, assigned)| *assigned == Some(*slot)) + }); + self.assigned.push((fingerprint.to_string(), slot)); + slot + } + + pub fn get(&self, fingerprint: &str) -> Option { + self.assigned + .iter() + .find(|(known, _)| known == fingerprint) + .and_then(|(_, slot)| *slot) + } + + pub fn fingerprint_by_slot(&self, slot: u8) -> Option<&str> { + self.assigned + .iter() + .find(|(_, assigned)| *assigned == Some(slot)) + .map(|(fingerprint, _)| fingerprint.as_str()) + } +} + +pub fn slot_label(slot: Option) -> String { + match slot { + Some(slot) => slot.to_string(), + None => "-".to_string(), + } +} diff --git a/packages/core/src/discovery/mod.rs b/packages/core/src/discovery/mod.rs index 7a4022a5..16a1dfab 100644 --- a/packages/core/src/discovery/mod.rs +++ b/packages/core/src/discovery/mod.rs @@ -1,7 +1,7 @@ mod store; pub use store::{ - DeviceChannel, DeviceLog, DiscoveredDevice, DiscoveredDeviceWithLogs, HttpChannel, + ChannelStatus, DeviceChannel, DeviceLog, DiscoveredDevice, HttpChannel, StatefulDevice, }; use crate::http::client::{ClientError, LsHttpClientV2}; @@ -77,7 +77,7 @@ pub struct DiscoveryConfig { } /// An event emitted by the discovery. Every event is also logged in -/// [`DiscoveredDeviceWithLogs::logs`]; the accumulated state is read from +/// [`StatefulDevice::logs`]; the accumulated state is read from /// [`DiscoveryHandle::devices`]. #[derive(Clone, Debug)] pub enum DiscoveryEvent { @@ -144,7 +144,7 @@ impl DiscoveryState { host: &str, port: u16, protocol: ProtocolTypeV2, - ) -> Result, ClientError> { + ) -> Result, ClientError> { let response = client .register(client_protocol(protocol), host, port, self.register_dto()) .await?; @@ -170,7 +170,7 @@ impl DiscoveryState { /// Puts a device into the store, logging the confirmation on it, and /// emits the resulting event. Returns whether the device is new, and /// its stored state after the merge. - async fn found(&self, device: DiscoveredDevice) -> (bool, DiscoveredDeviceWithLogs) { + async fn found(&self, device: DiscoveredDevice) -> (bool, StatefulDevice) { let (event, merged) = self.store.upsert(device, SystemTime::now()); let is_new = matches!(event, DiscoveryEvent::Discovered { .. }); if let Some(event_tx) = &self.event_tx { @@ -183,7 +183,9 @@ impl DiscoveryState { /// A handle to a running discovery: the store of discovered devices and the /// application-initiated operations. pub struct DiscoveryHandle { - multicast: MulticastHandle, + /// The multicast side, or the reason it could not be started; discovery + /// keeps working without it, see [`DiscoveryHandle::multicast_error`]. + multicast: Result, state: Arc, } @@ -196,9 +198,22 @@ impl DiscoveryHandle { /// themselves. Feed them back via [`DiscoveryHandle::add_device`]. /// /// Returns once the whole announcement burst has been sent, which takes a - /// few seconds, or immediately once discovery has been stopped. + /// few seconds, or immediately once discovery has been stopped or + /// multicast is unavailable. pub async fn announce(&self) { - self.multicast.announce().await; + if let Ok(multicast) = &self.multicast { + multicast.announce().await; + } + } + + /// The reason the multicast sockets could not be bound (e.g. the port is + /// taken, or there is no usable network interface), when they could not. + /// + /// Discovery then neither hears nor sends announcements; it still learns + /// about devices through [`DiscoveryHandle::discover`], + /// [`DiscoveryHandle::scan_subnet`] and [`DiscoveryHandle::add_device`]. + pub fn multicast_error(&self) -> Option<&anyhow::Error> { + self.multicast.as_ref().err() } /// Discovers a device at a known address, e.g. a favorite or a peer that @@ -214,7 +229,7 @@ impl DiscoveryHandle { host: &str, port: u16, protocol: ProtocolTypeV2, - ) -> Result, ClientError> { + ) -> Result, ClientError> { let client = self.state.unpinned_client()?; self.state.probe(&client, host, port, protocol).await } @@ -230,7 +245,7 @@ impl DiscoveryHandle { interface_ip: Ipv4Addr, port: u16, protocol: ProtocolTypeV2, - ) -> Result, ClientError> { + ) -> Result, ClientError> { if !self.state.scanning.lock().unwrap().insert(interface_ip) { return Ok(Vec::new()); } @@ -273,19 +288,22 @@ impl DiscoveryHandle { } /// All discovered devices in discovery order. - pub fn devices(&self) -> Vec { + pub fn devices(&self) -> Vec { self.state.store.devices() } - pub fn device_by_fingerprint(&self, fingerprint: &str) -> Option { + pub fn device_by_fingerprint(&self, fingerprint: &str) -> Option { self.state.store.by_fingerprint(fingerprint) } /// Waits until discovery has terminated and the multicast sockets have /// been closed, so that the port can be bound again. /// Must be called after requesting a stop via the stop channel. + /// Returns immediately when multicast is unavailable. pub async fn wait_stopped(&self) { - self.multicast.wait_stopped().await; + if let Ok(multicast) = &self.multicast { + multicast.wait_stopped().await; + } } } @@ -309,14 +327,13 @@ impl Drop for ScanGuard<'_> { /// Binds the multicast sockets and starts answering announcements of other /// devices. Nothing is announced until [`DiscoveryHandle::announce`] is called. /// -/// Fails when no network interface could be used, e.g. because the port is -/// already bound by another process or because there is no network at all. -pub async fn start( - config: DiscoveryConfig, - stop_rx: oneshot::Receiver<()>, -) -> anyhow::Result { +/// Starting cannot fail: when no multicast socket could be bound, e.g. because +/// the port is already bound by another process or because there is no network +/// at all, discovery runs without multicast. +pub async fn start(config: DiscoveryConfig, stop_rx: oneshot::Receiver<()>) -> DiscoveryHandle { let (multicast_tx, mut multicast_rx) = mpsc::channel(MULTICAST_CHANNEL_SIZE); + // On failure `multicast_tx` is dropped, which ends the answering task. let multicast = multicast::start( MulticastConfig { group: config.group, @@ -328,7 +345,7 @@ pub async fn start( }, stop_rx, ) - .await?; + .await; let state = Arc::new(DiscoveryState { device: config.device, @@ -358,7 +375,7 @@ pub async fn start( } }); - Ok(DiscoveryHandle { multicast, state }) + DiscoveryHandle { multicast, state } } /// Answers an announcement with a register request, as the protocol requires. @@ -437,11 +454,11 @@ fn confirmed_device( device_model: response.device_model, device_type: response.device_type, fingerprint, - channels: vec![DeviceChannel::Http(HttpChannel { + channel: DeviceChannel::Http(HttpChannel { host, port, protocol, - })], + }), download: response.download, } } diff --git a/packages/core/src/discovery/store.rs b/packages/core/src/discovery/store.rs index 7a6bba79..818222a8 100644 --- a/packages/core/src/discovery/store.rs +++ b/packages/core/src/discovery/store.rs @@ -2,6 +2,8 @@ use super::DiscoveryEvent; use crate::model::discovery::{DeviceType, ProtocolTypeV2}; +use std::collections::HashMap; +use std::net::IpAddr; use std::sync::Mutex; use std::time::SystemTime; @@ -29,26 +31,41 @@ pub struct DiscoveredDevice { /// In HTTPS mode the SHA-256 hash of the certificate, otherwise a random string. pub fingerprint: String, - /// The channels the device is reachable on, in discovery order. - /// A transfer can jump to another channel when one fails. - pub channels: Vec, + /// The channel the device was confirmed over. + pub channel: DeviceChannel, /// Whether the device's download API is active. pub download: bool, } -/// A [`DiscoveredDevice`] as kept in the store, together with the history of -/// events that affected it. +/// A [`DiscoveredDevice`] as kept in the store. #[derive(Clone, Debug)] -pub struct DiscoveredDeviceWithLogs { +pub struct StatefulDevice { + /// The device as it was most recently confirmed. pub device: DiscoveredDevice, + /// Every channel the device was confirmed on, with its current status. + /// Starts with `Available`, the application is responsible + /// to set it to `NotReachable` on error. + pub channels: HashMap, + /// The events that affected this device, oldest first, at most /// [`MAX_LOGS`]. Every confirmation is logged, so the last entry is when /// the device was last seen. pub logs: Vec, } +/// Whether a [`DeviceChannel`] of a [`StatefulDevice`] is believed to work. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelStatus { + /// The channel worked when it was last used. + Available, + + /// The channel failed when it was last used. A re-confirmation over the + /// channel makes it available again. + NotReachable, +} + /// A [`DiscoveryEvent`] that affected a device, with the time it happened. #[derive(Clone, Debug)] pub struct DeviceLog { @@ -56,20 +73,38 @@ pub struct DeviceLog { pub event: DiscoveryEvent, } -impl DiscoveredDevice { - /// The device's HTTP channels — the addresses it is reachable on — in - /// discovery order. A multi-homed device has one per address it was - /// discovered on. - pub fn http_channels(&self) -> impl Iterator { - // With more channel kinds this becomes a `filter_map`. - self.channels.iter().map(|channel| match channel { - DeviceChannel::Http(http) => http, - }) +impl StatefulDevice { + pub fn get_best_channel(&self) -> Option<&DeviceChannel> { + self.get_ranked_channels().into_iter().next() } - /// The device's first HTTP channel, when it has one. + pub fn get_ranked_channels(&self) -> Vec<&DeviceChannel> { + let mut channels: Vec<_> = self.channels.iter().collect(); + channels.sort_by_key(|(channel, status)| { + std::cmp::Reverse(( + **status == ChannelStatus::Available, + channel.is_ipv6(), + self.last_confirmed(channel), + )) + }); + channels.into_iter().map(|(channel, _)| channel).collect() + } + + /// The position of the channel's most recent confirmation in the logs; + /// `None` when its confirmations have been dropped from the log cap. + fn last_confirmed(&self, channel: &DeviceChannel) -> Option { + self.logs.iter().rposition(|log| { + let (DiscoveryEvent::Discovered { device } | DiscoveryEvent::Updated { device }) = + &log.event; + device.channel == *channel + }) + } +} + +impl DiscoveredDevice { + /// The device's HTTP channel, when it was confirmed over HTTP. pub fn http(&self) -> Option<&HttpChannel> { - self.http_channels().next() + self.channel.http() } } @@ -77,13 +112,20 @@ impl DiscoveredDevice { /// /// Only HTTP exists so far; other transports (e.g. WebRTC, Bluetooth) will /// become further variants. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum DeviceChannel { /// The device's HTTP server (protocol v2), reachable at one address. Http(HttpChannel), } impl DeviceChannel { + /// The HTTP address, when this is an HTTP channel. + pub fn http(&self) -> Option<&HttpChannel> { + match self { + DeviceChannel::Http(http) => Some(http), + } + } + /// Whether two channels address the same endpoint, so that a /// re-confirmation updates the known channel instead of adding one. fn same_endpoint(&self, other: &DeviceChannel) -> bool { @@ -91,10 +133,21 @@ impl DeviceChannel { (DeviceChannel::Http(own), DeviceChannel::Http(other)) => own.host == other.host, } } + + /// Whether the channel dials an IPv6 address. + fn is_ipv6(&self) -> bool { + match self { + DeviceChannel::Http(http) => { + // Strip the scope of a link-local address like `fe80::1%3`. + let host = http.host.split('%').next().unwrap_or(&http.host); + matches!(host.parse::(), Ok(IpAddr::V6(_))) + } + } + } } /// The address of a device's HTTP server. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct HttpChannel { /// The host to dial: an IP address, or the scoped form `fe80::1%3` for /// link-local IPv6 (the HTTP client accepts both). @@ -110,7 +163,7 @@ pub struct HttpChannel { /// All devices discovered in this run, identified by fingerprint, in /// discovery order. pub(super) struct DeviceStore { - devices: Mutex>, + devices: Mutex>, } impl DeviceStore { @@ -126,12 +179,13 @@ impl DeviceStore { /// stored state after the merge. /// /// Channels are merged by endpoint: a rediscovery over a known address - /// updates its channel in place, an unknown address adds one. + /// replaces its channel (and makes it available again), an unknown + /// address adds one. pub(super) fn upsert( &self, device: DiscoveredDevice, timestamp: SystemTime, - ) -> (DiscoveryEvent, DiscoveredDeviceWithLogs) { + ) -> (DiscoveryEvent, StatefulDevice) { let mut devices = self.devices.lock().unwrap(); match devices .iter_mut() @@ -142,13 +196,12 @@ impl DeviceStore { device: device.clone(), }; - let mut channels = std::mem::take(&mut known.device.channels); - for channel in device.channels { - match channels.iter_mut().find(|c| c.same_endpoint(&channel)) { - Some(known) => *known = channel, - None => channels.push(channel), - } - } + known + .channels + .retain(|channel, _| !channel.same_endpoint(&device.channel)); + known + .channels + .insert(device.channel.clone(), ChannelStatus::Available); known.logs.push(DeviceLog { timestamp, @@ -159,14 +212,15 @@ impl DeviceStore { known.logs.drain(..excess); } - known.device = DiscoveredDevice { channels, ..device }; + known.device = device; (event, known.clone()) } None => { let event = DiscoveryEvent::Discovered { device: device.clone(), }; - let known = DiscoveredDeviceWithLogs { + let known = StatefulDevice { + channels: HashMap::from([(device.channel.clone(), ChannelStatus::Available)]), device, logs: vec![DeviceLog { timestamp, @@ -180,11 +234,11 @@ impl DeviceStore { } /// All discovered devices in discovery order. - pub(super) fn devices(&self) -> Vec { + pub(super) fn devices(&self) -> Vec { self.devices.lock().unwrap().clone() } - pub(super) fn by_fingerprint(&self, fingerprint: &str) -> Option { + pub(super) fn by_fingerprint(&self, fingerprint: &str) -> Option { self.devices .lock() .unwrap() @@ -205,15 +259,19 @@ mod tests { device_model: None, device_type: Some(DeviceType::Desktop), fingerprint: fingerprint.to_string(), - channels: vec![DeviceChannel::Http(HttpChannel { + channel: DeviceChannel::Http(HttpChannel { host: host.to_string(), port: 53317, protocol: ProtocolTypeV2::Https, - })], + }), download: false, } } + fn channel_host(channel: &DeviceChannel) -> &str { + channel.http().unwrap().host.as_str() + } + /// The marker telling log entries apart: the host the logged snapshot /// was confirmed on. fn log_marker(log: &DeviceLog) -> &str { @@ -282,64 +340,137 @@ mod tests { let devices = store.devices(); assert_eq!(devices.len(), 1); - let hosts: Vec<&str> = devices[0] - .device - .http_channels() - .map(|http| http.host.as_str()) - .collect(); + let known = &devices[0]; + assert!( + known + .channels + .values() + .all(|status| *status == ChannelStatus::Available), + "a confirmed channel must be available" + ); + let mut hosts: Vec<&str> = known.channels.keys().map(channel_host).collect(); + hosts.sort(); assert_eq!( hosts, ["192.168.0.10", "fe80::1%3"], "every address the device was confirmed on must be kept" ); + assert_eq!( + channel_host(&known.device.channel), + "fe80::1%3", + "the device must carry the channel of the last confirmation" + ); } #[test] - fn test_upsert_updates_channel_of_known_endpoint_in_place() { + fn test_upsert_replaces_channel_of_known_endpoint() { let store = DeviceStore::new(); store.upsert(device("a", "192.168.0.10"), SystemTime::now()); store.upsert(device("a", "fe80::1%3"), SystemTime::now()); let mut update = device("a", "192.168.0.10"); - match &mut update.channels[0] { + match &mut update.channel { DeviceChannel::Http(http) => http.port = 54000, } store.upsert(update, SystemTime::now()); let known = store.by_fingerprint("a").unwrap(); - let channels: Vec<(&str, u16)> = known - .device - .http_channels() - .map(|http| (http.host.as_str(), http.port)) + let mut channels: Vec<(&str, u16)> = known + .channels + .keys() + .map(|channel| { + let http = channel.http().unwrap(); + (http.host.as_str(), http.port) + }) .collect(); + channels.sort(); assert_eq!( channels, [("192.168.0.10", 54000), ("fe80::1%3", 53317)], - "a known address must be updated in place, not duplicated" + "a known address must be replaced, not duplicated" ); } #[test] - fn test_upsert_keeps_channels_missing_from_the_update() { + fn test_get_channel_prefers_the_most_recent_confirmation() { let store = DeviceStore::new(); store.upsert(device("a", "192.168.0.10"), SystemTime::now()); + store.upsert(device("a", "10.0.0.10"), SystemTime::now()); + let known = store.by_fingerprint("a").unwrap(); + assert_eq!(channel_host(known.get_best_channel().unwrap()), "10.0.0.10"); - let mut update = device("a", "10.0.0.10"); - update.channels.clear(); - store.upsert(update, SystemTime::now()); - + store.upsert(device("a", "192.168.0.10"), SystemTime::now()); + let known = store.by_fingerprint("a").unwrap(); assert_eq!( - store - .by_fingerprint("a") - .unwrap() - .device - .http() - .unwrap() - .host, + channel_host(known.get_best_channel().unwrap()), "192.168.0.10", - "an update without an HTTP channel must not drop the known one" + "the latest confirmation must win among equals" + ); + } + + #[test] + fn test_get_channel_prefers_ipv6_over_ipv4() { + let store = DeviceStore::new(); + + store.upsert(device("a", "fe80::1%3"), SystemTime::now()); + store.upsert(device("a", "192.168.0.10"), SystemTime::now()); + + let known = store.by_fingerprint("a").unwrap(); + assert_eq!( + channel_host(known.get_best_channel().unwrap()), + "fe80::1%3", + "IPv6 must beat a more recent IPv4 confirmation" + ); + } + + #[test] + fn test_get_channel_prefers_available_channels() { + let store = DeviceStore::new(); + + store.upsert(device("a", "192.168.0.10"), SystemTime::now()); + store.upsert(device("a", "fe80::1%3"), SystemTime::now()); + + let mut known = store.by_fingerprint("a").unwrap(); + for (channel, status) in known.channels.iter_mut() { + if channel_host(channel) == "fe80::1%3" { + *status = ChannelStatus::NotReachable; + } + } + assert_eq!( + channel_host(known.get_best_channel().unwrap()), + "192.168.0.10", + "an available IPv4 channel must beat a not-reachable IPv6 one" + ); + } + + #[test] + fn test_get_channels_sorts_best_first() { + let store = DeviceStore::new(); + + store.upsert(device("a", "192.168.0.10"), SystemTime::now()); + store.upsert(device("a", "10.0.0.10"), SystemTime::now()); + store.upsert(device("a", "fe80::1%3"), SystemTime::now()); + + let mut known = store.by_fingerprint("a").unwrap(); + let hosts: Vec<&str> = known.get_ranked_channels().into_iter().map(channel_host).collect(); + assert_eq!( + hosts, + ["fe80::1%3", "10.0.0.10", "192.168.0.10"], + "IPv6 must come first, then the more recent IPv4 confirmation" + ); + + for (channel, status) in known.channels.iter_mut() { + if channel_host(channel) == "fe80::1%3" { + *status = ChannelStatus::NotReachable; + } + } + let hosts: Vec<&str> = known.get_ranked_channels().into_iter().map(channel_host).collect(); + assert_eq!( + hosts, + ["10.0.0.10", "192.168.0.10", "fe80::1%3"], + "a not-reachable channel must sort last" ); } diff --git a/packages/core/src/model/discovery.rs b/packages/core/src/model/discovery.rs index acf5f0e3..de2e3d25 100644 --- a/packages/core/src/model/discovery.rs +++ b/packages/core/src/model/discovery.rs @@ -14,7 +14,7 @@ pub enum DeviceType { } /// Protocol type for HTTP or HTTPS connections. -#[derive(Clone, Copy, Debug, Deserialize, Eq, Serialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Serialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum ProtocolTypeV2 { Http, diff --git a/packages/core/tests/discovery.rs b/packages/core/tests/discovery.rs index b443e24b..927be411 100644 --- a/packages/core/tests/discovery.rs +++ b/packages/core/tests/discovery.rs @@ -15,7 +15,7 @@ use localsend::discovery::{ use localsend::http::server::{start_with_port, ServerConfigV2, TlsConfig}; use localsend::http::state::ClientInfo; use localsend::model::discovery::{DeviceType, ProtocolTypeV2, PROTOCOL_VERSION_V2}; -use localsend::multicast::MulticastDevice; +use localsend::multicast::{InterfaceFilter, MulticastDevice}; use std::net::{Ipv4Addr, Ipv6Addr}; use std::sync::atomic::{AtomicU16, Ordering}; use std::time::Duration; @@ -161,8 +161,10 @@ async fn start_instance( }, stop_rx, ) - .await - .ok()?; + .await; + if handle.multicast_error().is_some() { + return None; + } Some(TestInstance { fingerprint: cert.fingerprint, @@ -296,8 +298,9 @@ async fn test_subnet_scan_finds_device_on_loopback() { assert_eq!(stored.device.alias, "ScanTarget"); assert!( stored - .device - .http_channels() + .channels + .keys() + .filter_map(|channel| channel.http()) .all(|http| http.host != "127.0.0.99"), "the interface address itself must not be probed" ); @@ -353,6 +356,66 @@ async fn test_targeted_discovery_reads_fingerprint_from_certificate_on_https() { .is_none()); } +#[tokio::test] +async fn test_discovery_works_without_multicast() { + let server_port = free_port(); + let _server_stop = + start_register_server(server_port, "Target", "target-fingerprint", None).await; + + // An interface whitelist matching no interface makes the multicast side + // fail deterministically. + let cert = generate_self_signed().expect("Failed to generate an identity"); + let (stop_tx, stop_rx) = oneshot::channel(); + let handle = discovery::start( + DiscoveryConfig { + group: TEST_GROUP, + group_v6: Some(TEST_GROUP_V6), + port: NEXT_MULTICAST_PORT.fetch_add(1, Ordering::Relaxed), + interface_filter: InterfaceFilter { + whitelist: Some(vec!["203.0.113.1".to_string()]), + blacklist: None, + }, + device: MulticastDevice { + alias: "NoMulticast".to_string(), + version: PROTOCOL_VERSION_V2.to_string(), + device_model: Some("Rust".to_string()), + device_type: Some(DeviceType::Headless), + fingerprint: cert.fingerprint.clone(), + port: free_port(), + protocol: ProtocolTypeV2::Http, + download: false, + }, + identity: DeviceIdentity { + cert_pem: cert.certificate_pem, + private_key_pem: cert.private_key_pem, + }, + timeout: discovery::DEFAULT_DISCOVERY_TIMEOUT, + event_tx: None, + }, + stop_rx, + ) + .await; + + assert!( + handle.multicast_error().is_some(), + "no interface matches the whitelist, so multicast must be unavailable" + ); + + // Announcing is a no-op, the active operations still work. + handle.announce().await; + let device = handle + .discover("127.0.0.1", server_port, ProtocolTypeV2::Http) + .await + .expect("Targeted discovery failed") + .expect("The target must not be mistaken for the device itself"); + assert_eq!(device.device.alias, "Target"); + assert!(handle.device_by_fingerprint("target-fingerprint").is_some()); + + // Stopping must not hang without multicast sockets to close. + drop(stop_tx); + handle.wait_stopped().await; +} + #[tokio::test] async fn test_announcement_is_answered_and_device_stored() { let multicast_port = NEXT_MULTICAST_PORT.fetch_add(1, Ordering::Relaxed); diff --git a/server/Cargo.lock b/server/Cargo.lock index 52be58f7..a1150242 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -25,9 +25,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "autocfg" @@ -42,7 +42,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -62,7 +62,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -98,6 +98,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "bitflags" version = "2.6.0" @@ -113,6 +119,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -127,9 +142,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" @@ -146,6 +161,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.39" @@ -160,6 +186,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -175,6 +207,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "croner" version = "2.1.0" @@ -194,6 +235,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "data-encoding" version = "2.6.0" @@ -206,8 +256,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -248,45 +309,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.95", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", "futures-sink", "futures-task", "pin-project-lite", - "pin-utils", "slab", ] @@ -308,26 +368,26 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "rand_core 0.10.1", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -380,6 +440,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.5.2" @@ -462,20 +531,20 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "localsend" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.23.0", "bytes", "futures-util", "lru", - "rand 0.9.2", + "rand 0.10.2", "serde", "serde_json", "thiserror", @@ -483,7 +552,7 @@ dependencies = [ "tokio-stream", "tracing", "tracing-subscriber", - "tungstenite 0.28.0", + "tungstenite 0.30.0", "uuid", ] @@ -505,9 +574,9 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "lru" -version = "0.16.3" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ "hashbrown", ] @@ -532,13 +601,13 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.0.3" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys", ] [[package]] @@ -547,7 +616,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -558,7 +627,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.95", ] [[package]] @@ -611,12 +680,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "ppv-lite86" version = "0.2.20" @@ -646,9 +709,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -657,18 +720,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -681,16 +745,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -702,12 +756,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "redox_syscall" @@ -738,9 +789,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -748,34 +799,35 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -806,7 +858,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", - "base64", + "base64 0.22.1", "futures-util", "localsend", "serde", @@ -825,8 +877,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.16", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -870,12 +933,12 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -889,6 +952,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -897,22 +971,22 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -927,9 +1001,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -939,7 +1013,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -959,20 +1033,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -1039,7 +1113,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.95", ] [[package]] @@ -1065,9 +1139,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -1090,33 +1164,32 @@ dependencies = [ "httparse", "log", "rand 0.8.5", - "sha1", + "sha1 0.10.6", "thiserror", "utf-8", ] [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.9.2", - "sha1", + "rand 0.10.2", + "sha1 0.11.0", "thiserror", - "utf-8", ] [[package]] name = "typenum" -version = "1.17.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -1132,11 +1205,11 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "uuid" -version = "1.20.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -1160,15 +1233,6 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -1191,7 +1255,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.95", "wasm-bindgen-shared", ] @@ -1213,7 +1277,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.95", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1242,24 +1306,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1333,15 +1379,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] - [[package]] name = "zerocopy" version = "0.7.35" @@ -1360,5 +1397,11 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.95", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"