diff --git a/core/nectarlink-core/src/deck.rs b/core/nectarlink-core/src/deck.rs index 831d0e2c..2e72cecb 100644 --- a/core/nectarlink-core/src/deck.rs +++ b/core/nectarlink-core/src/deck.rs @@ -225,14 +225,54 @@ pub fn is_valid_snippet(text: &str) -> bool { && !text.chars().any(|c| c.is_control() && c != '\n' && c != '\t') } -/// Validates that `path` is a non-empty `.exe` or `.lnk` file path on the PC. +/// Validates that `path` is a non-empty `.exe`, `.lnk`, `.desktop`, or `.appimage` +/// file path, or an absolute (or `~/`) Unix executable path excluding shells, +/// `.sh` scripts, and non-application system/temp directories. pub fn is_valid_app_path(path: &str) -> bool { let trimmed = path.trim(); if trimmed.is_empty() || trimmed.len() > MAX_PATH_BYTES || trimmed.chars().any(char::is_control) { return false; } + if trimmed.split(['/', '\\']).any(|seg| seg == "..") { + return false; + } let lower = trimmed.to_ascii_lowercase(); - (lower.ends_with(".exe") || lower.ends_with(".lnk")) && trimmed.len() > 4 + for ext in [".exe", ".lnk", ".desktop", ".appimage"] { + if let Some(stem) = lower.strip_suffix(ext) { + let file_stem = stem.rsplit(['/', '\\']).next().unwrap_or(stem); + return !file_stem.is_empty(); + } + } + if !trimmed.starts_with('/') && !trimmed.starts_with("~/") { + return false; + } + let Some((parent, name)) = trimmed.rsplit_once('/') else { + return false; + }; + let lower_name = name.to_ascii_lowercase(); + let is_shell = + matches!(lower_name.as_str(), "sh" | "bash" | "dash" | "zsh" | "fish" | "csh" | "tcsh" | "ksh"); + if parent.is_empty() + || parent == "~" + || name.is_empty() + || name == "." + || name.contains('\\') + || lower_name.ends_with(".sh") + || is_shell + { + return false; + } + let parent_segments = parent.strip_prefix("~/").unwrap_or_else(|| &parent[1..]); + if parent_segments.split('/').any(|seg| seg.is_empty() || seg == ".") { + return false; + } + !matches!(parent, "/etc" | "/tmp" | "/var/tmp" | "/dev" | "/proc" | "/sys") + && !parent.starts_with("/etc/") + && !parent.starts_with("/tmp/") + && !parent.starts_with("/var/tmp/") + && !parent.starts_with("/dev/") + && !parent.starts_with("/proc/") + && !parent.starts_with("/sys/") } /// Validates a command string for `RunCommand`. @@ -753,7 +793,36 @@ mod tests { assert!(DeckAction::LaunchApp { path: r"C:\Users\me\Desktop\OBS Studio.LNK".into() }.is_valid()); assert!(!DeckAction::LaunchApp { path: r"C:\scripts\evil.bat".into() }.is_valid()); assert!(!DeckAction::LaunchApp { path: r"C:\scripts\evil.ps1".into() }.is_valid()); + + assert!(DeckAction::LaunchApp { path: "/usr/bin/firefox".into() }.is_valid()); + assert!(DeckAction::LaunchApp { path: "org.mozilla.firefox.desktop".into() }.is_valid()); + assert!(DeckAction::LaunchApp { path: "/opt/app/MyApp.AppImage".into() }.is_valid()); + assert!(DeckAction::LaunchApp { path: "/home/alice/.local/bin/obsidian".into() }.is_valid()); + assert!(DeckAction::LaunchApp { path: "~/.local/bin/firefox".into() }.is_valid()); + assert!(DeckAction::LaunchApp { path: "/run/current-system/sw/bin/alacritty".into() }.is_valid()); + assert!(DeckAction::LaunchApp { path: "/usr/games/steam".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/usr/local/bin/start.sh".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/usr/bin/sh".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/usr/bin/bash".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/home/alice/.local/bin/zsh".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "~/.local/bin/zsh".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/home/alice/.local/bin/deploy.sh".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "~/.local/bin/deploy.sh".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/etc/passwd".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/tmp/payload".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/var/tmp/sub/payload".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/dev/shm/payload".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "./payload".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/payload".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "~/payload".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "~/".into() }.is_valid()); assert!(!DeckAction::LaunchApp { path: ".exe".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: ".desktop".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "../bin/sh".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/usr/../etc/passwd".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "~/.local/../bin/firefox".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "relative/bin/firefox".into() }.is_valid()); + assert!(!DeckAction::LaunchApp { path: "/".into() }.is_valid()); assert!(DeckAction::Shortcut { key: "m".into(), mods: vec![KeyMod::Ctrl, KeyMod::Shift] }.is_valid()); assert!(DeckAction::Shortcut { key: remote_keys::F5.into(), mods: vec![] }.is_valid()); diff --git a/core/nectarlink-core/src/features.rs b/core/nectarlink-core/src/features.rs index f7a9e9ba..76320f26 100644 --- a/core/nectarlink-core/src/features.rs +++ b/core/nectarlink-core/src/features.rs @@ -281,6 +281,15 @@ impl DeviceFacts { DeviceFacts { kind: info.kind, os: info.os.clone(), os_ver: info.os_ver.clone(), caps, power } } + /// Creates baseline [`DeviceFacts`] for a desktop or phone OS. + pub fn for_os(os: &str) -> Self { + let (kind, power) = match os { + "android" => (DeviceKind::Phone, PowerLevel::Basic), + _ => (DeviceKind::Desktop, PowerLevel::NotApplicable), + }; + DeviceFacts { kind, os: os.to_owned(), os_ver: String::new(), caps: BTreeSet::new(), power } + } + fn role(&self) -> Option { role_from(self.kind, &self.os) } @@ -308,7 +317,7 @@ fn role_from(kind: DeviceKind, os: &str) -> Option { DeviceKind::Desktop | DeviceKind::Laptop => Some(Role::Desktop), DeviceKind::Unknown => match os { "android" => Some(Role::Phone), - "windows" | "macos" => Some(Role::Desktop), + "windows" | "macos" | "linux" => Some(Role::Desktop), _ => None, }, } @@ -1318,6 +1327,28 @@ mod tests { fn unknown_kinds_fall_back_to_the_os() { let phone = facts(DeviceKind::Unknown, "android", "16", PowerLevel::Basic, BASIC_PHONE); assert_eq!(matrix(&phone).state("device.find_phone"), Some(FeatureState::Available)); + + let linux_unknown = facts(DeviceKind::Unknown, "linux", "6.12", PowerLevel::NotApplicable, PC); + assert_eq!(linux_unknown.role(), Some(Role::Desktop)); + assert_eq!( + matrix_with(&phone, &linux_unknown, &HashMap::new()).state("device.find_phone"), + Some(FeatureState::Available) + ); + } + + #[test] + fn linux_desktop_maps_to_desktop_role_and_matches_windows_feature_matrix() { + let mut linux_pc = DeviceFacts::for_os("linux"); + assert_eq!(linux_pc.kind, DeviceKind::Desktop); + assert_eq!(linux_pc.role(), Some(Role::Desktop)); + linux_pc.os_ver = "6.12".into(); + linux_pc.caps = PC.iter().map(|c| (*c).to_owned()).collect(); + + let phone = facts(DeviceKind::Phone, "android", "16", PowerLevel::Basic, BASIC_PHONE); + let toggles = HashMap::new(); + let m_linux = matrix_with(&phone, &linux_pc, &toggles); + let m_win = matrix_with(&phone, &pc(), &toggles); + assert_eq!(m_linux, m_win); } #[test] diff --git a/core/nectarlink-core/src/handoff.rs b/core/nectarlink-core/src/handoff.rs index 0e170a34..a91d2a88 100644 --- a/core/nectarlink-core/src/handoff.rs +++ b/core/nectarlink-core/src/handoff.rs @@ -275,7 +275,7 @@ fn find_url_or_geo_token(text: &str) -> Option { return None; } // Check if the whole string is a geo: URI (possibly with spaces in ?q= that we encode). - if trimmed.len() > 4 && trimmed[..4].eq_ignore_ascii_case("geo:") { + if trimmed.len() > 4 && trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("geo:")) { let first_line = trimmed.lines().next().unwrap_or(trimmed).trim(); let normalized: String = first_line.chars().filter(|c| !c.is_control()).map(|c| if c == ' ' { '+' } else { c }).collect(); @@ -440,8 +440,9 @@ fn extract_http_map_query(url: &str) -> Option { let after_scheme = before_frag.split("://").nth(1)?; let path = after_scheme.find('/').map(|i| &after_scheme[i..])?; for marker in ["/maps/place/", "/maps/search/"] { - if let Some(idx) = path.to_ascii_lowercase().find(marker) { - let rest = &path[idx + marker.len()..]; + if let Some(idx) = path.to_ascii_lowercase().find(marker) + && let Some(rest) = path.get(idx + marker.len()..) + { let seg = rest.split('/').next().unwrap_or(""); if !seg.is_empty() && !seg.starts_with('@') { let decoded = clean_map_label(&percent_decode(seg)); @@ -610,6 +611,15 @@ mod tests { assert_eq!(apple.kind, HandoffKind::MapLocation); } + #[test] + fn handles_multibyte_utf8_at_char_boundaries_without_panicking() { + // '—' is 3 bytes at bytes 3..6 when preceded by 3 ASCII bytes ("abc—def"). + assert!(extract_handoff_link("abc—def").is_none()); + assert!(extract_handoff_link("🚀✨🔥").is_none()); + let link = extract_handoff_link("abc— https://nectarlink.app/docs").unwrap(); + assert_eq!(link.url, "https://nectarlink.app/docs"); + } + #[test] fn extracts_and_updates_video_timestamps() { let yt = diff --git a/core/nectarlink-core/src/identity.rs b/core/nectarlink-core/src/identity.rs index a2ef195a..4957a011 100644 --- a/core/nectarlink-core/src/identity.rs +++ b/core/nectarlink-core/src/identity.rs @@ -103,6 +103,11 @@ fn write_atomically(path: &Path, data: &[u8]) -> io::Result<()> { let tmp = path.with_extension("tmp"); { let mut file = fs::File::create(&tmp)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } file.write_all(data)?; file.sync_all()?; } @@ -262,4 +267,16 @@ mod tests { assert!(!a.contains("9774d56d682e549c"), "the raw ID never appears"); assert_eq!(install_id_from(" "), None); } + + #[cfg(unix)] + #[test] + fn key_file_has_0600_permissions_on_unix() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let protector = default_protector(); + load_or_create(dir.path(), protector.as_ref()).unwrap(); + let meta = fs::metadata(dir.path().join(FILE_NAME)).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + } } diff --git a/core/nectarlink-core/src/node.rs b/core/nectarlink-core/src/node.rs index 961fcd1d..c47add4b 100644 --- a/core/nectarlink-core/src/node.rs +++ b/core/nectarlink-core/src/node.rs @@ -48,7 +48,10 @@ const DEFER_DIAL: Duration = Duration::from_millis(750); /// How long a phone plugged into a PC waits for the PC to dial through the cable before /// dialing itself (see [`Shared::usb_tunnel_inbound`]). const USB_DIAL_GRACE: Duration = Duration::from_secs(6); -const EVENT_CAPACITY: usize = 512; +/// Broadcast channel capacity for [`NodeEvent`]s: large enough to absorb initial +/// notification snapshots and multi-peer capability/state bursts without lagging +/// UI or FFI subscribers. +const EVENT_CAPACITY: usize = 2048; /// A connection with no traffic for this long is dead. iroh sends a /// keep-alive every 1 s, so a healthy link is never idle that long, and a /// device that drops off the network shows as offline within ~2-4 seconds @@ -555,7 +558,10 @@ impl Shared { /// still the peer's session (a replaced one has nothing to say). pub fn publish_online(&self, session: &Session) { let sessions = lock(&self.sessions); - if sessions.get(&session.peer).is_some_and(|current| std::ptr::eq(current.as_ref(), session)) { + if sessions + .get(&session.peer) + .is_some_and(|current| std::ptr::eq(current.as_ref(), session) && current.is_alive()) + { self.set_link(session.peer, LinkState::Online { path: session.path(), rtt_ms: session.rtt_ms() }); } } @@ -663,9 +669,10 @@ impl Shared { if self.usb_peer_addr(&peer).is_some() { return true; } - conn.paths() - .iter() - .any(|p| matches!(p.remote_addr(), iroh::TransportAddr::Ip(addr) if self.is_usb_addr(addr))) + conn.paths().iter().any(|p| match p.remote_addr() { + iroh::TransportAddr::Ip(addr) => self.is_usb_addr(addr), + _ => false, + }) } pub(crate) fn note_discovered(&self, peer: DeviceId, name: Option) { @@ -680,7 +687,7 @@ impl Shared { } pub(crate) fn on_usb_tunnel_accepted( - &self, + self: &Arc, peer: DeviceId, name: String, bridge_addr: SocketAddr, @@ -695,6 +702,8 @@ impl Shared { self.note_discovered(peer, (!name.is_empty()).then_some(name)); self.remember_addrs(&peer, &[bridge_addr]); if self.store.is_paired(&peer).unwrap_or(false) { + self.start_supervisor(peer); + self.wake_supervisor(&peer); if let Some(session) = self.session(&peer) { self.publish_online(&session); } @@ -733,7 +742,7 @@ impl Shared { s.publish_online(&session); s.refresh_capabilities(&peer); } else { - let _ = s.dial(peer).await; + s.wake_supervisor(&peer); } }); } else if auto_pair { @@ -821,20 +830,22 @@ impl Shared { } } - /// Removes any cached non-LAN (cellular / public IPv6 / WAN) addresses for `peer` - /// so `maintain` does not repeatedly dial a mobile-data endpoint that rejects connections. + /// Removes any cached non-LAN (cellular / public IPv6 / WAN) or stale loopback + /// addresses for `peer` so `maintain` does not repeatedly dial a dead endpoint. pub(crate) fn purge_non_lan_addrs(&self, peer: &DeviceId) { if let Ok(Some(record)) = self.store.get_peer(peer) { - let lan_only: Vec = - record.last_addrs.iter().copied().filter(|a| crate::session::is_lan_ip(a.ip())).collect(); + let lan_only: Vec = record + .last_addrs + .iter() + .copied() + .filter(|a| !a.ip().to_canonical().is_loopback() && crate::session::is_lan_ip(a.ip())) + .collect(); if lan_only.len() != record.last_addrs.len() && let Some(seen) = record.last_seen { let _ = self.store.record_seen(peer, seen, &lan_only); } - if let Ok(key) = crate::public_key(peer) { - self.memory.add_endpoint_info(EndpointInfo::new(key).with_ip_addrs(lan_only)); - } + self.remember_addrs(peer, &lan_only); } } @@ -1005,6 +1016,9 @@ impl Shared { remote: nectarlink_protocol::messages::Hello, ) -> Option> { let peer = crate::device_id(&conn.remote_id()); + if conn.close_reason().is_some() { + return None; + } if !self.is_connection_allowed(&conn) { tracing::debug!(peer = %peer.short(), "refusing session on non-LAN path while away_mode is off"); conn.close(VarInt::from_u32(CLOSE_NORMAL), b"away mode disabled"); @@ -1047,12 +1061,32 @@ impl Shared { let addrs: Vec = session .remote_ip_addrs() .into_iter() - .filter(|a| allow_wan || crate::session::is_lan_ip(a.ip())) + .filter(|a| { + !a.ip().to_canonical().is_loopback() && (allow_wan || crate::session::is_lan_ip(a.ip())) + }) .collect(); - if let Err(e) = self.store.record_seen(&peer, crate::now_unix(), &addrs) { + let persisted_addrs = if addrs.is_empty() { + self.store + .get_peer(&peer) + .ok() + .flatten() + .map(|r| { + r.last_addrs + .into_iter() + .filter(|a| { + !a.ip().to_canonical().is_loopback() + && (allow_wan || crate::session::is_lan_ip(a.ip())) + }) + .collect() + }) + .unwrap_or_default() + } else { + addrs + }; + if let Err(e) = self.store.record_seen(&peer, crate::now_unix(), &persisted_addrs) { tracing::warn!(error = %e, "failed to record peer addresses"); } - self.remember_addrs(&peer, &addrs); + self.remember_addrs(&peer, &persisted_addrs); self.emit(NodeEvent::PeerInfoChanged { device: peer, info: effective_device }); self.emit(NodeEvent::PeerPowerChanged { device: peer, power: remote.power.effective() }); self.refresh_capabilities(&peer); @@ -1125,6 +1159,15 @@ impl Shared { /// Dials a paired device and runs the handshake. async fn dial(self: &Arc, peer: DeviceId) -> Result> { + struct CloseOnDrop(Option); + impl Drop for CloseOnDrop { + fn drop(&mut self) { + if let Some(conn) = self.0.take() { + conn.close(VarInt::from_u32(CLOSE_NORMAL), b"dial cancelled"); + } + } + } + let mut addr = EndpointAddr::new(crate::public_key(&peer)?); if let Some(usb_addr) = self.usb_peer_addr(&peer) { addr = addr.with_ip_addr(usb_addr); @@ -1133,13 +1176,18 @@ impl Shared { .await .map_err(|_| Error::Timeout)? .map_err(crate::error::net)?; - if !self.wait_connection_allowed(&conn).await { + let mut guard = CloseOnDrop(Some(conn)); + let conn_ref = guard.0.as_ref().expect("connection present"); + if !self.wait_connection_allowed(conn_ref).await { tracing::debug!(peer = %peer.short(), "refusing outgoing session on non-LAN path while away_mode is off"); - conn.close(VarInt::from_u32(CLOSE_NORMAL), b"away mode disabled"); + if let Some(c) = guard.0.take() { + c.close(VarInt::from_u32(CLOSE_NORMAL), b"away mode disabled"); + } return Err(Error::Offline); } let hello = session::local_hello(self); - let (send, recv, remote) = session::handshake_dialer(&conn, &hello).await?; + let (send, recv, remote) = session::handshake_dialer(conn_ref, &hello).await?; + let conn = guard.0.take().expect("connection present"); self.register_session(conn, self.id, (send, recv), remote) .or_else(|| self.session(&peer)) .ok_or(Error::Offline) @@ -1155,6 +1203,30 @@ impl Shared { } } +/// Dials `peer`, interrupting an in-flight non-USB dial if a USB tunnel comes up while waiting. +async fn dial_with_usb_interrupt( + node: &Arc, + peer: DeviceId, + wake: &Notify, +) -> Option>> { + let had_usb = node.usb_peer_addr(&peer).is_some(); + if had_usb { + return Some(node.dial(peer).await); + } + let dial_fut = node.dial(peer); + tokio::pin!(dial_fut); + loop { + tokio::select! { + res = &mut dial_fut => return Some(res), + _ = wake.notified() => { + if node.usb_peer_addr(&peer).is_some() && node.session(&peer).is_none() { + return None; + } + } + } + } +} + /// Keeps one paired device connected: waits while a session is alive, /// otherwise dials with exponential backoff. Woken early by LAN discovery. async fn maintain(shared: Weak, peer: DeviceId, wake: Arc, cancel: CancellationToken) { @@ -1212,7 +1284,8 @@ async fn maintain(shared: Weak, peer: DeviceId, wake: Arc, cance // When both devices come up together they'd dial each other at once and // one connection would be dropped. The higher ID briefly defers so the // lower one usually wins; it still dials in case the other can't reach it. - if node.id > peer && backoff == BACKOFF_MIN { + // Skip the deferral if an outbound USB tunnel is already ready. + if node.id > peer && backoff == BACKOFF_MIN && node.usb_peer_addr(&peer).is_none() { drop(node); tokio::select! { _ = tokio::time::sleep(DEFER_DIAL) => {} @@ -1222,14 +1295,18 @@ async fn maintain(shared: Weak, peer: DeviceId, wake: Arc, cance if again.session(&peer).is_some() { continue; } - let result = again.dial(peer).await; + let Some(result) = dial_with_usb_interrupt(&again, peer, &wake).await else { + continue; + }; let ok = handle_dial_result(&again, result, peer, &mut backoff); drop(again); if ok { continue; } } else { - let result = node.dial(peer).await; + let Some(result) = dial_with_usb_interrupt(&node, peer, &wake).await else { + continue; + }; let ok = handle_dial_result(&node, result, peer, &mut backoff); drop(node); if ok { @@ -1458,8 +1535,12 @@ impl Node { for peer in shared.store.list_peers()? { if !config.away_mode { - let lan_only: Vec = - peer.last_addrs.iter().copied().filter(|a| crate::session::is_lan_ip(a.ip())).collect(); + let lan_only: Vec = peer + .last_addrs + .iter() + .copied() + .filter(|a| !a.ip().to_canonical().is_loopback() && crate::session::is_lan_ip(a.ip())) + .collect(); if lan_only.len() != peer.last_addrs.len() && let Some(seen) = peer.last_seen { @@ -1497,16 +1578,14 @@ impl Node { .last_addrs .iter() .copied() - .filter(|a| crate::session::is_lan_ip(a.ip())) + .filter(|a| !a.ip().to_canonical().is_loopback() && crate::session::is_lan_ip(a.ip())) .collect(); if lan_only.len() != peer.last_addrs.len() && let Some(seen) = peer.last_seen { let _ = self.shared.store.record_seen(&peer.id, seen, &lan_only); } - if let Ok(key) = crate::public_key(&peer.id) { - self.shared.memory.add_endpoint_info(EndpointInfo::new(key).with_ip_addrs(lan_only)); - } + self.shared.remember_addrs(&peer.id, &lan_only); } } } else { @@ -2385,7 +2464,7 @@ impl Node { types::AUDIO_PCM, &nectarlink_protocol::messages::AudioPcmFrame { sample_rate, channels, pcm }, )?; - session.send(env).await + session.try_send_realtime(env) } /// Opens a web link on a paired device. diff --git a/core/nectarlink-core/src/session.rs b/core/nectarlink-core/src/session.rs index 33b368c9..3d1e0d00 100644 --- a/core/nectarlink-core/src/session.rs +++ b/core/nectarlink-core/src/session.rs @@ -300,20 +300,36 @@ impl Session { self.outbox.send(env).await.map_err(|_| Error::Offline) } + /// Best-effort non-blocking enqueue for loss-tolerant real-time media frames + /// (such as live PCM audio fallback). + /// + /// Returns `Ok(())` even when a frame is dropped because the outbox is past half + /// capacity or full, so real-time media never fills the control stream outbox and + /// starves heartbeats or RPCs. Do not use this for messages that must arrive. + pub fn try_send_realtime(&self, env: Envelope) -> Result<()> { + if self.outbox.capacity() <= OUTBOX_CAPACITY / 2 { + return Ok(()); + } + match self.outbox.try_send(env) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => Ok(()), + Err(mpsc::error::TrySendError::Closed(_)) => Err(Error::Offline), + } + } + /// Sends a request and waits for the reply with the matching `re`. pub async fn request(&self, env: Envelope, timeout: Duration) -> Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (tx, rx) = oneshot::channel(); self.pending.lock().unwrap_or_else(|e| e.into_inner()).insert(id, tx); - let result = async { + let result = match tokio::time::timeout(timeout, async { self.send(env.with_id(id)).await?; - match tokio::time::timeout(timeout, rx).await { - Ok(Ok(reply)) => Ok(reply), - Ok(Err(_)) => Err(Error::Offline), - Err(_) => Err(Error::Timeout), - } - } - .await; + rx.await.map_err(|_| Error::Offline) + }) + .await + { + Ok(inner) => inner, + Err(_) => Err(Error::Timeout), + }; self.pending.lock().unwrap_or_else(|e| e.into_inner()).remove(&id); result } diff --git a/core/nectarlink-core/tests/two_devices.rs b/core/nectarlink-core/tests/two_devices.rs index 01a410d1..bea996e1 100644 --- a/core/nectarlink-core/tests/two_devices.rs +++ b/core/nectarlink-core/tests/two_devices.rs @@ -6081,3 +6081,88 @@ async fn a_phone_keeps_its_notifications_from_a_phone_that_doesnt_show_them() { other.node.shutdown().await; phone.node.shutdown().await; } + +#[tokio::test] +async fn usb_upgrade_reconnect_and_realtime_pcm_do_not_starve_control_rpcs() { + use nectarlink_core::ConnectionPath; + + let mut phone = start_usb_device("Pixel", DeviceKind::Phone, Some(0), &["find.ring"]).await; + let mut pc = start_usb_device("Desktop", DeviceKind::Desktop, None, &["find.ring"]).await; + pair_qr(&mut pc, &mut phone).await; + let (pc_id, phone_id) = (pc.node.device_id(), phone.node.device_id()); + assert!(matches!( + pc.node.paired_devices().unwrap()[0].link, + LinkState::Online { path: ConnectionPath::Lan, .. } + )); + + // Attach USB tunnel while a LAN session is already live: both sides upgrade to ConnectionPath::Usb. + let usb_port = phone.node.usb_port().expect("phone USB TCP listener"); + let tunnel_addr: SocketAddr = ("127.0.0.1".parse::().unwrap(), usb_port).into(); + pc.node.connect_usb_tunnel(tunnel_addr).await.expect("connect USB tunnel"); + + wait_for(&mut pc, "pc upgrades to usb", |e| match e { + NodeEvent::LinkChanged { device, link: LinkState::Online { path: ConnectionPath::Usb, .. } } + if *device == phone_id => + { + Some(()) + } + _ => None, + }) + .await; + wait_for(&mut phone, "phone upgrades to usb", |e| match e { + NodeEvent::LinkChanged { device, link: LinkState::Online { path: ConnectionPath::Usb, .. } } + if *device == pc_id => + { + Some(()) + } + _ => None, + }) + .await; + + // Enqueue a burst of 512 >960-byte PCM frames (2x OUTBOX_CAPACITY, going through + // try_send_realtime) before the writer task runs and verify that excess frames past + // half outbox capacity are dropped while control RPCs (ring) still complete promptly. + const BURST_FRAMES: usize = 512; + let payload = vec![0u8; 1_000]; + for _ in 0..BURST_FRAMES { + pc.node + .send_pc_audio_pcm(&phone_id, 48_000, 2, payload.clone()) + .await + .expect("realtime PCM enqueue returns Ok even when dropping frames"); + } + with_timeout("ring during realtime pcm burst", pc.node.ring(phone_id, true)) + .await + .expect("control RPC succeeds during realtime burst"); + assert_eq!(*phone.platform.rings.lock().unwrap(), [true]); + + let mut received_pcm = 0usize; + while let Ok(ev) = phone.events.try_recv() { + if matches!(ev, NodeEvent::PcAudioFrame { .. }) { + received_pcm += 1; + } + } + assert!( + (1..=128).contains(&received_pcm), + "expected excess realtime frames to be dropped past half outbox capacity (128), got {received_pcm}" + ); + + // Shut down the phone (which closes the USB TCP tunnel) and restart it over LAN. + phone.node.shutdown().await; + wait_for(&mut pc, "phone offline before restart", |e| match e { + NodeEvent::LinkChanged { device, link: LinkState::Offline { .. } } if *device == phone_id => Some(()), + _ => None, + }) + .await; + + let TestDevice { node: old_phone, dir, .. } = phone; + drop(old_phone); + let mut phone = start_in(dir, "Pixel", DeviceKind::Phone).await; + wait_online(&mut phone, pc_id).await; + wait_online(&mut pc, phone_id).await; + with_timeout("ring after restart", pc.node.ring(phone_id, false)) + .await + .expect("ring works after restart"); + + pc.node.shutdown().await; + phone.node.shutdown().await; +} diff --git a/core/nectarlink-ffi/src/lib.rs b/core/nectarlink-ffi/src/lib.rs index 453189e6..d5d9f863 100644 --- a/core/nectarlink-ffi/src/lib.rs +++ b/core/nectarlink-ffi/src/lib.rs @@ -3241,6 +3241,7 @@ impl NectarlinkNode { runtime.block_on(Node::start(config, Arc::new(PlatformAdapter(platform, listener.clone()))))?; let mut events = node.events(); + let node_for_events = node.clone(); runtime.spawn(async move { loop { match events.recv().await { @@ -3249,7 +3250,32 @@ impl NectarlinkNode { listener.on_event(ev); } } - Err(RecvError::Lagged(missed)) => tracing::warn!(missed, "event listener fell behind"), + Err(RecvError::Lagged(missed)) => { + tracing::warn!(missed, "event listener fell behind; resynchronizing state"); + if let Ok(devices) = node_for_events.paired_devices() { + for d in devices { + listener.on_event(Event::LinkChanged { + id: d.id.to_string(), + link: d.link.into(), + }); + listener.on_event(Event::WakeInfoChanged { + id: d.id.to_string(), + can_wake: d.can_wake, + }); + listener.on_event(Event::PeerInfoChanged { + id: d.id.to_string(), + info: d.info.into(), + }); + if let Ok(matrix) = node_for_events.capabilities(d.id) { + listener.on_event(Event::Capabilities { + id: d.id.to_string(), + features: features(&matrix), + }); + } + } + } + listener.on_event(Event::QueueChanged); + } Err(RecvError::Closed) => return, } } diff --git a/desktop/app/qml/MainWindow.qml b/desktop/app/qml/MainWindow.qml index 4ef20650..5b3d4c97 100644 --- a/desktop/app/qml/MainWindow.qml +++ b/desktop/app/qml/MainWindow.qml @@ -127,7 +127,7 @@ NativeWindow { // window turns translucent only once the backdrop is in place, and opaque again // before it is removed. // Hive keeps its own fixed colors, so it never shows the wallpaper through. - readonly property bool wantBackdrop: (Preferences.backdrop || Theme.glass) && !Theme.amoled && !Theme.hive + readonly property bool wantBackdrop: Qt.platform.os === "windows" && (Preferences.backdrop || Theme.glass) && !Theme.amoled && !Theme.hive property bool micaApplied: false property bool micaVisible: false backdrop: micaApplied diff --git a/desktop/app/qml/components/CaptionButtons.qml b/desktop/app/qml/components/CaptionButtons.qml index 78f8a7ff..9df80ed8 100644 --- a/desktop/app/qml/components/CaptionButtons.qml +++ b/desktop/app/qml/components/CaptionButtons.qml @@ -1,10 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later import QtQuick +import QtQuick.Shapes import app.nectarlink -// Minimize, maximize/restore and close, drawn like Windows 11's own (same -// size and glyphs) in the app's colors. The maximize button is registered -// with the window so Windows shows Snap Layouts when hovering it. +// Minimize, maximize/restore and close, drawn like Windows 11's own on Windows +// (Segoe Fluent Icons) and with crisp vector strokes on Linux where that font +// isn't installed. The maximize button is registered with the window so Windows +// shows Snap Layouts when hovering it. Row { id: buttons required property NativeWindow window @@ -17,25 +19,30 @@ Row { CaptionButton { id: minimize - glyph: "" + glyph: "\ue921" + iconPath: "M 1 5 L 9 5" label: qsTr("Minimize") onActivated: buttons.window.showMinimized() Component.onCompleted: buttons.window.addCaptionHole(minimize) } CaptionButton { id: maximize - glyph: buttons.maximized ? "" : "" + glyph: buttons.maximized ? "\ue923" : "\ue922" + iconPath: buttons.maximized + ? "M 1 3 L 7 3 L 7 9 L 1 9 Z M 3 3 L 3 1 L 9 1 L 9 7 L 7 7" + : "M 1 1 L 9 1 L 9 9 L 1 9 Z" label: buttons.maximized ? qsTr("Restore") : qsTr("Maximize") - // Mouse input for this button goes through Windows (Snap Layouts). - nativeInput: true - hovered: buttons.window.maximizeHovered - pressed: buttons.window.maximizePressed + // Mouse input for this button goes through Windows (Snap Layouts) on Windows. + nativeInput: Qt.platform.os === "windows" + nativeHovered: buttons.window.maximizeHovered + nativePressed: buttons.window.maximizePressed onActivated: buttons.window.toggleMaximized() Component.onCompleted: buttons.window.maximizeButton = maximize } CaptionButton { id: close - glyph: "" + glyph: "\ue8bb" + iconPath: "M 1 1 L 9 9 M 9 1 L 1 9" label: qsTr("Close") danger: true onActivated: buttons.window.close() @@ -45,11 +52,14 @@ Row { component CaptionButton: Item { id: button property string glyph + property string iconPath property string label property bool danger: false property bool nativeInput: false - property bool hovered: !nativeInput && hover.hovered - property bool pressed: !nativeInput && tap.pressed + property bool nativeHovered: false + property bool nativePressed: false + readonly property bool hovered: nativeInput ? nativeHovered : hover.hovered + readonly property bool pressed: nativeInput ? nativePressed : tap.pressed signal activated width: 46 @@ -79,12 +89,29 @@ Row { } Text { anchors.centerIn: parent + visible: Qt.platform.os === "windows" text: button.glyph font.family: "Segoe Fluent Icons" font.pixelSize: 10 color: button.danger && (button.hovered || button.pressed) ? "#FFFFFF" : Theme.surfaceContent opacity: buttons.window.active || button.hovered ? 1 : 0.45 } + Shape { + anchors.centerIn: parent + visible: Qt.platform.os !== "windows" + width: 10 + height: 10 + opacity: buttons.window.active || button.hovered ? 1 : 0.45 + preferredRendererType: Shape.CurveRenderer + ShapePath { + strokeColor: button.danger && (button.hovered || button.pressed) ? "#FFFFFF" : Theme.surfaceContent + strokeWidth: 1.25 + fillColor: "transparent" + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + PathSvg { path: button.iconPath } + } + } Rectangle { anchors.fill: parent anchors.margins: 2 diff --git a/desktop/app/qml/pages/DeckPage.qml b/desktop/app/qml/pages/DeckPage.qml index 63a56667..2757d297 100644 --- a/desktop/app/qml/pages/DeckPage.qml +++ b/desktop/app/qml/pages/DeckPage.qml @@ -105,7 +105,7 @@ Item { { id: "shortcut", group: qsTr("Custom"), label: qsTr("Keyboard shortcut") }, { id: "open_url", group: qsTr("Custom"), label: qsTr("Open website") }, { id: "type_text", group: qsTr("Custom"), label: qsTr("Type text snippet") }, - { id: "launch_app", group: qsTr("Apps"), label: qsTr("Launch application (.exe / .lnk)") }, + { id: "launch_app", group: qsTr("Apps"), label: Qt.platform.os === "linux" ? qsTr("Launch application (.desktop / binary)") : qsTr("Launch application (.exe / .lnk)") }, { id: "run_command", group: qsTr("Apps"), label: qsTr("Run shell command") } ] @@ -144,7 +144,9 @@ Item { FileDialog { id: appFilePicker title: qsTr("Choose an application or shortcut") - nameFilters: [qsTr("Applications and shortcuts (*.exe *.lnk)")] + nameFilters: Qt.platform.os === "linux" + ? [qsTr("Applications and launchers (*.desktop *.AppImage *)"), qsTr("All files (*)")] + : [qsTr("Applications and shortcuts (*.exe *.lnk)")] onAccepted: { const localPath = DeckController.urlToLocalPath(selectedFile.toString()) if (localPath.length > 0) { @@ -152,7 +154,7 @@ Item { if (!editorSheet.labelCustomized || editorSheet.tileLabel.length === 0 || editorSheet.tileLabel === qsTr("Launch app")) { const parts = localPath.split(/[\\/]/) const file = parts[parts.length - 1] || "" - const stem = file.replace(/\.(exe|lnk)$/i, "") + const stem = file.replace(/\.(exe|lnk|desktop|appimage)$/i, "") if (stem.length > 0) editorSheet.tileLabel = stem } @@ -755,7 +757,7 @@ Item { text: editorSheet.actionKind === "shortcut" ? qsTr("Key (e.g. c, v, z, f5, tab, enter, space, escape, up, down):") : editorSheet.actionKind === "open_url" ? qsTr("Website address (https://…):") : editorSheet.actionKind === "type_text" ? qsTr("Text snippet to type:") - : editorSheet.actionKind === "launch_app" ? qsTr("Application or shortcut path (.exe or .lnk):") + : editorSheet.actionKind === "launch_app" ? (Qt.platform.os === "linux" ? qsTr("Application or launcher path (/usr/bin/…, .desktop, or .AppImage):") : qsTr("Application or shortcut path (.exe or .lnk):")) : qsTr("Shell command to run on this PC:") } diff --git a/desktop/app/qml/pages/WalkthroughPage.qml b/desktop/app/qml/pages/WalkthroughPage.qml index 81e29cde..dadc123f 100644 --- a/desktop/app/qml/pages/WalkthroughPage.qml +++ b/desktop/app/qml/pages/WalkthroughPage.qml @@ -110,7 +110,7 @@ FocusScope { "Get the Android app from GitHub Releases or install the APK over USB.", "Scan the QR code from the NectarLink Android app, or pick your phone from nearby devices.", "Safe features are on by default. Higher-trust access stays off until you turn it on.", - "Three quick checks so notifications and discovery work reliably in Windows.", + "Three quick checks so notifications and discovery work reliably on your PC.", "Reopen it from Settings or the Command Palette." ] diff --git a/desktop/app/qml/setup/SetupWindow.qml b/desktop/app/qml/setup/SetupWindow.qml index 39bb6884..d7f84bed 100644 --- a/desktop/app/qml/setup/SetupWindow.qml +++ b/desktop/app/qml/setup/SetupWindow.qml @@ -1,10 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later import QtQuick +import QtQuick.Shapes import app.nectarlink // The installer's and the uninstaller's window (src/setup.rs). One window, // three moments: welcome, working, done. Its own fixed colors (honey on warm -// paper, or on charcoal), following Windows' light or dark mode. +// paper, or on charcoal), following the system's light or dark mode. NativeWindow { id: window @@ -112,11 +113,27 @@ NativeWindow { } Text { anchors.centerIn: parent - text: "" + visible: Qt.platform.os === "windows" + text: "\ue8bb" font.family: "Segoe Fluent Icons" font.pixelSize: 10 color: closeArea.containsMouse ? "#FFFFFF" : window.ink } + Shape { + anchors.centerIn: parent + visible: Qt.platform.os !== "windows" + width: 10 + height: 10 + preferredRendererType: Shape.CurveRenderer + ShapePath { + strokeColor: closeArea.containsMouse ? "#FFFFFF" : window.ink + strokeWidth: 1.25 + fillColor: "transparent" + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + PathSvg { path: "M 1 1 L 9 9 M 9 1 L 1 9" } + } + } MouseArea { id: closeArea anchors.fill: parent diff --git a/desktop/app/src/deck.rs b/desktop/app/src/deck.rs index dee958db..a969cafb 100644 --- a/desktop/app/src/deck.rs +++ b/desktop/app/src/deck.rs @@ -333,7 +333,14 @@ fn execute_action(action: &DeckAction) -> Result<(), String> { win::input::type_text(text); Ok(()) } - DeckAction::LaunchApp { path } => win::shell::launch_app(Path::new(path)), + DeckAction::LaunchApp { path } => { + let expanded = path + .trim() + .strip_prefix("~/") + .and_then(|rest| dirs::home_dir().map(|home| home.join(rest))) + .unwrap_or_else(|| PathBuf::from(path.trim())); + win::shell::launch_app(&expanded) + } DeckAction::RunCommand { command } => { if let Some(obs_cmd) = command.trim().strip_prefix("obs:") { send_obs_command(obs_cmd.trim()) diff --git a/desktop/app/src/icons.rs b/desktop/app/src/icons.rs index ca536cd3..8ec37994 100644 --- a/desktop/app/src/icons.rs +++ b/desktop/app/src/icons.rs @@ -43,7 +43,12 @@ pub fn save(data_dir: &Path, app: &str, png: &[u8]) -> std::io::Result /// A `file:///` URL for QML and toasts. pub fn file_url(path: &Path) -> String { - format!("file:///{}", path.to_string_lossy().replace('\\', "/")) + let s = path.to_string_lossy().replace('\\', "/"); + if let Some(unc) = s.strip_prefix("//") { + format!("file://{}", unc.trim_start_matches('/')) + } else { + format!("file:///{}", s.trim_start_matches('/')) + } } /// Returns the cached PNG icon path for `app` if it exists on disk. @@ -69,5 +74,7 @@ mod tests { assert_eq!(file_name("../../evil\\x"), "_.._evil_x.png"); assert!(save(dir.path(), "com.bad", b"GIF89a").is_err(), "only PNG"); assert!(file_url(Path::new(r"C:\a\b.png")).starts_with("file:///C:/a/b.png")); + assert_eq!(file_url(Path::new(r"\\server\share\a.png")), "file://server/share/a.png"); + assert_eq!(file_url(Path::new("/home/user/a.png")), "file:///home/user/a.png"); } } diff --git a/desktop/app/src/local_api.rs b/desktop/app/src/local_api.rs index 99365dc2..ea4b2862 100644 --- a/desktop/app/src/local_api.rs +++ b/desktop/app/src/local_api.rs @@ -103,19 +103,7 @@ pub fn token_file_path() -> PathBuf { /// guessable token. pub fn generate_token() -> Option { let mut bytes = [0u8; 32]; - let filled = windows::Security::Cryptography::CryptographicBuffer::GenerateRandom(32) - .and_then(|buf| { - let mut arr = windows::core::Array::::new(); - windows::Security::Cryptography::CryptographicBuffer::CopyToByteArray(&buf, &mut arr)?; - if arr.len() == 32 { - bytes.copy_from_slice(&arr); - Ok(()) - } else { - Err(windows::core::Error::empty()) - } - }) - .is_ok(); - (filled || crate::win::secure_random(&mut bytes)).then(|| HEXLOWER.encode(&bytes)) + crate::win::secure_random(&mut bytes).then(|| HEXLOWER.encode(&bytes)) } fn is_valid_token_hex(s: &str) -> bool {