From a76188b5403d09cf648d6bdbefe66de683ab7e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Kirst=C3=A4tter?= <7329802+jochenkirstaetter@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:21:29 +0400 Subject: [PATCH 1/5] feat(linux): clipboard, notifications, tray, autostart, shell, shortcuts, network & BLE (Part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jochen Kirstätter <7329802+jochenkirstaetter@users.noreply.github.com> --- desktop/app/src/core_host.rs | 3 +- desktop/app/src/linux/autostart.rs | 133 ++- desktop/app/src/linux/ble_beacon.rs | 386 ++++++- desktop/app/src/linux/bt_hfp.rs | 8 +- desktop/app/src/linux/clipboard.rs | 946 +++++++++++++++- desktop/app/src/linux/doctor.rs | 344 +++++- desktop/app/src/linux/elevated.rs | 103 +- desktop/app/src/linux/icon.rs | 35 +- desktop/app/src/linux/image.rs | 1192 +++++++++++++++++++- desktop/app/src/linux/jumplist.rs | 203 +++- desktop/app/src/linux/share_target.rs | 191 +++- desktop/app/src/linux/shell.rs | 441 +++++++- desktop/app/src/linux/shortcut.rs | 406 ++++++- desktop/app/src/linux/single_instance.rs | 154 ++- desktop/app/src/linux/toast.rs | 779 +++++++++++++- desktop/app/src/linux/tray.rs | 1255 +++++++++++++++++++++- desktop/app/src/linux/widget.rs | 270 ++++- desktop/app/src/linux/wifi.rs | 135 ++- desktop/app/src/linux/wol.rs | 231 +++- desktop/app/src/pc_notifications.rs | 197 +++- desktop/app/src/send_to.rs | 4 + desktop/app/src/settings.rs | 4 + desktop/app/src/transfers.rs | 18 +- 23 files changed, 7241 insertions(+), 197 deletions(-) diff --git a/desktop/app/src/core_host.rs b/desktop/app/src/core_host.rs index 7984c837..a150d274 100644 --- a/desktop/app/src/core_host.rs +++ b/desktop/app/src/core_host.rs @@ -112,9 +112,10 @@ pub fn compute_capabilities( nectarlink_core::SMS_SHOW.into(), nectarlink_core::TOGGLES_SHOW.into(), ]; + #[cfg(any(windows, target_os = "linux"))] + caps.push(nectarlink_core::PC_WAKE.into()); #[cfg(windows)] { - caps.push(nectarlink_core::PC_WAKE.into()); caps.push("media.control".into()); caps.push(nectarlink_core::STORAGE_MOUNT.into()); caps.push(nectarlink_core::STORAGE_READ.into()); diff --git a/desktop/app/src/linux/autostart.rs b/desktop/app/src/linux/autostart.rs index 248b5fbf..28a3ab04 100644 --- a/desktop/app/src/linux/autostart.rs +++ b/desktop/app/src/linux/autostart.rs @@ -1,20 +1,135 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! XDG autostart management (`~/.config/autostart/nectarlink.desktop`) on Linux. -use std::{io, path::Path}; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; +const DESKTOP_FILE_NAME: &str = "nectarlink.desktop"; + +/// The command line written into the autostart entry's `Exec=` line. pub fn command(exe: &Path) -> String { format!("\"{}\" --minimized", exe.display()) } -pub fn set(_enabled: bool, _exe: &Path) -> io::Result<()> { - Ok(()) + +fn autostart_file_path_in(config_dir: &Path) -> PathBuf { + config_dir.join("autostart").join(DESKTOP_FILE_NAME) +} + +fn autostart_file_path() -> io::Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no XDG config directory"))?; + Ok(autostart_file_path_in(&config_dir)) +} + +fn render_desktop_entry(exe: &Path) -> String { + let cmd = command(exe); + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=NectarLink\n\ + Comment=Link your Android phone and PC\n\ + Exec={cmd}\n\ + Icon=nectarlink\n\ + Terminal=false\n\ + Categories=Utility;Network;\n\ + StartupNotify=false\n\ + X-GNOME-Autostart-enabled=true\n" + ) +} + +fn set_in(config_dir: &Path, enabled: bool, exe: &Path) -> io::Result<()> { + let path = autostart_file_path_in(config_dir); + if enabled { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, render_desktop_entry(exe)) + } else { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } +} + +fn is_enabled_in(config_dir: &Path) -> bool { + let path = autostart_file_path_in(config_dir); + let Ok(contents) = fs::read_to_string(path) else { + return false; + }; + !contents.lines().any(|line| { + let t = line.trim(); + t.eq_ignore_ascii_case("Hidden=true") || t.eq_ignore_ascii_case("X-GNOME-Autostart-enabled=false") + }) +} + +/// Adds or removes the XDG autostart `.desktop` entry for `exe`. +pub fn set(enabled: bool, exe: &Path) -> io::Result<()> { + let path = autostart_file_path()?; + if let Some(config_dir) = path.parent().and_then(Path::parent) { + set_in(config_dir, enabled, exe) + } else { + Err(io::Error::new(io::ErrorKind::NotFound, "invalid XDG autostart path")) + } } + +/// Alias for checking whether the XDG autostart entry is currently present and enabled. pub fn is_enabled() -> bool { - false + dirs::config_dir().as_deref().is_some_and(is_enabled_in) +} + +/// Alias for enabling or disabling XDG autostart for the current executable. +pub fn set_enabled(enabled: bool) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + set(enabled, &exe).map_err(|e| e.to_string()) } -pub fn set_enabled(_enabled: bool) -> Result<(), String> { - Ok(()) + +/// Whether `exe` is installed in a standard Linux application location (rather +/// than run directly from a `target/` build directory). +pub fn is_installed(exe: &Path) -> bool { + if std::env::var_os("APPIMAGE").is_some() { + return true; + } + let s = exe.to_string_lossy(); + if s.contains("/target/debug/") || s.contains("/target/release/") { + return false; + } + exe.starts_with("/usr/bin") + || exe.starts_with("/usr/local/bin") + || exe.starts_with("/opt") + || dirs::data_local_dir().is_some_and(|d| exe.starts_with(d)) } -pub fn is_installed(_exe: &Path) -> bool { - false + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_and_removes_xdg_autostart_desktop_file() { + let dir = tempfile::tempdir().unwrap(); + let exe = Path::new("/usr/bin/nectarlink-desktop"); + + assert!(!is_enabled_in(dir.path())); + set_in(dir.path(), true, exe).unwrap(); + assert!(is_enabled_in(dir.path())); + + let desktop = fs::read_to_string(autostart_file_path_in(dir.path())).unwrap(); + assert!(desktop.contains("Exec=\"/usr/bin/nectarlink-desktop\" --minimized")); + + set_in(dir.path(), false, exe).unwrap(); + assert!(!is_enabled_in(dir.path())); + // Removing again is a no-op. + set_in(dir.path(), false, exe).unwrap(); + } + + #[test] + fn detects_installed_vs_build_paths() { + assert!(is_installed(Path::new("/usr/bin/nectarlink-desktop"))); + assert!(is_installed(Path::new("/opt/nectarlink/nectarlink-desktop"))); + assert!(!is_installed(Path::new("/home/user/repo/target/debug/nectarlink-desktop"))); + assert!(!is_installed(Path::new("/home/user/repo/target/release/nectarlink-desktop"))); + } } diff --git a/desktop/app/src/linux/ble_beacon.rs b/desktop/app/src/linux/ble_beacon.rs index d167ec00..f8a39b48 100644 --- a/desktop/app/src/linux/ble_beacon.rs +++ b/desktop/app/src/linux/ble_beacon.rs @@ -1,9 +1,387 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +use std::collections::HashMap; +use std::process::{Child, Command, Stdio}; +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; use nectarlink_core::DeviceId; -pub fn start(_device_id: DeviceId) { - let _ = crate::automations::TRIGGER_PHONE_WALKED_AWAY; +pub const COMPANY_ID: u16 = 0xFFFF; +pub const MAGIC: &[u8; 5] = b"NCTR\x01"; +pub const NEAR_RSSI_DBM: i16 = -72; +pub const WALK_AWAY_RSSI_DBM: i16 = -85; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const POLL_STEP: Duration = Duration::from_millis(100); + +static RUNNING: AtomicBool = AtomicBool::new(false); +static WORKER: Mutex>> = Mutex::new(None); + +#[derive(Debug, Default)] +pub struct RssiTracker { + peers: HashMap<[u8; 8], PeerRssiState>, +} + +#[derive(Debug, Clone, Default)] +struct PeerRssiState { + samples: [i16; 4], + count: usize, + idx: usize, + armed_near: bool, +} + +impl RssiTracker { + pub fn record(&mut self, prefix: [u8; 8], rssi: i16) -> bool { + let state = self.peers.entry(prefix).or_default(); + state.samples[state.idx] = rssi; + state.idx = (state.idx + 1) % state.samples.len(); + state.count = (state.count + 1).min(state.samples.len()); + if state.count < state.samples.len() { + return false; + } + let sum: i32 = state.samples.iter().map(|&v| i32::from(v)).sum(); + let avg = (sum / (state.samples.len() as i32)) as i16; + if avg >= NEAR_RSSI_DBM { + state.armed_near = true; + false + } else if state.armed_near && avg <= WALK_AWAY_RSSI_DBM { + state.armed_near = false; + true + } else { + false + } + } +} + +pub fn build_manufacturer_payload(device_id: &DeviceId) -> [u8; 13] { + let mut out = [0u8; 13]; + out[0..5].copy_from_slice(MAGIC); + out[5..13].copy_from_slice(&device_id.as_bytes()[0..8]); + out +} + +/// Validates the `NCTR\x01` magic header and extracts the peer device ID prefix. +/// Accepts either the 13-byte payload (5-byte magic + 8-byte prefix) or a 12-byte +/// payload (5-byte magic + 7-byte prefix, zero-padded in the 8th byte). +pub fn parse_manufacturer_payload(bytes: &[u8]) -> Option<[u8; 8]> { + if bytes.len() < 12 || &bytes[0..5] != MAGIC { + return None; + } + let mut prefix = [0u8; 8]; + if bytes.len() >= 13 { + prefix.copy_from_slice(&bytes[5..13]); + } else { + prefix[0..7].copy_from_slice(&bytes[5..12]); + } + Some(prefix) +} + +pub fn start(device_id: DeviceId) { + if RUNNING.swap(true, Ordering::SeqCst) { + return; + } + let handle = thread::Builder::new().name("ble-beacon".into()).spawn(move || run_worker(device_id)).ok(); + if let Ok(mut guard) = WORKER.lock() { + *guard = handle; + } +} + +pub fn stop() { + RUNNING.store(false, Ordering::SeqCst); + let handle = WORKER.lock().ok().and_then(|mut guard| guard.take()); + if let Some(h) = handle { + let _ = h.join(); + } +} + +fn walk_away_lock_enabled() -> bool { + let rules = crate::settings::automation_rules(); + !crate::automations::matching_rules(&rules, crate::automations::TRIGGER_PHONE_WALKED_AWAY).is_empty() +} + +fn start_bluetoothctl_advertisement(payload: &[u8; 13]) -> Option { + let hex_bytes = payload.iter().map(|b| format!("0x{b:02x}")).collect::>().join(" "); + let mut child = Command::new("bluetoothctl") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + let script = + format!("advertise.manufacturer 0xffff {hex_bytes}\nadvertise.name off\nadvertise on\nscan on\n"); + let _ = stdin.write_all(script.as_bytes()); + let _ = stdin.flush(); + child.stdin = Some(stdin); + } + Some(child) +} + +fn stop_bluetoothctl_advertisement(child: Option) { + let Some(mut child) = child else { return }; + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + let _ = stdin.write_all(b"scan off\nadvertise off\nquit\n"); + let _ = stdin.flush(); + } + let _ = child.kill(); + let _ = child.wait(); +} + +fn is_adapter_powered() -> bool { + if let Ok(out) = Command::new("busctl") + .args(["--system", "get-property", "org.bluez", "/org/bluez/hci0", "org.bluez.Adapter1", "Powered"]) + .output() + && out.status.success() + { + let stdout = String::from_utf8_lossy(&out.stdout); + return stdout.trim().ends_with("true"); + } + if let Ok(out) = Command::new("bluetoothctl").arg("show").output() + && out.status.success() + { + let stdout = String::from_utf8_lossy(&out.stdout); + return stdout.lines().any(|l| { + l.trim().strip_prefix("Powered:").is_some_and(|v| v.trim().eq_ignore_ascii_case("yes")) + }); + } + false +} + +fn fallback_peer_prefix(id_str: &str) -> [u8; 8] { + let mut out = [0u8; 8]; + let bytes = id_str.as_bytes(); + let start = bytes.len().saturating_sub(8); + for (dst, &src) in out.iter_mut().zip(&bytes[start..]) { + *dst = src; + } + out +} + +fn parse_rssi_from_info(info: &str) -> Option { + for line in info.lines() { + let trimmed = line.trim(); + let Some(rest) = trimmed.strip_prefix("RSSI:") else { + continue; + }; + let rest = rest.trim(); + if let Some(open) = rest.find('(') + && let Some(close) = rest[open + 1..].find(')') + && let Ok(val) = rest[open + 1..open + 1 + close].trim().parse::() + { + return Some(val); + } + if let Some(tok) = rest.split_whitespace().next() + && let Ok(val) = tok.parse::() + { + return Some(val); + } + } + None +} + +fn parse_manufacturer_from_info(info: &str) -> Option> { + let mut in_ffff_block = false; + let mut collected = Vec::new(); + for line in info.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("ManufacturerData Key:") { + if !collected.is_empty() { + break; + } + let lower = rest.to_ascii_lowercase(); + in_ffff_block = lower.contains("0xffff") || lower.contains("65535"); + continue; + } + if in_ffff_block && let Some(rest) = trimmed.strip_prefix("ManufacturerData Value:") { + for tok in rest.split_whitespace() { + let hex = tok.strip_prefix("0x").unwrap_or(tok); + if hex.len() == 2 + && let Ok(b) = u8::from_str_radix(hex, 16) + { + collected.push(b); + } else { + break; + } + } + } + } + if collected.is_empty() { None } else { Some(collected) } +} + +fn poll_bluez_devices(own_prefix: &[u8; 8], tracker: &mut RssiTracker) { + let Ok(out) = Command::new("bluetoothctl").arg("devices").output() else { + return; + }; + if !out.status.success() { + return; + } + let devices = super::bt_hfp::parse_bluetoothctl_devices(&String::from_utf8_lossy(&out.stdout)); + for (mac, _) in devices { + let Ok(info_out) = Command::new("bluetoothctl").args(["info", &mac]).output() else { + continue; + }; + if !info_out.status.success() { + continue; + } + let info = String::from_utf8_lossy(&info_out.stdout); + let Some(rssi) = parse_rssi_from_info(&info) else { + continue; + }; + let connected = super::bt_hfp::parse_bluetoothctl_connected(&info); + let paired = info + .lines() + .any(|l| l.trim().strip_prefix("Paired:").is_some_and(|v| v.trim().eq_ignore_ascii_case("yes"))); + + let peer_prefix = if let Some(mfg_bytes) = parse_manufacturer_from_info(&info) + && let Some(prefix) = parse_manufacturer_payload(&mfg_bytes) + { + if &prefix == own_prefix { + continue; + } + Some(prefix) + } else if connected && paired && walk_away_lock_enabled() { + Some(fallback_peer_prefix(&mac)) + } else { + None + }; + + if let Some(prefix) = peer_prefix + && tracker.record(prefix, rssi) + { + tracing::info!("BLE walk-away threshold reached; locking workstation and firing trigger"); + let _ = super::shell::lock_workstation(); + crate::automations::fire_trigger(crate::automations::TRIGGER_PHONE_WALKED_AWAY); + } + } +} + +fn run_worker(device_id: DeviceId) { + if !is_adapter_powered() { + tracing::debug!("BLE beacon: /org/bluez/hci0 unavailable or not powered; exiting worker"); + RUNNING.store(false, Ordering::SeqCst); + return; + } + + let payload = build_manufacturer_payload(&device_id); + let mut own_prefix = [0u8; 8]; + own_prefix.copy_from_slice(&device_id.as_bytes()[0..8]); + + let adv_child = start_bluetoothctl_advertisement(&payload); + let mut tracker = RssiTracker::default(); + + while RUNNING.load(Ordering::Relaxed) { + poll_bluez_devices(&own_prefix, &mut tracker); + + let steps = (POLL_INTERVAL.as_millis() / POLL_STEP.as_millis()).max(1); + for _ in 0..steps { + if !RUNNING.load(Ordering::Relaxed) { + break; + } + thread::sleep(POLL_STEP); + } + } + + stop_bluetoothctl_advertisement(adv_child); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_13_byte_manufacturer_payload_with_magic_and_id_prefix() { + let mut raw = [0u8; 32]; + for (i, b) in raw.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(3); + } + let id = DeviceId(raw); + let payload = build_manufacturer_payload(&id); + assert_eq!(payload.len(), 13); + assert_eq!(&payload[0..5], b"NCTR\x01"); + assert_eq!(&payload[5..13], &raw[0..8]); + assert_eq!(COMPANY_ID, 0xFFFF); + } + + #[test] + fn manufacturer_payload_roundtrips_through_parser() { + let mut raw = [0u8; 32]; + for (i, b) in raw.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(0x10); + } + let id = DeviceId(raw); + let payload = build_manufacturer_payload(&id); + let parsed = parse_manufacturer_payload(&payload).expect("valid payload should parse"); + assert_eq!(&parsed[..], &raw[0..8]); + + // Also supports 12-byte payloads (5-byte magic + 7-byte prefix). + let parsed_12 = parse_manufacturer_payload(&payload[0..12]).expect("12-byte payload should parse"); + assert_eq!(&parsed_12[0..7], &raw[0..7]); + assert_eq!(parsed_12[7], 0); + } + + #[test] + fn parser_rejects_short_or_wrong_magic_payloads() { + assert_eq!(parse_manufacturer_payload(b""), None); + assert_eq!(parse_manufacturer_payload(b"NCTR\x01short"), None); + assert_eq!(parse_manufacturer_payload(b"NCTR\x0212345678"), None); + assert_eq!(parse_manufacturer_payload(b"XXXX\x0112345678"), None); + } + + #[test] + fn rssi_tracker_fires_walk_away_only_after_armed_near() { + let mut tracker = RssiTracker::default(); + let peer = [1, 2, 3, 4, 5, 6, 7, 8]; + for _ in 0..4 { + assert!(!tracker.record(peer, -90)); + } + for _ in 0..4 { + assert!(!tracker.record(peer, -65)); + } + assert!(!tracker.record(peer, -88)); + assert!(!tracker.record(peer, -90)); + assert!(!tracker.record(peer, -92)); + assert!(tracker.record(peer, -90)); + assert!(!tracker.record(peer, -92)); + } + + #[test] + fn rssi_tracker_arms_at_near_threshold_and_fires_once_on_walk_away() { + let mut tracker = RssiTracker::default(); + let peer = [9, 8, 7, 6, 5, 4, 3, 2]; + // Exactly at NEAR_RSSI_DBM (-72) for 4 samples arms the tracker. + for _ in 0..4 { + assert!(!tracker.record(peer, NEAR_RSSI_DBM)); + } + // Dropping to WALK_AWAY_RSSI_DBM (-85) across 4 samples triggers once. + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(tracker.record(peer, WALK_AWAY_RSSI_DBM)); + // Subsequent distant samples do not re-trigger without re-arming near. + for _ in 0..4 { + assert!(!tracker.record(peer, -95)); + } + } + + #[test] + fn start_and_stop_never_panic_when_ble_radio_is_absent_or_present() { + let id = DeviceId([0x42; 32]); + start(id); + stop(); + } + + #[test] + fn start_and_stop_are_idempotent_without_bluez() { + let id = DeviceId([0x24; 32]); + start(id); + start(id); + stop(); + stop(); + } } -pub fn stop() {} diff --git a/desktop/app/src/linux/bt_hfp.rs b/desktop/app/src/linux/bt_hfp.rs index b0e56678..16b159ec 100644 --- a/desktop/app/src/linux/bt_hfp.rs +++ b/desktop/app/src/linux/bt_hfp.rs @@ -9,6 +9,12 @@ pub struct HfpStatus { pub detail: String, } +pub(crate) fn is_valid_bt_mac(mac: &str) -> bool { + !mac.is_empty() + && !mac.starts_with('-') + && mac.bytes().all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'-') +} + pub(crate) fn parse_bluetoothctl_devices(output: &str) -> Vec<(String, String)> { let mut out = Vec::new(); for raw_line in output.lines() { @@ -18,7 +24,7 @@ pub(crate) fn parse_bluetoothctl_devices(output: &str) -> Vec<(String, String)> }; let rest = rest.trim(); let mut parts = rest.splitn(2, char::is_whitespace); - let Some(mac) = parts.next().map(str::trim).filter(|m| !m.is_empty()) else { + let Some(mac) = parts.next().map(str::trim).filter(|m| is_valid_bt_mac(m)) else { continue; }; let name = parts.next().map(str::trim).unwrap_or_default().to_owned(); diff --git a/desktop/app/src/linux/clipboard.rs b/desktop/app/src/linux/clipboard.rs index 436f10d6..1d2b4d14 100644 --- a/desktop/app/src/linux/clipboard.rs +++ b/desktop/app/src/linux/clipboard.rs @@ -1,7 +1,58 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! The Linux clipboard (`wl-copy`/`wl-paste` on Wayland, `xclip`/`xsel` on X11, +//! with an in-memory fallback for headless test environments): watching for +//! copied text and images, reading them, and writing what a phone sent +//! (docs/protocol/clipboard.md). +//! +//! Content that password managers mark as private (`x-kde-passwordManagerHint` +//! set to `"secret"`, or `x-gnome-privacy-hint`) is never read for sending. +//! Writes from NectarLink record their [`Fingerprint`] in [`LAST_REPORTED`] +//! before updating the system clipboard so the watcher thread never echoes +//! phone clipboard writes back to the phone. -use std::{path::PathBuf, sync::Mutex}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use super::image::{self, Bitmap}; + +/// Poll interval for the background Linux clipboard watcher thread. +const POLL_INTERVAL: Duration = Duration::from_millis(450); + +/// Content fingerprint used to deduplicate clipboard updates and suppress +/// echoing NectarLink's own writes back to connected phones. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fingerprint { + Text(u64), + Image(u64), +} + +type OnCopy = Box; +type WrittenRich = (String, Option, Option); + +static ON_COPY: OnceLock = OnceLock::new(); + +/// Fingerprint of the most recently reported or locally-written clipboard +/// item so the watcher thread never echoes our own writes back to the phone. +static LAST_REPORTED: Mutex> = Mutex::new(None); + +/// Most recent rich-text payload (`(plain_text, html, rtf)`) written by +/// NectarLink so rich formats survive round-trips through plain-text bridges. +static LAST_WRITTEN_RICH: Mutex> = Mutex::new(None); + +/// In-memory clipboard fallback for headless environments without a live +/// Wayland/X11 clipboard server. +static LAST_CLIP: Mutex = Mutex::new(Clip::Empty); + +/// Plain text of the most recent `write_private` call so an explicit +/// `read_for_paste` (Ctrl+V in a mirror window) can read it even when +/// `read()` treats it as `Clip::Private`. +static LAST_PRIVATE_TEXT: Mutex> = Mutex::new(None); + +/// In-memory file-list fallback for headless environments. +static LAST_FILES: Mutex>> = Mutex::new(None); #[derive(Debug, Clone, PartialEq, Eq)] pub enum Image { @@ -13,7 +64,11 @@ impl Image { pub fn to_png(&self) -> Result, String> { match self { Image::Png(bytes) => Ok(bytes.clone()), - Image::Bitmap(_) => Err("not supported on Linux yet".into()), + Image::Bitmap(dib) => { + let bmp = image::bmp_from_dib(dib).ok_or_else(|| "invalid DIB header".to_string())?; + let decoded = image::decode(&bmp).map_err(|e| e.to_string())?; + image::encode_png(&decoded).map_err(|e| e.to_string()) + } } } } @@ -26,47 +81,900 @@ pub enum Clip { Empty, } -static FALLBACK_TEXT: Mutex> = Mutex::new(None); +fn fnv(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, &b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)) +} + +pub(crate) fn fingerprint(clip: &Clip) -> Option { + match clip { + Clip::Text { text, .. } => { + let normalized = text.replace("\r\n", "\n"); + let stripped = normalized.trim_end_matches('\n'); + if stripped.trim().is_empty() { None } else { Some(Fingerprint::Text(fnv(stripped.as_bytes()))) } + } + Clip::Image(Image::Png(bytes) | Image::Bitmap(bytes)) => { + if let Ok(bitmap) = image::decode(bytes) { + Some(Fingerprint::Image(fnv(&bitmap.bgra))) + } else if !bytes.is_empty() { + Some(Fingerprint::Image(fnv(bytes))) + } else { + None + } + } + Clip::Private | Clip::Empty => None, + } +} + +fn has_display_server() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some() +} + +fn is_wayland() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() +} + +/// Checks whether a newline-separated list of clipboard MIME targets contains +/// a password-manager / privacy exclusion hint (`x-kde-passwordManagerHint` or +/// `x-gnome-privacy-hint`). +pub(crate) fn is_secret_mime_list(targets: &str) -> bool { + targets.lines().any(|line| { + let t = line.trim(); + t.eq_ignore_ascii_case("x-gnome-privacy-hint") + || t.eq_ignore_ascii_case("x-kde-passwordManagerHint") + || t.eq_ignore_ascii_case("ExcludeClipboardContentFromMonitorProcessing") + || t.eq_ignore_ascii_case("Clipboard Viewer Ignore") + }) +} + +fn has_gnome_privacy_hint(targets: &str) -> bool { + targets.lines().any(|line| { + let t = line.trim(); + t.eq_ignore_ascii_case("x-gnome-privacy-hint") + || t.eq_ignore_ascii_case("ExcludeClipboardContentFromMonitorProcessing") + || t.eq_ignore_ascii_case("Clipboard Viewer Ignore") + }) +} + +fn has_kde_password_hint(targets: &str) -> bool { + targets.lines().any(|line| line.trim().eq_ignore_ascii_case("x-kde-passwordManagerHint")) +} + +fn has_mime_target(targets: &str, wanted: &str) -> bool { + targets.lines().any(|line| { + let t = line.trim(); + t.eq_ignore_ascii_case(wanted) + || t.split(';').next().is_some_and(|base| base.trim().eq_ignore_ascii_case(wanted)) + }) +} + +pub(crate) fn is_kde_secret_value(bytes: &[u8]) -> bool { + let trimmed = match bytes.iter().position(|&b| b == 0) { + Some(end) => &bytes[..end], + None => bytes, + }; + std::str::from_utf8(trimmed).ok().is_some_and(|s| s.trim().eq_ignore_ascii_case("secret")) +} + +/// Starts watching the Linux clipboard on a background thread (`"clipboard"`). +/// Newly copied text or images that are not marked private and were not +/// written by NectarLink are passed to `on_copy`. +pub fn start(on_copy: F) +where + F: Fn(Clip) + Send + Sync + 'static, +{ + if ON_COPY.set(Box::new(on_copy)).is_err() { + return; + } + if !has_display_server() { + return; + } + let spawned = std::thread::Builder::new().name("clipboard".into()).spawn(watcher_loop); + if let Err(e) = spawned { + tracing::warn!(error = %e, "can't start Linux clipboard watcher"); + } +} + +fn watcher_loop() { + loop { + std::thread::sleep(POLL_INTERVAL); + let clip = read(); + if let Some(on_copy) = ON_COPY.get() { + let _ = report_if_changed(clip, on_copy); + } + } +} + +/// Evaluates `clip` against [`LAST_REPORTED`] and [`LAST_WRITTEN_RICH`], +/// invoking ` callback ` only when `clip` represents new non-private content. +pub(crate) fn report_if_changed(mut clip: Clip, callback: F) -> bool +where + F: FnOnce(Clip), +{ + if matches!(clip, Clip::Private | Clip::Empty) { + return false; + } + if let Clip::Text { ref text, ref mut html, ref mut rtf } = clip + && html.is_none() + && rtf.is_none() + && let Some((prev_text, saved_html, saved_rtf)) = + LAST_WRITTEN_RICH.lock().unwrap_or_else(|e| e.into_inner()).clone() + && prev_text == *text + { + *html = saved_html; + *rtf = saved_rtf; + } + let Some(fp) = fingerprint(&clip) else { + return false; + }; + { + let mut last = LAST_REPORTED.lock().unwrap_or_else(|e| e.into_inner()); + if *last == Some(fp) { + return false; + } + *last = Some(fp); + } + callback(clip); + true +} + +fn query_os_targets() -> Option<(bool, String)> { + if is_wayland() + && let Ok(out) = Command::new("wl-paste").arg("--list-types").output() + && out.status.success() + { + return Some((true, String::from_utf8_lossy(&out.stdout).into_owned())); + } + if let Ok(out) = Command::new("xclip").args(["-selection", "clipboard", "-t", "TARGETS", "-o"]).output() + && out.status.success() + { + return Some((false, String::from_utf8_lossy(&out.stdout).into_owned())); + } + None +} + +fn read_os_mime(wayland: bool, mime: &str) -> Option> { + if wayland { + let out = Command::new("wl-paste").args(["--no-newline", "--type", mime]).output().ok()?; + out.status.success().then_some(out.stdout) + } else { + let out = Command::new("xclip").args(["-selection", "clipboard", "-t", mime, "-o"]).output().ok()?; + out.status.success().then_some(out.stdout) + } +} + +fn clean_valid_utf8_text(bytes: Vec) -> Option { + if bytes.is_empty() + || bytes.starts_with(b"\xFF\xD8\xFF") + || bytes.starts_with(b"\x89PNG\r\n\x1a\n") + || bytes.starts_with(b"GIF87a") + || bytes.starts_with(b"GIF89a") + || bytes.starts_with(b"RIFF") + || bytes.starts_with(b"BM") + { + return None; + } + let text = String::from_utf8(bytes).ok()?; + let trimmed = text.trim_matches('\0'); + if trimmed.trim().is_empty() { + return None; + } + Some(trimmed.to_owned()) +} + +fn read_os_plain_text(wayland: bool) -> Option { + if wayland { + for mime in ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING"] { + if let Ok(out) = Command::new("wl-paste").args(["--no-newline", "--type", mime]).output() + && out.status.success() + && let Some(text) = clean_valid_utf8_text(out.stdout) + { + return Some(text); + } + } + } else { + for mime in ["UTF8_STRING", "text/plain;charset=utf-8", "text/plain", "STRING"] { + if let Ok(out) = + Command::new("xclip").args(["-selection", "clipboard", "-t", mime, "-o"]).output() + && out.status.success() + && let Some(text) = clean_valid_utf8_text(out.stdout) + { + return Some(text); + } + } + if let Ok(out) = Command::new("xsel").args(["--clipboard", "--output"]).output() + && out.status.success() + && let Some(text) = clean_valid_utf8_text(out.stdout) + { + return Some(text); + } + } + None +} + +fn enrich_with_last_written_rich(text: String, mut html: Option, mut rtf: Option) -> Clip { + if html.is_none() + && rtf.is_none() + && let Some((prev_text, saved_html, saved_rtf)) = + LAST_WRITTEN_RICH.lock().unwrap_or_else(|e| e.into_inner()).clone() + && prev_text == text + { + html = saved_html; + rtf = saved_rtf; + } + Clip::Text { text, html, rtf } +} + +pub(crate) fn has_copied_files_mime(targets: &str) -> bool { + has_mime_target(targets, "text/uri-list") + || has_mime_target(targets, "x-special/gnome-copied-files") + || has_mime_target(targets, "x-special/mate-copied-files") + || has_mime_target(targets, "application/x-kde-cutselection") +} + +pub(crate) fn is_copied_file_uri_text(text: &str) -> bool { + let mut found_file_uri = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || matches!(trimmed, "copy" | "cut") { + continue; + } + if from_file_uri(trimmed).is_some() { + found_file_uri = true; + } else { + return false; + } + } + found_file_uri +} + +fn read_from_os(check_privacy: bool) -> Option { + if cfg!(test) || !has_display_server() { + return None; + } + if let Some((wayland, targets)) = query_os_targets() { + if check_privacy { + if has_gnome_privacy_hint(&targets) { + return Some(Clip::Private); + } + if has_kde_password_hint(&targets) { + match read_os_mime(wayland, "x-kde-passwordManagerHint") { + Some(val) if is_kde_secret_value(&val) => return Some(Clip::Private), + None => return Some(Clip::Private), + _ => {} + } + } + } -pub fn start(_on_copy: F) {} + if has_mime_target(&targets, "image/png") + && let Some(bytes) = read_os_mime(wayland, "image/png") + && !bytes.is_empty() + { + return Some(Clip::Image(Image::Png(bytes))); + } + for img_mime in + ["image/jpeg", "image/jpg", "image/webp", "image/bmp", "image/x-bmp", "image/x-MS-bmp"] + { + if has_mime_target(&targets, img_mime) + && let Some(bytes) = read_os_mime(wayland, img_mime) + && let Ok(bitmap) = image::decode(&bytes) + && let Ok(png) = image::encode_png(&bitmap) + { + return Some(Clip::Image(Image::Png(png))); + } + } + + if has_copied_files_mime(&targets) { + return Some(Clip::Empty); + } + + if let Some(text) = read_os_plain_text(wayland) { + if is_copied_file_uri_text(&text) { + return Some(Clip::Empty); + } + let html = if has_mime_target(&targets, "text/html") { + read_os_mime(wayland, "text/html").and_then(|b| { + decode_cf_html(&b).or_else(|| { + let s = String::from_utf8(b).ok()?; + let trimmed = s.trim_matches('\0').trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) + }) + }) + } else { + None + }; + let rtf = if has_mime_target(&targets, "text/rtf") { + read_os_mime(wayland, "text/rtf").and_then(|b| decode_cf_rtf(&b)) + } else if has_mime_target(&targets, "application/rtf") { + read_os_mime(wayland, "application/rtf").and_then(|b| decode_cf_rtf(&b)) + } else { + None + }; + return Some(enrich_with_last_written_rich(text, html, rtf)); + } + } + if let Some(text) = read_os_plain_text(is_wayland()) { + if is_copied_file_uri_text(&text) { + return Some(Clip::Empty); + } + return Some(enrich_with_last_written_rich(text, None, None)); + } + None +} + +/// Reads the current clipboard contents. pub fn read() -> Clip { - match FALLBACK_TEXT.lock().unwrap_or_else(|e| e.into_inner()).clone() { - Some(text) => Clip::Text { text, html: None, rtf: None }, - None => Clip::Empty, + if let Some(os_clip) = read_from_os(true) { + // If we wrote a private clip whose fingerprint matches what's still on + // the OS clipboard, keep treating it as `Clip::Private`. + if let Some(fp) = fingerprint(&os_clip) + && *LAST_REPORTED.lock().unwrap_or_else(|e| e.into_inner()) == Some(fp) + && matches!(*LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()), Clip::Private) + { + return Clip::Private; + } + return os_clip; + } + + let fallback = LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone(); + match fallback { + Clip::Text { text, html, rtf } => enrich_with_last_written_rich(text, html, rtf), + other => other, } } +/// Like [`read`], but for an explicit paste (Ctrl+V in a mirror window), +/// where the user asked to send whatever is on the clipboard right now, +/// including items marked excluded from automatic clipboard monitoring. pub fn read_for_paste() -> Clip { - read() + if let Some(os_clip) = read_from_os(false) { + return os_clip; + } + let fallback = read(); + if matches!(fallback, Clip::Private) + && let Some(text) = LAST_PRIVATE_TEXT.lock().unwrap_or_else(|e| e.into_inner()).clone() + { + return Clip::Text { text, html: None, rtf: None }; + } + fallback } pub fn read_image_png() -> Option> { - None + match read() { + Clip::Image(img) => img.to_png().ok(), + _ => None, + } } pub fn write(text: &str) -> Result<(), String> { - crate::mirror::note_remote_clipboard(text); - *FALLBACK_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_owned()); - Ok(()) + write_text(text, None, None, false) } -pub fn write_rich(text: &str, _html: Option<&str>, _rtf: Option<&str>) -> Result<(), String> { - write(text) +pub fn write_rich(text: &str, html: Option<&str>, rtf: Option<&str>) -> Result<(), String> { + write_text(text, html, rtf, false) } pub fn write_private(text: &str) -> Result<(), String> { - write(text) + write_text(text, None, None, true) +} + +fn write_text(text: &str, html: Option<&str>, rtf: Option<&str>, private: bool) -> Result<(), String> { + let mut html_owned = html.filter(|s| !s.trim().is_empty()).map(str::to_owned); + let mut rtf_owned = rtf.filter(|s| !s.trim().is_empty()).map(str::to_owned); + + if !private + && html_owned.is_none() + && rtf_owned.is_none() + && let Some((prev_text, prev_h, prev_r)) = + LAST_WRITTEN_RICH.lock().unwrap_or_else(|e| e.into_inner()).as_ref() + && prev_text == text + { + html_owned = prev_h.clone(); + rtf_owned = prev_r.clone(); + } + + let text_clip = Clip::Text { text: text.to_owned(), html: html_owned.clone(), rtf: rtf_owned.clone() }; + + // Update LAST_REPORTED and LAST_WRITTEN_RICH BEFORE touching the OS + // clipboard so the background watcher never echoes our own write back. + *LAST_REPORTED.lock().unwrap_or_else(|e| e.into_inner()) = fingerprint(&text_clip); + + if private { + *LAST_WRITTEN_RICH.lock().unwrap_or_else(|e| e.into_inner()) = None; + *LAST_PRIVATE_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_owned()); + *LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()) = Clip::Private; + } else { + *LAST_PRIVATE_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = None; + *LAST_WRITTEN_RICH.lock().unwrap_or_else(|e| e.into_inner()) = + if html_owned.is_some() || rtf_owned.is_some() { + Some((text.to_owned(), html_owned.clone(), rtf_owned.clone())) + } else { + None + }; + *LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()) = text_clip; + } + + if !cfg!(test) { + crate::mirror::note_remote_clipboard(text); + } + + if !cfg!(test) && has_display_server() { + if private { + let _ = pipe_to_cmd("wl-copy", &["--sensitive"], text.as_bytes()) + .or_else(|_| pipe_to_cmd("wl-copy", &[], text.as_bytes())) + .or_else(|_| pipe_to_cmd("xclip", &["-selection", "clipboard"], text.as_bytes())) + .or_else(|_| pipe_to_cmd("xsel", &["--clipboard", "--input"], text.as_bytes())); + } else { + let _ = pipe_to_cmd("wl-copy", &[], text.as_bytes()) + .or_else(|_| pipe_to_cmd("xclip", &["-selection", "clipboard"], text.as_bytes())) + .or_else(|_| pipe_to_cmd("xsel", &["--clipboard", "--input"], text.as_bytes())); + } + } + Ok(()) } -pub fn write_image(_mime: &str, _bytes: &[u8]) -> Result<(), String> { +pub fn write_image(mime: &str, bytes: &[u8]) -> Result<(), String> { + let bitmap: Bitmap = image::decode(bytes).map_err(|e| e.to_string())?; + let png = if mime == "image/png" && bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + bytes.to_vec() + } else { + image::encode_png(&bitmap).map_err(|e| e.to_string())? + }; + + // Record fingerprint BEFORE updating the system clipboard. + *LAST_REPORTED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Fingerprint::Image(fnv(&bitmap.bgra))); + *LAST_WRITTEN_RICH.lock().unwrap_or_else(|e| e.into_inner()) = None; + *LAST_PRIVATE_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = None; + *LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()) = Clip::Image(Image::Png(png.clone())); + + if !cfg!(test) && has_display_server() { + let _ = pipe_to_cmd("wl-copy", &["--type", "image/png"], &png) + .or_else(|_| pipe_to_cmd("xclip", &["-selection", "clipboard", "-t", "image/png"], &png)); + } Ok(()) } +fn pipe_to_cmd(program: &str, args: &[&str], bytes: &[u8]) -> std::io::Result<()> { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(bytes)?; + } + let status = child.wait()?; + if status.success() { Ok(()) } else { Err(std::io::Error::other("clipboard helper exited with error")) } +} + +/// Converts a filesystem path into a percent-encoded `file:///...` URI suitable +/// for `text/uri-list`. +pub(crate) fn to_file_uri(path: &Path) -> String { + let raw = path.to_string_lossy(); + let normalized = raw.replace('\\', "/"); + let mut encoded = String::with_capacity(normalized.len() + 16); + if !normalized.starts_with('/') { + encoded.push('/'); + } + for &b in normalized.as_bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/' | b':') { + encoded.push(b as char); + } else { + encoded.push_str(&format!("%{b:02X}")); + } + } + format!("file://{encoded}") +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Parses a `file://` URI from a `text/uri-list` line and percent-decodes it +/// into a [`PathBuf`]. +pub(crate) fn from_file_uri(uri: &str) -> Option { + let trimmed = uri.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + let rest = if let Some(after) = trimmed.strip_prefix("file://localhost/") { + format!("/{after}") + } else { + let after = trimmed.strip_prefix("file://")?; + if after.is_empty() { + return None; + } + after.to_owned() + }; + + let bytes = rest.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) + { + decoded.push((hi << 4) | lo); + i += 3; + continue; + } + decoded.push(bytes[i]); + i += 1; + } + let path_str = String::from_utf8(decoded).ok()?; + (!path_str.is_empty()).then(|| PathBuf::from(path_str)) +} + +pub(crate) fn format_uri_list(paths: &[PathBuf]) -> String { + let mut out = String::new(); + for path in paths { + out.push_str(&to_file_uri(path)); + out.push_str("\r\n"); + } + out +} + +pub(crate) fn parse_uri_list(uri_list: &str) -> Vec { + uri_list.lines().filter_map(from_file_uri).collect() +} + +pub fn build_dropfiles_payload(paths: &[PathBuf]) -> Vec { + const DROPFILES_HEADER_SIZE: u32 = 20; + let mut utf16_units: Vec = Vec::new(); + for path in paths { + let s = path.to_string_lossy(); + utf16_units.extend(s.encode_utf16()); + utf16_units.push(0); + } + utf16_units.push(0); + + let mut out = Vec::with_capacity(DROPFILES_HEADER_SIZE as usize + utf16_units.len() * 2); + out.extend_from_slice(&DROPFILES_HEADER_SIZE.to_le_bytes()); + out.extend_from_slice(&0i32.to_le_bytes()); + out.extend_from_slice(&0i32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&1u32.to_le_bytes()); + for unit in utf16_units { + out.extend_from_slice(&unit.to_le_bytes()); + } + out +} + pub fn read_files() -> Option> { + if !cfg!(test) && has_display_server() { + let wayland = is_wayland(); + for mime in ["text/uri-list", "x-special/gnome-copied-files", "x-special/mate-copied-files"] { + if let Some(bytes) = read_os_mime(wayland, mime) + && let Ok(text) = String::from_utf8(bytes) + { + let parsed: Vec = parse_uri_list(&text).into_iter().filter(|p| p.exists()).collect(); + if !parsed.is_empty() { + return Some(parsed); + } + } + } + if let Some(text) = read_os_plain_text(wayland) + && is_copied_file_uri_text(&text) + { + let parsed: Vec = parse_uri_list(&text).into_iter().filter(|p| p.exists()).collect(); + if !parsed.is_empty() { + return Some(parsed); + } + } + } + LAST_FILES.lock().unwrap_or_else(|e| e.into_inner()).clone().filter(|v| !v.is_empty()) +} + +pub fn write_files(paths: &[PathBuf]) -> bool { + if paths.is_empty() { + return false; + } + let uri_list = format_uri_list(paths); + let trimmed = uri_list.trim_end_matches(&['\r', '\n'][..]); + *LAST_REPORTED.lock().unwrap_or_else(|e| e.into_inner()) = + Some(Fingerprint::Text(fnv(trimmed.as_bytes()))); + *LAST_WRITTEN_RICH.lock().unwrap_or_else(|e| e.into_inner()) = None; + *LAST_PRIVATE_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = None; + *LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()) = Clip::Empty; + *LAST_FILES.lock().unwrap_or_else(|e| e.into_inner()) = Some(paths.to_vec()); + if !cfg!(test) && has_display_server() { + let _ = pipe_to_cmd("wl-copy", &["--type", "text/uri-list"], uri_list.as_bytes()).or_else(|_| { + pipe_to_cmd("xclip", &["-selection", "clipboard", "-t", "text/uri-list"], uri_list.as_bytes()) + }); + } + true +} + +pub(crate) fn encode_cf_html(fragment: &str) -> Vec { + const PREFIX: &str = ""; + const SUFFIX: &str = ""; + const HEADER_TEMPLATE: &str = concat!( + "Version:0.9\r\n", + "StartHTML:0000000000\r\n", + "EndHTML:0000000000\r\n", + "StartFragment:0000000000\r\n", + "EndFragment:0000000000\r\n", + ); + + let start_html = HEADER_TEMPLATE.len(); + let start_fragment = start_html + PREFIX.len(); + let end_fragment = start_fragment + fragment.len(); + let end_html = end_fragment + SUFFIX.len(); + + let header = format!( + "Version:0.9\r\nStartHTML:{start_html:010}\r\nEndHTML:{end_html:010}\r\nStartFragment:{start_fragment:010}\r\nEndFragment:{end_fragment:010}\r\n" + ); + debug_assert_eq!(header.len(), start_html); + + let mut out = Vec::with_capacity(end_html + 1); + out.extend_from_slice(header.as_bytes()); + out.extend_from_slice(PREFIX.as_bytes()); + out.extend_from_slice(fragment.as_bytes()); + out.extend_from_slice(SUFFIX.as_bytes()); + out.push(0); + out +} + +pub(crate) fn decode_cf_html(bytes: &[u8]) -> Option { + let trimmed = match bytes.iter().position(|&b| b == 0) { + Some(end) => &bytes[..end], + None => bytes, + }; + if trimmed.is_empty() { + return None; + } + let text = std::str::from_utf8(trimmed).ok()?; + + let start_frag = parse_cf_html_offset(text, "StartFragment:"); + let end_frag = parse_cf_html_offset(text, "EndFragment:"); + if let (Some(s), Some(e)) = (start_frag, end_frag) + && s <= e + && e <= trimmed.len() + && let Ok(slice) = std::str::from_utf8(&trimmed[s..e]) + { + let cleaned = slice.trim(); + if !cleaned.is_empty() { + return Some(cleaned.to_string()); + } + } + + const START_MARK: &str = ""; + const END_MARK: &str = ""; + if let Some(s_idx) = text.find(START_MARK) { + let after = &text[s_idx + START_MARK.len()..]; + if let Some(e_idx) = after.find(END_MARK) { + let cleaned = after[..e_idx].trim(); + if !cleaned.is_empty() { + return Some(cleaned.to_string()); + } + } + } + None +} + +fn parse_cf_html_offset(text: &str, key: &str) -> Option { + for line in text.lines().take(12) { + if let Some(rest) = line.trim().strip_prefix(key) { + return rest.trim().parse::().ok(); + } + } None } -pub fn write_files(_paths: &[PathBuf]) -> bool { - false +pub(crate) fn encode_cf_rtf(rtf: &str) -> Vec { + let mut out = Vec::with_capacity(rtf.len() + 1); + out.extend_from_slice(rtf.as_bytes()); + out.push(0); + out +} + +pub(crate) fn decode_cf_rtf(bytes: &[u8]) -> Option { + let trimmed = match bytes.iter().position(|&b| b == 0) { + Some(end) => &bytes[..end], + None => bytes, + }; + let s = std::str::from_utf8(trimmed).ok()?.trim(); + if s.starts_with("{\\rtf") { Some(s.to_string()) } else { None } +} + +#[cfg(test)] +mod tests { + use super::*; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn cf_html_round_trips_utf8_fragment() { + let fragment = "

Hello world — 🐝

"; + let encoded = encode_cf_html(fragment); + assert_eq!(encoded.last(), Some(&0)); + let decoded = decode_cf_html(&encoded).expect("decoded fragment"); + assert_eq!(decoded, fragment); + } + + #[test] + fn cf_html_falls_back_to_markers_when_offsets_invalid() { + let raw = b"Version:0.9\r\nStartFragment:99999\r\nEndFragment:99999\r\nfallback\0"; + assert_eq!(decode_cf_html(raw).as_deref(), Some("fallback")); + } + + #[test] + fn cf_rtf_round_trips_and_validates_prefix() { + let rtf = "{\\rtf1\\ansi\\b Hello\\b0}"; + let encoded = encode_cf_rtf(rtf); + assert_eq!(decode_cf_rtf(&encoded).as_deref(), Some(rtf)); + assert!(decode_cf_rtf(b"not rtf\0").is_none()); + } + + #[test] + fn dropfiles_payload_has_valid_header_and_double_null_terminator() { + let paths = vec![PathBuf::from(r"C:\Temp\a.txt")]; + let bytes = build_dropfiles_payload(&paths); + assert_eq!(u32::from_le_bytes(bytes[0..4].try_into().unwrap()), 20); + assert_eq!(u32::from_le_bytes(bytes[16..20].try_into().unwrap()), 1); + assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); + } + + #[test] + fn text_and_rich_text_round_trip_and_preserve_last_written_rich() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + write("plain clip").expect("write plain"); + assert_eq!(read(), Clip::Text { text: "plain clip".to_string(), html: None, rtf: None }); + + let html = "

Rich clip

"; + let rtf = "{\\rtf1\\ansi{\\b Rich} clip}"; + write_rich("Rich clip", Some(html), Some(rtf)).expect("write rich"); + assert_eq!( + read(), + Clip::Text { + text: "Rich clip".to_string(), + html: Some(html.to_string()), + rtf: Some(rtf.to_string()), + } + ); + + // If a clipboard bridge echoes the same plain text without rich MIME + // formats, LAST_WRITTEN_RICH preserves the HTML and RTF formats. + write("Rich clip").expect("echo plain text"); + assert_eq!( + read(), + Clip::Text { + text: "Rich clip".to_string(), + html: Some(html.to_string()), + rtf: Some(rtf.to_string()), + } + ); + + // Writing private text marks the clipboard as Private and clears rich state. + write_private("948271").expect("write private"); + assert_eq!(read(), Clip::Private); + assert!(LAST_WRITTEN_RICH.lock().unwrap_or_else(|e| e.into_inner()).is_none()); + } + + #[test] + fn read_for_paste_returns_private_text() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + write_private("secret").expect("write_private"); + assert_eq!(read(), Clip::Private); + assert_eq!(read_for_paste(), Clip::Text { text: "secret".into(), html: None, rtf: None }); + } + + #[test] + fn image_png_and_bitmap_round_trip_and_update_fingerprint() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let bmp = Bitmap { width: 4, height: 4, bgra: [0x42, 0x84, 0xC6, 0xFF].repeat(16) }; + let png = image::encode_png(&bmp).expect("encode png"); + write_image("image/png", &png).expect("write image"); + let read_png = read_image_png().expect("read_image_png"); + let decoded = image::decode(&read_png).expect("decode round-trip png"); + assert_eq!(decoded, bmp); + + let dib = image::dibv5(&bmp); + let img_from_dib = Image::Bitmap(dib); + let converted = img_from_dib.to_png().expect("Bitmap::to_png"); + assert_eq!(image::decode(&converted).expect("decode"), bmp); + } + + #[test] + fn file_uri_encoding_and_write_read_files_round_trip() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let path = PathBuf::from("/path/with spaces/doc #1 (final).pdf"); + let uri = to_file_uri(&path); + assert_eq!(uri, "file:///path/with%20spaces/doc%20%231%20%28final%29.pdf"); + assert_eq!(from_file_uri(&uri), Some(path.clone())); + assert_eq!( + from_file_uri("file://localhost/home/alice/notes%202.txt"), + Some(PathBuf::from("/home/alice/notes 2.txt")) + ); + assert_eq!(from_file_uri("# comment line"), None); + + let paths = + vec![PathBuf::from("/tmp/nectarlink_a.txt"), PathBuf::from("/tmp/nectarlink dir/b 🐝.png")]; + let list = format_uri_list(&paths); + assert_eq!(parse_uri_list(&list), paths); + + assert!(!write_files(&[])); + assert!(write_files(&paths)); + assert_eq!(read_files(), Some(paths)); + } + + #[test] + fn loop_prevention_fingerprint_deduplicates_writes_and_copies() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + write("from phone").expect("write from phone"); + + // Because write() updated LAST_REPORTED, report_if_changed() must not + // echo "from phone" back to the phone (even with CRLF differences). + let mut reported = Vec::new(); + assert!(!report_if_changed( + Clip::Text { text: "from phone\r\n".into(), html: None, rtf: None }, + |c| reported.push(c) + )); + let _ = write("from phone"); + assert!(!report_if_changed(Clip::Text { text: "from phone".into(), html: None, rtf: None }, |c| { + reported.push(c) + })); + assert!(reported.is_empty()); + + // When new user content appears on the PC, it is reported exactly once. + let user_clip = Clip::Text { text: "copied on PC".into(), html: None, rtf: None }; + assert!(report_if_changed(user_clip.clone(), |c| reported.push(c))); + assert!(!report_if_changed(user_clip, |c| reported.push(c))); + assert_eq!(reported.len(), 1); + } + + #[test] + fn password_manager_mime_hint_detection() { + let normal_targets = "TIMESTAMP\nTARGETS\ntext/plain\ntext/plain;charset=utf-8\n"; + assert!(!is_secret_mime_list(normal_targets)); + + let kde_targets = "TARGETS\nx-kde-passwordManagerHint\ntext/plain\n"; + assert!(is_secret_mime_list(kde_targets)); + assert!(is_kde_secret_value(b"secret\n")); + assert!(is_kde_secret_value(b"secret\0")); + assert!(!is_kde_secret_value(b"public")); + + let gnome_targets = "text/plain\nx-gnome-privacy-hint\n"; + assert!(is_secret_mime_list(gnome_targets)); + } + + #[test] + fn copied_files_uri_list_is_not_treated_as_text_clip() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let targets = "text/plain\ntext/uri-list\n"; + assert!(has_copied_files_mime(targets)); + let gnome_targets = "x-special/gnome-copied-files\nUTF8_STRING\n"; + assert!(has_copied_files_mime(gnome_targets)); + + assert!(is_copied_file_uri_text( + "file:///home/jochen/Downloads/Nectarlink/PXL_20261016_130349832.MP.jpg\r\n" + )); + assert!(is_copied_file_uri_text( + "copy\nfile:///home/jochen/Downloads/Nectarlink/PXL_20261016_130349832.MP.jpg" + )); + assert!(!is_copied_file_uri_text("Check out file:///tmp/a.txt and https://example.com")); + + // After write_files, read() must return Clip::Empty (so send_now falls through to + // read_files() and the background watcher never echoes file:///... as text). + let paths = vec![PathBuf::from("/home/jochen/Downloads/Nectarlink/PXL_20261016_130349832.MP.jpg")]; + assert!(write_files(&paths)); + assert_eq!(read(), Clip::Empty); + let mut reported = Vec::new(); + assert!(!report_if_changed( + Clip::Text { + text: "file:///home/jochen/Downloads/Nectarlink/PXL_20261016_130349832.MP.jpg".into(), + html: None, + rtf: None, + }, + |c| reported.push(c) + )); + assert!(reported.is_empty()); + } } diff --git a/desktop/app/src/linux/doctor.rs b/desktop/app/src/linux/doctor.rs index 857233c5..b5bcb547 100644 --- a/desktop/app/src/linux/doctor.rs +++ b/desktop/app/src/linux/doctor.rs @@ -1,39 +1,357 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +use std::fs; +use std::io::Result; +use std::path::Path; +use std::process::Command; -use std::{io, path::Path}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Firewall { pub on: bool, pub allowed: bool, pub blocked: bool, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct Network { pub name: String, pub trusted: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy)] pub struct Adapters { pub local_address: bool, pub vpn: bool, } -pub fn firewall(_exe: &Path) -> io::Result { +pub fn parse_ufw_status(output: &str) -> Option { + let lower = output.to_ascii_lowercase(); + if lower.contains("status: inactive") { + return Some(Firewall { on: false, allowed: true, blocked: false }); + } + if !lower.contains("status: active") { + return None; + } + + let mut tcp_allowed = false; + let mut udp_allowed = false; + let mut blocked = false; + + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() + || trimmed.starts_with("Status:") + || trimmed.starts_with("To ") + || trimmed.starts_with("--") + { + continue; + } + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + if tokens.len() < 2 { + continue; + } + let port_spec = tokens[0].to_ascii_lowercase(); + let touches_7718 = port_spec == "7718" + || port_spec == "7718/tcp" + || port_spec == "7718/udp" + || port_spec.starts_with("7718:"); + if !touches_7718 { + continue; + } + let upper_line = trimmed.to_ascii_uppercase(); + if upper_line.contains("DENY") || upper_line.contains("REJECT") { + blocked = true; + } else if upper_line.contains("ALLOW") { + if port_spec == "7718" { + tcp_allowed = true; + udp_allowed = true; + } else if port_spec == "7718/tcp" || port_spec.ends_with("/tcp") { + tcp_allowed = true; + } else if port_spec == "7718/udp" || port_spec.ends_with("/udp") { + udp_allowed = true; + } + } + } + + let allowed = !blocked && tcp_allowed && udp_allowed; + Some(Firewall { on: true, allowed, blocked }) +} + +pub fn parse_firewalld_output(state: &str, ports: &str) -> Firewall { + if !state.trim().eq_ignore_ascii_case("running") { + return Firewall { on: false, allowed: true, blocked: false }; + } + let allowed = + ports.split_whitespace().any(|p| p.eq_ignore_ascii_case("7718/tcp") || p.starts_with("7718-")); + Firewall { on: true, allowed, blocked: false } +} + +fn check_ufw_config_fallback() -> Option { + let conf = fs::read_to_string("/etc/ufw/ufw.conf").ok()?; + let enabled = conf.lines().any(|l| { + let t = l.trim(); + t.eq_ignore_ascii_case("ENABLED=yes") || t.eq_ignore_ascii_case("ENABLED=\"yes\"") + }); + if !enabled { + return Some(Firewall { on: false, allowed: true, blocked: false }); + } + if let Ok(rules) = fs::read_to_string("/etc/ufw/user.rules") { + let has_drop_7718 = + rules.lines().any(|l| l.contains("7718") && (l.contains("-j DROP") || l.contains("-j REJECT"))); + let has_accept_7718 = rules.lines().any(|l| l.contains("7718") && l.contains("-j ACCEPT")); + return Some(Firewall { + on: true, + allowed: !has_drop_7718 && has_accept_7718, + blocked: has_drop_7718, + }); + } + Some(Firewall { on: true, allowed: false, blocked: false }) +} + +fn is_service_active(service: &str) -> bool { + Command::new("systemctl").args(["is-active", "--quiet", service]).status().is_ok_and(|s| s.success()) +} + +fn has_command(cmd: &str) -> bool { + if cmd.is_empty() || cmd.contains('/') || cmd.contains('\0') { + return false; + } + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + let path_var = std::env::var_os("PATH").unwrap_or_default(); + let extra = [Path::new("/usr/sbin"), Path::new("/sbin"), Path::new("/usr/bin"), Path::new("/bin")]; + std::env::split_paths(&path_var).chain(extra.into_iter().map(Path::to_path_buf)).any(|dir| { + let candidate = dir.join(cmd); + fs::metadata(&candidate).is_ok_and(|m| { + if !m.is_file() { + return false; + } + #[cfg(unix)] + { + m.mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } + }) + }) +} + +pub fn firewall(_exe: &Path) -> Result { + // 1. Try ufw status + if let Ok(out) = Command::new("ufw").arg("status").output() + && out.status.success() + { + let stdout = String::from_utf8_lossy(&out.stdout); + if let Some(fw) = parse_ufw_status(&stdout) { + return Ok(fw); + } + } + if is_service_active("ufw") + && let Some(fw) = check_ufw_config_fallback() + { + return Ok(fw); + } + + // 2. Try firewalld (firewall-cmd --state & firewall-cmd --list-ports) + if let Ok(state_out) = Command::new("firewall-cmd").arg("--state").output() { + let state_str = String::from_utf8_lossy(&state_out.stdout); + if state_str.trim().eq_ignore_ascii_case("running") { + let ports_str = Command::new("firewall-cmd") + .arg("--list-ports") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + return Ok(parse_firewalld_output(&state_str, &ports_str)); + } + } + + // 3. Try nftables + if is_service_active("nftables") + && let Ok(conf) = fs::read_to_string("/etc/nftables.conf") + { + let lower = conf.to_ascii_lowercase(); + let has_policy_drop = lower.contains("policy drop"); + let has_7718 = lower.contains("7718"); + if has_policy_drop || has_7718 { + return Ok(Firewall { on: true, allowed: has_7718, blocked: false }); + } + } + Ok(Firewall { on: false, allowed: true, blocked: false }) } -pub fn networks() -> io::Result> { - Ok(Vec::new()) + +pub fn networks() -> Result> { + let mut list = Vec::new(); + if let Ok(entries) = fs::read_dir("/sys/class/net") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "lo" { + continue; + } + let state = fs::read_to_string(entry.path().join("operstate")) + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if state == "up" { + list.push(Network { name, trusted: true }); + } + } + } + Ok(list) } + pub fn adapters() -> Adapters { - Adapters { local_address: true, vpn: false } + let mut local_address = false; + let mut vpn = false; + + if let Ok(entries) = fs::read_dir("/sys/class/net") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if name == "lo" { + continue; + } + let state = fs::read_to_string(entry.path().join("operstate")) + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if state != "up" && state != "unknown" { + continue; + } + if name.starts_with("tun") + || name.starts_with("tap") + || name.starts_with("wg") + || name.starts_with("tailscale") + || name.starts_with("nordlynx") + || name.starts_with("ppp") + { + vpn = true; + } else { + local_address = true; + } + } + } + + Adapters { local_address, vpn } } -pub fn fix_firewall(_exe: &Path) -> io::Result { - Ok(false) + +pub fn fix_firewall(_exe: &Path) -> Result { + if is_service_active("ufw") || has_command("ufw") { + let status = super::elevated::run_elevated( + Path::new("sh"), + &["-c", "ufw allow 7718/tcp && ufw allow 7718/udp && ufw allow 5353/udp"], + ); + return Ok(matches!(status, Ok(0))); + } + if is_service_active("firewalld") || has_command("firewall-cmd") { + let status = super::elevated::run_elevated( + Path::new("sh"), + &[ + "-c", + "firewall-cmd --permanent --add-port=7718/tcp --add-port=7718/udp --add-port=5353/udp && firewall-cmd --reload", + ], + ); + return Ok(matches!(status, Ok(0))); + } + if is_service_active("nftables") { + let status = super::elevated::run_elevated( + Path::new("sh"), + &[ + "-c", + "nft add rule inet filter input tcp dport 7718 accept; nft add rule inet filter input udp dport { 5353, 7718 } accept", + ], + ); + return Ok(matches!(status, Ok(0))); + } + Ok(true) } + pub fn open_firewall_settings() -> bool { - false + Command::new("gufw") + .spawn() + .or_else(|_| Command::new("firewall-config").spawn()) + .or_else(|_| Command::new("plasma-firewall").spawn()) + .or_else(|_| Command::new("gnome-control-center").arg("network").spawn()) + .is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ufw_inactive_active_allowed_and_blocked() { + assert_eq!( + parse_ufw_status("Status: inactive\n"), + Some(Firewall { on: false, allowed: true, blocked: false }) + ); + + let allowed_output = "\ +Status: active + +To Action From +-- ------ ---- +22/tcp ALLOW Anywhere +7718/tcp ALLOW Anywhere +7718/udp ALLOW Anywhere +"; + assert_eq!( + parse_ufw_status(allowed_output), + Some(Firewall { on: true, allowed: true, blocked: false }) + ); + + let combined_allowed_output = "\ +Status: active + +To Action From +-- ------ ---- +7718 ALLOW Anywhere +"; + assert_eq!( + parse_ufw_status(combined_allowed_output), + Some(Firewall { on: true, allowed: true, blocked: false }) + ); + + let blocked_output = "\ +Status: active + +To Action From +-- ------ ---- +7718/tcp DENY Anywhere +"; + assert_eq!( + parse_ufw_status(blocked_output), + Some(Firewall { on: true, allowed: false, blocked: true }) + ); + + let unconfigured_active = "\ +Status: active + +To Action From +-- ------ ---- +22/tcp ALLOW Anywhere +"; + assert_eq!( + parse_ufw_status(unconfigured_active), + Some(Firewall { on: true, allowed: false, blocked: false }) + ); + + assert_eq!(parse_ufw_status("ufw: command not found"), None); + } + + #[test] + fn parses_firewalld_running_and_not_running() { + assert_eq!( + parse_firewalld_output("not running\n", ""), + Firewall { on: false, allowed: true, blocked: false } + ); + assert_eq!( + parse_firewalld_output("running\n", "80/tcp 443/tcp\n"), + Firewall { on: true, allowed: false, blocked: false } + ); + assert_eq!( + parse_firewalld_output("running\n", "5353/udp 7718/tcp 7718/udp\n"), + Firewall { on: true, allowed: true, blocked: false } + ); + } } diff --git a/desktop/app/src/linux/elevated.rs b/desktop/app/src/linux/elevated.rs index 0c64444b..4b05a724 100644 --- a/desktop/app/src/linux/elevated.rs +++ b/desktop/app/src/linux/elevated.rs @@ -1,32 +1,109 @@ // SPDX-License-Identifier: GPL-3.0-or-later -//! Linux privilege check (stub). +//! Running a process with root privileges on Linux via `pkexec` (Polkit). -use std::path::Path; +use std::{ + path::Path, + process::{Child, Command, Stdio}, + sync::Mutex, +}; +/// Why starting the elevated process failed. #[derive(Debug)] pub enum Error { + /// The user dismissed or declined the Polkit prompt (`pkexec` exit status 126 or 127). Declined, Other(String), } +/// A running elevated child process. #[derive(Debug)] -pub struct Process; +pub struct Process(Mutex); -impl Process { - pub fn exit_code(&self) -> Option { - Some(1) - } +unsafe extern "C" { + fn geteuid() -> u32; } +/// Returns `true` if the current process is already running as `root` (`euid == 0`). pub fn is_elevated() -> bool { - // SAFETY: geteuid has no preconditions and is always safe to call on Linux. - unsafe { libc::geteuid() == 0 } + // SAFETY: `geteuid` is always safe and has no preconditions. + unsafe { geteuid() == 0 } } -pub fn start(_program: &Path, _params: &str) -> Result { - Err(Error::Other("elevated execution not supported on Linux yet".into())) +fn split_params(params: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_quotes = false; + for ch in params.chars() { + match ch { + '"' => in_quotes = !in_quotes, + c if c.is_whitespace() && !in_quotes => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + c => cur.push(c), + } + } + if !cur.is_empty() { + out.push(cur); + } + out } -pub fn run_elevated(_exe: &Path, _args: &[&str]) -> Result { - Err("elevated execution not supported on Linux yet".into()) +/// Starts `program` with `params` under `pkexec` (or directly when already `root`), +/// without waiting for it to finish. +pub fn start(program: &Path, params: &str) -> Result { + let parsed = split_params(params); + let mut cmd = if is_elevated() { + let mut c = Command::new(program); + c.args(&parsed); + c + } else { + let mut c = Command::new("pkexec"); + c.arg(program).args(&parsed); + c + }; + cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + match cmd.spawn() { + Ok(child) => Ok(Process(Mutex::new(child))), + Err(e) => Err(Error::Other(e.to_string())), + } +} + +/// Runs `program` with `args` under `pkexec` and waits for its exit code. +pub fn run_elevated(program: &Path, args: &[&str]) -> Result { + let mut cmd = if is_elevated() { + let mut c = Command::new(program); + c.args(args); + c + } else { + let mut c = Command::new("pkexec"); + c.arg(program).args(args); + c + }; + let status = cmd.status().map_err(|e| e.to_string())?; + Ok(status.code().unwrap_or(1)) +} + +impl Process { + /// `None` while the process is still running; `Some(code)` once it has exited. + pub fn exit_code(&self) -> Option { + let mut child = self.0.lock().unwrap_or_else(|e| e.into_inner()); + match child.try_wait() { + Ok(Some(status)) => Some(status.code().unwrap_or(1) as u32), + Ok(None) => None, + Err(_) => Some(1), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_quoted_and_unquoted_params() { + let args = split_params("--install-to \"/opt/NectarLink App\" --quiet"); + assert_eq!(args, vec!["--install-to", "/opt/NectarLink App", "--quiet"]); + } } diff --git a/desktop/app/src/linux/icon.rs b/desktop/app/src/linux/icon.rs index 49762cbf..63aaf787 100644 --- a/desktop/app/src/linux/icon.rs +++ b/desktop/app/src/linux/icon.rs @@ -18,8 +18,37 @@ pub fn write_png(path: &Path, size: u32) -> Result<()> { } let bitmap = Bitmap { width: size, height: size, bgra }; let png = encode_png(&bitmap)?; - if let Some(parent) = path.parent() { - let _ = fs::create_dir_all(parent); + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; + if let Some(parent) = path.parent() { + let _ = fs::DirBuilder::new().recursive(true).mode(0o700).create(parent); + if let Ok(meta) = fs::symlink_metadata(parent) { + let euid = unsafe { libc::geteuid() }; + if meta.file_type().is_symlink() || meta.uid() != euid { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "refusing to write icon into untrusted directory", + )); + } + } + } + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .custom_flags(libc::O_NOFOLLOW) + .mode(0o644) + .open(path)?; + file.write_all(&png)?; + Ok(()) + } + #[cfg(not(unix))] + { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + fs::write(path, png) } - fs::write(path, png) } diff --git a/desktop/app/src/linux/image.rs b/desktop/app/src/linux/image.rs index 73167dd1..240215d3 100644 --- a/desktop/app/src/linux/image.rs +++ b/desktop/app/src/linux/image.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Image decoding, scaling, square cropping, and PNG/JPEG/DIB encoding on Linux. -use std::io; +use std::io::{Error, ErrorKind, Result}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Bitmap { @@ -10,27 +10,1185 @@ pub struct Bitmap { pub bgra: Vec, } -pub fn encode_png(_bitmap: &Bitmap) -> io::Result> { - Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) +const BITMAPFILEHEADER_SIZE: usize = 14; +const BITMAPV5HEADER_SIZE: u32 = 124; +const BI_RGB: u32 = 0; +const BI_BITFIELDS: u32 = 3; +const LCS_SRGB: u32 = 0x7352_4742; + +const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + +const ZIGZAG: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, + 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, + 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +const STD_LUMA_QUANT: [u8; 64] = [ + 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69, 56, 14, 17, + 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, 68, 109, 103, 77, 24, 35, 55, 64, 81, 104, 113, 92, 49, 64, 78, + 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99, +]; + +const STD_CHROMA_QUANT: [u8; 64] = [ + 17, 18, 24, 47, 99, 99, 99, 99, 18, 21, 26, 66, 99, 99, 99, 99, 24, 26, 56, 99, 99, 99, 99, 99, 47, 66, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, +]; + +const BITS_DC_LUMA: [u8; 16] = [0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]; +const VAL_DC_LUMA: [u8; 12] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + +const BITS_DC_CHROMA: [u8; 16] = [0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0]; +const VAL_DC_CHROMA: [u8; 12] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + +const BITS_AC_LUMA: [u8; 16] = [0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7d]; +const VAL_AC_LUMA: [u8; 162] = [ + 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, + 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, + 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, + 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, + 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, + 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, + 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, + 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, + 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, +]; + +const BITS_AC_CHROMA: [u8; 16] = [0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77]; +const VAL_AC_CHROMA: [u8; 162] = [ + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, + 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, + 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28, 0x29, + 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, + 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, + 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, + 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, + 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, + 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, +]; + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc ^= u32::from(byte); + for _ in 0..8 { + if crc & 1 != 0 { + crc = (crc >> 1) ^ 0xEDB8_8320; + } else { + crc >>= 1; + } + } + } + !crc } -pub fn encode_jpeg(_bitmap: &Bitmap, _quality: u8) -> io::Result> { - Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + +fn adler32(data: &[u8]) -> u32 { + let mut a: u32 = 1; + let mut b: u32 = 0; + for &byte in data { + a = (a + u32::from(byte)) % 65_521; + b = (b + a) % 65_521; + } + (b << 16) | a } -pub fn decode(_bytes: &[u8]) -> io::Result { - Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + +fn write_png_chunk(out: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + let start = out.len(); + out.extend_from_slice(chunk_type); + out.extend_from_slice(data); + let crc = crc32(&out[start..]); + out.extend_from_slice(&crc.to_be_bytes()); } -pub fn scale_to(bitmap: &Bitmap, _max: u32) -> Bitmap { - bitmap.clone() + +pub fn encode_png(bitmap: &Bitmap) -> Result> { + if bitmap.width == 0 || bitmap.height == 0 { + return Err(Error::new(ErrorKind::InvalidInput, "empty bitmap")); + } + let expected = (bitmap.width as usize) + .checked_mul(bitmap.height as usize) + .and_then(|px| px.checked_mul(4)) + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "bitmap dimensions overflow"))?; + if bitmap.bgra.len() < expected { + return Err(Error::new(ErrorKind::InvalidInput, "bitmap buffer too short")); + } + + let stride = bitmap.width as usize * 4; + let mut raw = Vec::with_capacity((stride + 1) * bitmap.height as usize); + for row in 0..bitmap.height as usize { + raw.push(0u8); // Filter type 0 (None) + let row_slice = &bitmap.bgra[row * stride..(row + 1) * stride]; + for px in row_slice.as_chunks::<4>().0 { + raw.push(px[2]); // R + raw.push(px[1]); // G + raw.push(px[0]); // B + raw.push(px[3]); // A + } + } + + // Wrap raw scanlines in a valid zlib stream using stored (uncompressed) DEFLATE blocks. + let mut zlib = Vec::with_capacity(raw.len() + (raw.len() / 65_535 + 1) * 5 + 6); + zlib.push(0x78); + zlib.push(0x01); + let chunks: Vec<&[u8]> = if raw.is_empty() { vec![&[][..]] } else { raw.chunks(65_535).collect() }; + for (i, chunk) in chunks.iter().enumerate() { + let is_last = i + 1 == chunks.len(); + zlib.push(u8::from(is_last)); + let len = chunk.len() as u16; + zlib.extend_from_slice(&len.to_le_bytes()); + zlib.extend_from_slice(&(!len).to_le_bytes()); + zlib.extend_from_slice(chunk); + } + zlib.extend_from_slice(&adler32(&raw).to_be_bytes()); + + let mut out = Vec::with_capacity(zlib.len() + 64); + out.extend_from_slice(&PNG_SIG); + + let mut ihdr = [0u8; 13]; + ihdr[0..4].copy_from_slice(&bitmap.width.to_be_bytes()); + ihdr[4..8].copy_from_slice(&bitmap.height.to_be_bytes()); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type: RGBA + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + write_png_chunk(&mut out, b"IHDR", &ihdr); + write_png_chunk(&mut out, b"IDAT", &zlib); + write_png_chunk(&mut out, b"IEND", &[]); + + Ok(out) } -pub fn crop_square(bitmap: &Bitmap) -> Bitmap { - bitmap.clone() + +fn scale_quant_table(base: &[u8; 64], quality: u8) -> [u8; 64] { + let q = i32::from(quality.clamp(1, 100)); + let scale = if q < 50 { 5000 / q } else { 200 - q * 2 }; + let mut out = [0u8; 64]; + for (i, &b) in base.iter().enumerate() { + let v = (i32::from(b) * scale + 50) / 100; + out[ZIGZAG[i]] = v.clamp(1, 255) as u8; + } + out +} + +fn build_huff_encode_table(bits: &[u8; 16], vals: &[u8]) -> [(u16, u8); 256] { + let mut table = [(0u16, 0u8); 256]; + let mut code: u16 = 0; + let mut idx = 0usize; + for (len_minus_1, &count) in bits.iter().enumerate() { + let bit_len = (len_minus_1 + 1) as u8; + for _ in 0..count { + let sym = vals[idx] as usize; + table[sym] = (code, bit_len); + code = code.wrapping_add(1); + idx += 1; + } + code <<= 1; + } + table +} + +struct JpegBitWriter { + out: Vec, + bit_buf: u32, + bit_count: u8, +} + +impl JpegBitWriter { + fn new(capacity: usize) -> Self { + Self { out: Vec::with_capacity(capacity), bit_buf: 0, bit_count: 0 } + } + + fn write_bits(&mut self, bits: u16, len: u8) { + if len == 0 { + return; + } + let mask = (1u32 << len) - 1; + self.bit_buf = (self.bit_buf << len) | (u32::from(bits) & mask); + self.bit_count += len; + while self.bit_count >= 8 { + self.bit_count -= 8; + let byte = ((self.bit_buf >> self.bit_count) & 0xFF) as u8; + self.out.push(byte); + if byte == 0xFF { + self.out.push(0x00); + } + } + } + + fn flush(&mut self) { + if self.bit_count > 0 { + let pad_len = 8 - self.bit_count; + self.write_bits((1u16 << pad_len) - 1, pad_len); + } + } } -pub fn crop_square_jpeg(_bytes: &[u8], _max_side: u32, _quality: u8) -> io::Result> { - Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + +fn magnitude_category(val: i32) -> (u8, u16) { + let abs_v = val.unsigned_abs(); + if abs_v == 0 { + return (0, 0); + } + let cat = (32 - abs_v.leading_zeros()) as u8; + let mask = (1u32 << cat) - 1; + let bits = if val > 0 { abs_v & mask } else { (abs_v ^ mask) & mask }; + (cat, bits as u16) +} + +fn fdct_block(block: &[f32; 64], quant_zigzag: &[u8; 64]) -> [i32; 64] { + let mut tmp = [0.0f32; 64]; + let mut cos_table = [[0.0f32; 8]; 8]; + for (u, row) in cos_table.iter_mut().enumerate() { + for (x, cell) in row.iter_mut().enumerate() { + *cell = (((2 * x + 1) * u) as f32 * std::f32::consts::PI / 16.0).cos(); + } + } + for y in 0..8 { + for u in 0..8 { + let cu = if u == 0 { std::f32::consts::FRAC_1_SQRT_2 } else { 1.0 }; + let mut sum = 0.0f32; + for x in 0..8 { + sum += block[y * 8 + x] * cos_table[u][x]; + } + tmp[y * 8 + u] = 0.5 * cu * sum; + } + } + let mut out_zz = [0i32; 64]; + for (v, cos_v) in cos_table.iter().enumerate() { + let cv = if v == 0 { std::f32::consts::FRAC_1_SQRT_2 } else { 1.0 }; + for u in 0..8 { + let mut sum = 0.0f32; + for (y, &cos_vy) in cos_v.iter().enumerate() { + sum += tmp[y * 8 + u] * cos_vy; + } + let coeff = 0.5 * cv * sum; + let idx = v * 8 + u; + let zz = ZIGZAG[idx]; + let q = f32::from(quant_zigzag[zz]); + out_zz[zz] = (coeff / q).round() as i32; + } + } + out_zz } -pub fn bmp_from_dib(_dib: &[u8]) -> Option> { + +fn encode_jpeg_block( + bw: &mut JpegBitWriter, + zz: &[i32; 64], + prev_dc: i32, + dc_table: &[(u16, u8); 256], + ac_table: &[(u16, u8); 256], +) -> i32 { + let dc = zz[0]; + let diff = dc - prev_dc; + let (cat, bits) = magnitude_category(diff); + let (hcode, hlen) = dc_table[cat as usize]; + bw.write_bits(hcode, hlen); + bw.write_bits(bits, cat); + + let mut zero_run = 0u8; + for &ac in &zz[1..] { + if ac == 0 { + zero_run += 1; + } else { + while zero_run >= 16 { + let (zcode, zlen) = ac_table[0xF0]; + bw.write_bits(zcode, zlen); + zero_run -= 16; + } + let (acat, abits) = magnitude_category(ac); + let sym = ((zero_run << 4) | acat) as usize; + let (acode, alen) = ac_table[sym]; + bw.write_bits(acode, alen); + bw.write_bits(abits, acat); + zero_run = 0; + } + } + if zero_run > 0 { + let (ecode, elen) = ac_table[0x00]; + bw.write_bits(ecode, elen); + } + dc +} + +/// Encodes `bitmap` into a standard baseline JFIF JPEG stream (`quality` 1..=100). +pub fn encode_jpeg(bitmap: &Bitmap, quality: u8) -> Result> { + if bitmap.width == 0 || bitmap.height == 0 { + return Err(Error::new(ErrorKind::InvalidInput, "empty bitmap")); + } + if bitmap.width > 65_535 || bitmap.height > 65_535 { + return Err(Error::new(ErrorKind::InvalidInput, "bitmap dimensions exceed JPEG maximum")); + } + let expected = (bitmap.width as usize) + .checked_mul(bitmap.height as usize) + .and_then(|px| px.checked_mul(4)) + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "bitmap dimensions overflow"))?; + if bitmap.bgra.len() < expected { + return Err(Error::new(ErrorKind::InvalidInput, "bitmap buffer too short")); + } + + let luma_q = scale_quant_table(&STD_LUMA_QUANT, quality); + let chroma_q = scale_quant_table(&STD_CHROMA_QUANT, quality); + let dc_luma = build_huff_encode_table(&BITS_DC_LUMA, &VAL_DC_LUMA); + let dc_chroma = build_huff_encode_table(&BITS_DC_CHROMA, &VAL_DC_CHROMA); + let ac_luma = build_huff_encode_table(&BITS_AC_LUMA, &VAL_AC_LUMA); + let ac_chroma = build_huff_encode_table(&BITS_AC_CHROMA, &VAL_AC_CHROMA); + + let mut bw = JpegBitWriter::new(expected / 2 + 1024); + + // SOI + bw.out.extend_from_slice(&[0xFF, 0xD8]); + + // APP0 (JFIF 1.01) + bw.out.extend_from_slice(&[ + 0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0x00, 0x01, 0x01, 0x01, 0x00, 0x48, 0x00, 0x48, 0x00, + 0x00, + ]); + + // DQT (table 0 = luma, table 1 = chroma) + bw.out.extend_from_slice(&[0xFF, 0xDB, 0x00, 67, 0x00]); + bw.out.extend_from_slice(&luma_q); + bw.out.extend_from_slice(&[0xFF, 0xDB, 0x00, 67, 0x01]); + bw.out.extend_from_slice(&chroma_q); + + // SOF0 (Baseline DCT, 3 components, 4:4:4 sampling 1x1) + let w_be = (bitmap.width as u16).to_be_bytes(); + let h_be = (bitmap.height as u16).to_be_bytes(); + bw.out.extend_from_slice(&[ + 0xFF, 0xC0, 0x00, 17, 8, h_be[0], h_be[1], w_be[0], w_be[1], 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1, + ]); + + // DHT (4 tables: DC0, AC0, DC1, AC1) + let write_dht = |out: &mut Vec, tc_th: u8, bits: &[u8; 16], vals: &[u8]| { + let len = (2 + 1 + 16 + vals.len()) as u16; + out.extend_from_slice(&[0xFF, 0xC4]); + out.extend_from_slice(&len.to_be_bytes()); + out.push(tc_th); + out.extend_from_slice(bits); + out.extend_from_slice(vals); + }; + write_dht(&mut bw.out, 0x00, &BITS_DC_LUMA, &VAL_DC_LUMA); + write_dht(&mut bw.out, 0x10, &BITS_AC_LUMA, &VAL_AC_LUMA); + write_dht(&mut bw.out, 0x01, &BITS_DC_CHROMA, &VAL_DC_CHROMA); + write_dht(&mut bw.out, 0x11, &BITS_AC_CHROMA, &VAL_AC_CHROMA); + + // SOS + bw.out.extend_from_slice(&[0xFF, 0xDA, 0x00, 12, 3, 1, 0x00, 2, 0x11, 3, 0x11, 0x00, 0x3F, 0x00]); + + let w = bitmap.width as usize; + let h = bitmap.height as usize; + let mcu_cols = w.div_ceil(8); + let mcu_rows = h.div_ceil(8); + + let mut prev_y = 0i32; + let mut prev_cb = 0i32; + let mut prev_cr = 0i32; + + for my in 0..mcu_rows { + for mx in 0..mcu_cols { + let mut y_blk = [0.0f32; 64]; + let mut cb_blk = [0.0f32; 64]; + let mut cr_blk = [0.0f32; 64]; + for by in 0..8 { + let py = (my * 8 + by).min(h - 1); + for bx in 0..8 { + let px = (mx * 8 + bx).min(w - 1); + let idx = (py * w + px) * 4; + let b = f32::from(bitmap.bgra[idx]); + let g = f32::from(bitmap.bgra[idx + 1]); + let r = f32::from(bitmap.bgra[idx + 2]); + let y_val = 0.299 * r + 0.587 * g + 0.114 * b - 128.0; + let cb_val = -0.168_736 * r - 0.331_264 * g + 0.5 * b; + let cr_val = 0.5 * r - 0.418_688 * g - 0.081_312 * b; + let bidx = by * 8 + bx; + y_blk[bidx] = y_val; + cb_blk[bidx] = cb_val; + cr_blk[bidx] = cr_val; + } + } + let y_zz = fdct_block(&y_blk, &luma_q); + let cb_zz = fdct_block(&cb_blk, &chroma_q); + let cr_zz = fdct_block(&cr_blk, &chroma_q); + prev_y = encode_jpeg_block(&mut bw, &y_zz, prev_y, &dc_luma, &ac_luma); + prev_cb = encode_jpeg_block(&mut bw, &cb_zz, prev_cb, &dc_chroma, &ac_chroma); + prev_cr = encode_jpeg_block(&mut bw, &cr_zz, prev_cr, &dc_chroma, &ac_chroma); + } + } + + bw.flush(); + // EOI + bw.out.extend_from_slice(&[0xFF, 0xD9]); + Ok(bw.out) +} + +#[derive(Default, Clone)] +struct HuffDecodeTable { + max_code: [i32; 17], + val_offset: [i32; 17], + vals: Vec, +} + +impl HuffDecodeTable { + fn from_bits_vals(bits: &[u8; 16], vals: &[u8]) -> Self { + let mut max_code = [-1i32; 17]; + let mut val_offset = [0i32; 17]; + let mut code = 0i32; + let mut idx = 0i32; + for i in 1..=16 { + let count = i32::from(bits[i - 1]); + if count > 0 { + val_offset[i] = idx - code; + idx += count; + code += count; + max_code[i] = code - 1; + } else { + max_code[i] = -1; + } + code <<= 1; + } + Self { max_code, val_offset, vals: vals.to_vec() } + } +} + +struct JpegBitReader<'a> { + data: &'a [u8], + pos: usize, + bit_buf: u32, + bit_count: u8, +} + +impl<'a> JpegBitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0, bit_buf: 0, bit_count: 0 } + } + + fn next_byte(&mut self) -> Option { + if self.pos >= self.data.len() { + return None; + } + let b = self.data[self.pos]; + self.pos += 1; + if b == 0xFF { + if self.pos < self.data.len() && self.data[self.pos] == 0x00 { + self.pos += 1; + } else { + return None; + } + } + Some(b) + } + + fn read_bit(&mut self) -> Option { + if self.bit_count == 0 { + let b = self.next_byte()?; + self.bit_buf = u32::from(b); + self.bit_count = 8; + } + self.bit_count -= 1; + Some(((self.bit_buf >> self.bit_count) & 1) as u8) + } + + fn read_bits(&mut self, len: u8) -> Option { + let mut v = 0u16; + for _ in 0..len { + v = (v << 1) | u16::from(self.read_bit()?); + } + Some(v) + } + + fn decode_huff(&mut self, table: &HuffDecodeTable) -> Option { + let mut code = 0i32; + for len in 1..=16 { + code = (code << 1) | i32::from(self.read_bit()?); + if code <= table.max_code[len] { + let idx = (code + table.val_offset[len]) as usize; + return table.vals.get(idx).copied(); + } + } + None + } +} + +fn extend_magnitude(bits: u16, cat: u8) -> i32 { + if cat == 0 { + return 0; + } + let half = 1u16 << (cat - 1); + if bits < half { i32::from(bits) - ((1i32 << cat) - 1) } else { i32::from(bits) } +} + +fn idct_block(zz: &[i32; 64], quant_zigzag: &[u8; 64]) -> [f32; 64] { + let mut dequant = [0.0f32; 64]; + for i in 0..64 { + let z = ZIGZAG[i]; + dequant[i] = (zz[z] as f32) * f32::from(quant_zigzag[z]); + } + let mut cos_table = [[0.0f32; 8]; 8]; + for (u, row) in cos_table.iter_mut().enumerate() { + for (x, cell) in row.iter_mut().enumerate() { + *cell = (((2 * x + 1) * u) as f32 * std::f32::consts::PI / 16.0).cos(); + } + } + let mut tmp = [0.0f32; 64]; + for y in 0..8 { + for x in 0..8 { + let mut sum = 0.0f32; + for u in 0..8 { + let cu = if u == 0 { std::f32::consts::FRAC_1_SQRT_2 } else { 1.0 }; + sum += cu * dequant[y * 8 + u] * cos_table[u][x]; + } + tmp[y * 8 + x] = 0.5 * sum; + } + } + let mut out = [0.0f32; 64]; + for y in 0..8 { + for x in 0..8 { + let mut sum = 0.0f32; + for v in 0..8 { + let cv = if v == 0 { std::f32::consts::FRAC_1_SQRT_2 } else { 1.0 }; + sum += cv * tmp[v * 8 + x] * cos_table[v][y]; + } + out[y * 8 + x] = 0.5 * sum; + } + } + out +} + +fn decode_baseline_jpeg(bytes: &[u8]) -> Option { + if bytes.len() < 4 || bytes[0..2] != [0xFF, 0xD8] { + return None; + } + let mut pos = 2usize; + let mut width = 0u32; + let mut height = 0u32; + let mut quant_tables = [[1u8; 64]; 4]; + let mut dc_tables: [Option; 4] = [None, None, None, None]; + let mut ac_tables: [Option; 4] = [None, None, None, None]; + let mut comp_q = [0usize, 1, 1]; + let mut comp_dc = [0usize, 1, 1]; + let mut comp_ac = [0usize, 1, 1]; + + while pos + 4 <= bytes.len() { + if bytes[pos] != 0xFF { + return None; + } + while pos < bytes.len() && bytes[pos] == 0xFF { + pos += 1; + } + if pos >= bytes.len() { + return None; + } + let marker = bytes[pos]; + pos += 1; + if marker == 0xD9 { + break; + } + if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) { + continue; + } + if pos + 2 > bytes.len() { + return None; + } + let seg_len = u16::from_be_bytes([bytes[pos], bytes[pos + 1]]) as usize; + if seg_len < 2 || pos + seg_len > bytes.len() { + return None; + } + let seg = &bytes[pos + 2..pos + seg_len]; + pos += seg_len; + + match marker { + 0xDB => { + let mut s = 0usize; + while s + 65 <= seg.len() { + let pq_tq = seg[s]; + if (pq_tq >> 4) != 0 { + return None; + } + let tq = (pq_tq & 0x0F) as usize; + if tq >= 4 { + return None; + } + quant_tables[tq].copy_from_slice(&seg[s + 1..s + 65]); + s += 65; + } + } + 0xC0 => { + if seg.len() < 15 || seg[0] != 8 || seg[5] != 3 { + return None; + } + height = u32::from(u16::from_be_bytes([seg[1], seg[2]])); + width = u32::from(u16::from_be_bytes([seg[3], seg[4]])); + for c in 0..3 { + let sampling = seg[6 + c * 3 + 1]; + if sampling != 0x11 { + return None; // non-4:4:4 handled by external fallback + } + comp_q[c] = (seg[6 + c * 3 + 2] & 0x03) as usize; + } + } + 0xC4 => { + let mut s = 0usize; + while s + 17 <= seg.len() { + let tc_th = seg[s]; + let tc = (tc_th >> 4) as usize; + let th = (tc_th & 0x0F) as usize; + if tc > 1 || th >= 4 { + return None; + } + let bits: [u8; 16] = seg[s + 1..s + 17].try_into().ok()?; + let total_vals: usize = bits.iter().map(|&b| b as usize).sum(); + s += 17; + if s + total_vals > seg.len() { + return None; + } + let vals = &seg[s..s + total_vals]; + s += total_vals; + let table = HuffDecodeTable::from_bits_vals(&bits, vals); + if tc == 0 { + dc_tables[th] = Some(table); + } else { + ac_tables[th] = Some(table); + } + } + } + 0xDA => { + if seg.len() < 10 || seg[0] != 3 || width == 0 || height == 0 { + return None; + } + for c in 0..3 { + let td_ta = seg[1 + c * 2 + 1]; + comp_dc[c] = ((td_ta >> 4) & 0x03) as usize; + comp_ac[c] = (td_ta & 0x03) as usize; + } + let mut br = JpegBitReader::new(&bytes[pos..]); + let w = width as usize; + let h = height as usize; + let mcu_cols = w.div_ceil(8); + let mcu_rows = h.div_ceil(8); + let mut bgra = vec![0u8; w * h * 4]; + let mut prev_dc = [0i32; 3]; + + for my in 0..mcu_rows { + for mx in 0..mcu_cols { + let mut planes = [[0.0f32; 64]; 3]; + for c in 0..3 { + let dc_tab = dc_tables[comp_dc[c]].as_ref()?; + let ac_tab = ac_tables[comp_ac[c]].as_ref()?; + let mut zz = [0i32; 64]; + let cat = br.decode_huff(dc_tab)?; + let diff_bits = br.read_bits(cat)?; + let dc_val = prev_dc[c] + extend_magnitude(diff_bits, cat); + prev_dc[c] = dc_val; + zz[0] = dc_val; + + let mut k = 1usize; + while k < 64 { + let rs = br.decode_huff(ac_tab)?; + if rs == 0x00 { + break; + } + if rs == 0xF0 { + k += 16; + continue; + } + let run = (rs >> 4) as usize; + let acat = rs & 0x0F; + k += run; + if k >= 64 { + return None; + } + let abits = br.read_bits(acat)?; + zz[k] = extend_magnitude(abits, acat); + k += 1; + } + planes[c] = idct_block(&zz, &quant_tables[comp_q[c]]); + } + for by in 0..8 { + let py = my * 8 + by; + if py >= h { + continue; + } + for bx in 0..8 { + let px = mx * 8 + bx; + if px >= w { + continue; + } + let bidx = by * 8 + bx; + let y_val = planes[0][bidx] + 128.0; + let cb_val = planes[1][bidx]; + let cr_val = planes[2][bidx]; + let r = (y_val + 1.402 * cr_val).round().clamp(0.0, 255.0) as u8; + let g = (y_val - 0.344_136 * cb_val - 0.714_136 * cr_val) + .round() + .clamp(0.0, 255.0) as u8; + let b = (y_val + 1.772 * cb_val).round().clamp(0.0, 255.0) as u8; + let dst = (py * w + px) * 4; + bgra[dst] = b; + bgra[dst + 1] = g; + bgra[dst + 2] = r; + bgra[dst + 3] = 255; + } + } + } + } + return Some(Bitmap { width, height, bgra }); + } + _ => {} + } + } None } -pub fn dibv5(_bitmap: &Bitmap) -> Vec { - Vec::new() + +fn decode_stored_zlib(zlib: &[u8]) -> Option> { + if zlib.len() < 6 { + return None; + } + let mut pos = 2; // skip 2-byte zlib header + let end = zlib.len() - 4; // exclude 4-byte adler32 + let mut out = Vec::new(); + loop { + if pos >= end { + return None; + } + let header = zlib[pos]; + pos += 1; + let bfinal = (header & 1) != 0; + let btype = (header >> 1) & 0b11; + if btype != 0 { + return None; // compressed block; fall back to external decoder + } + if pos + 4 > end { + return None; + } + let len = u16::from_le_bytes([zlib[pos], zlib[pos + 1]]) as usize; + let nlen = u16::from_le_bytes([zlib[pos + 2], zlib[pos + 3]]); + if (len as u16) != !nlen { + return None; + } + pos += 4; + if pos + len > end { + return None; + } + out.extend_from_slice(&zlib[pos..pos + len]); + pos += len; + if bfinal { + break; + } + } + Some(out) +} + +fn decode_stored_png(bytes: &[u8]) -> Option { + if bytes.len() < 8 || bytes[..8] != PNG_SIG { + return None; + } + let mut pos = 8; + let mut width = 0u32; + let mut height = 0u32; + let mut color_type = 0u8; + let mut idat = Vec::new(); + while pos + 8 <= bytes.len() { + let len = u32::from_be_bytes(bytes[pos..pos + 4].try_into().ok()?) as usize; + let ctype = &bytes[pos + 4..pos + 8]; + pos += 8; + if pos + len + 4 > bytes.len() { + return None; + } + let data = &bytes[pos..pos + len]; + pos += len + 4; // skip CRC + match ctype { + b"IHDR" if data.len() >= 13 => { + width = u32::from_be_bytes(data[0..4].try_into().ok()?); + height = u32::from_be_bytes(data[4..8].try_into().ok()?); + let bit_depth = data[8]; + color_type = data[9]; + if bit_depth != 8 || (color_type != 6 && color_type != 2) { + return None; + } + } + b"IDAT" => idat.extend_from_slice(data), + b"IEND" => break, + _ => {} + } + } + if width == 0 || height == 0 || idat.is_empty() { + return None; + } + let raw = decode_stored_zlib(&idat)?; + let bpp = if color_type == 6 { 4 } else { 3 }; + let stride = width as usize * bpp; + if raw.len() < (stride + 1) * height as usize { + return None; + } + let mut bgra = Vec::with_capacity(width as usize * height as usize * 4); + for row in 0..height as usize { + let row_start = row * (stride + 1); + if raw[row_start] != 0 { + return None; + } + let row_bytes = &raw[row_start + 1..row_start + 1 + stride]; + for px in row_bytes.chunks_exact(bpp) { + bgra.push(px[2]); // B + bgra.push(px[1]); // G + bgra.push(px[0]); // R + bgra.push(if bpp == 4 { px[3] } else { 255 }); + } + } + Some(Bitmap { width, height, bgra }) +} + +fn decode_bmp(bytes: &[u8]) -> Option { + if bytes.len() < 54 || &bytes[0..2] != b"BM" { + return None; + } + let pixel_offset = u32::from_le_bytes(bytes[10..14].try_into().ok()?) as usize; + let header_size = u32::from_le_bytes(bytes[14..18].try_into().ok()?) as usize; + if header_size < 40 || bytes.len() < 14 + header_size { + return None; + } + let width = i32::from_le_bytes(bytes[18..22].try_into().ok()?); + let height_raw = i32::from_le_bytes(bytes[22..26].try_into().ok()?); + let bpp = u16::from_le_bytes(bytes[28..30].try_into().ok()?); + if width <= 0 || height_raw == 0 || (bpp != 24 && bpp != 32) { + return None; + } + let top_down = height_raw < 0; + let width_u = width as u32; + let height_u = height_raw.unsigned_abs(); + let bytes_per_px = (bpp / 8) as usize; + let row_stride = (width_u as usize * bytes_per_px).div_ceil(4) * 4; + if pixel_offset + row_stride * height_u as usize > bytes.len() { + return None; + } + let mut bgra = vec![0u8; width_u as usize * height_u as usize * 4]; + for y in 0..height_u as usize { + let src_row = if top_down { y } else { height_u as usize - 1 - y }; + let src = &bytes[pixel_offset + src_row * row_stride..]; + let dst = &mut bgra[y * width_u as usize * 4..(y + 1) * width_u as usize * 4]; + for x in 0..width_u as usize { + let s = &src[x * bytes_per_px..]; + let d = &mut dst[x * 4..x * 4 + 4]; + d[0] = s[0]; + d[1] = s[1]; + d[2] = s[2]; + d[3] = if bytes_per_px == 4 { s[3] } else { 255 }; + } + } + Some(Bitmap { width: width_u, height: height_u, bgra }) +} + +fn decode_via_python_or_ffmpeg(bytes: &[u8]) -> Option { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut child = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-i", + "pipe:0", + "-frames:v", + "1", + "-f", + "image2pipe", + "-c:v", + "bmp", + "-pix_fmt", + "bgra", + "pipe:1", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + if let Some(mut stdin) = child.stdin.take() { + let input = bytes.to_vec(); + std::thread::spawn(move || { + let _ = stdin.write_all(&input); + }); + } + let output = child.wait_with_output().ok()?; + if output.status.success() { decode_bmp(&output.stdout) } else { None } +} + +pub fn decode(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(Error::new(ErrorKind::InvalidInput, "empty image")); + } + if let Some(bmp) = decode_stored_png(bytes) { + return Ok(bmp); + } + if let Some(bmp) = decode_bmp(bytes) { + return Ok(bmp); + } + if let Some(dib_bmp) = bmp_from_dib(bytes).and_then(|b| decode_bmp(&b)) { + return Ok(dib_bmp); + } + if let Some(jpg) = decode_baseline_jpeg(bytes) { + return Ok(jpg); + } + if let Some(bmp) = decode_via_python_or_ffmpeg(bytes) { + return Ok(bmp); + } + Err(Error::new(ErrorKind::InvalidData, "unsupported or corrupt image data")) +} + +/// Scales down (never up) so neither side exceeds `max`, averaging covered pixels. +pub fn scale_to(bitmap: &Bitmap, max: u32) -> Bitmap { + let longest = bitmap.width.max(bitmap.height); + if longest <= max || max == 0 || bitmap.width == 0 || bitmap.height == 0 { + return bitmap.clone(); + } + let width = ((u64::from(bitmap.width) * u64::from(max)) / u64::from(longest)).max(1) as u32; + let height = ((u64::from(bitmap.height) * u64::from(max)) / u64::from(longest)).max(1) as u32; + let mut bgra = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + let y0 = (u64::from(y) * u64::from(bitmap.height) / u64::from(height)) as u32; + let y1 = ((u64::from(y + 1) * u64::from(bitmap.height) / u64::from(height)) as u32) + .max(y0 + 1) + .min(bitmap.height); + for x in 0..width { + let x0 = (u64::from(x) * u64::from(bitmap.width) / u64::from(width)) as u32; + let x1 = ((u64::from(x + 1) * u64::from(bitmap.width) / u64::from(width)) as u32) + .max(x0 + 1) + .min(bitmap.width); + let mut sum = [0u32; 4]; + for sy in y0..y1 { + for sx in x0..x1 { + let i = ((sy * bitmap.width + sx) * 4) as usize; + for (total, &val) in sum.iter_mut().zip(&bitmap.bgra[i..i + 4]) { + *total += u32::from(val); + } + } + } + let n = ((y1 - y0) * (x1 - x0)).max(1); + bgra.extend(sum.map(|s| (s / n) as u8)); + } + } + Bitmap { width, height, bgra } +} + +/// Crops the center square of `bitmap`. +pub fn crop_square(bitmap: &Bitmap) -> Bitmap { + let side = bitmap.width.min(bitmap.height); + if side == 0 || (bitmap.width == bitmap.height) { + return bitmap.clone(); + } + let x_off = (bitmap.width - side) / 2; + let y_off = (bitmap.height - side) / 2; + let mut bgra = Vec::with_capacity((side * side * 4) as usize); + for y in 0..side { + let src_row = (y_off + y) as usize * bitmap.width as usize; + let start = (src_row + x_off as usize) * 4; + let end = start + (side as usize * 4); + bgra.extend_from_slice(&bitmap.bgra[start..end]); + } + Bitmap { width: side, height: side, bgra } +} + +/// Decodes an image, center-crops it to a square, scales to at most `max_side`, +/// and encodes it as a baseline JPEG. +pub fn crop_square_jpeg(bytes: &[u8], max_side: u32, quality: u8) -> Result> { + let decoded = decode(bytes)?; + let cropped = crop_square(&decoded); + let scaled = if max_side > 0 { scale_to(&cropped, max_side) } else { cropped }; + encode_jpeg(&scaled, quality) +} + +pub fn bmp_from_dib(dib: &[u8]) -> Option> { + if dib.len() < 40 { + return None; + } + let header_size = u32::from_le_bytes(dib[0..4].try_into().ok()?); + if header_size < 40 || (header_size as usize) > dib.len() { + return None; + } + let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?); + let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?); + let colors_used = u32::from_le_bytes(dib[32..36].try_into().ok()?); + + let palette_entries = if colors_used != 0 { + colors_used + } else if bit_count <= 8 { + 1u32 << bit_count + } else { + 0 + }; + let masks_bytes: u32 = if compression == BI_BITFIELDS && header_size == 40 { + if bit_count == 32 { 16 } else { 12 } + } else { + 0 + }; + let pixel_offset = (BITMAPFILEHEADER_SIZE as u32) + .checked_add(header_size)? + .checked_add(palette_entries.checked_mul(4)?)? + .checked_add(masks_bytes)?; + let total_size = (BITMAPFILEHEADER_SIZE as u32).checked_add(u32::try_from(dib.len()).ok()?)?; + + let mut bmp = Vec::with_capacity(BITMAPFILEHEADER_SIZE + dib.len()); + bmp.extend_from_slice(b"BM"); + bmp.extend_from_slice(&total_size.to_le_bytes()); + bmp.extend_from_slice(&0u32.to_le_bytes()); + bmp.extend_from_slice(&pixel_offset.to_le_bytes()); + bmp.extend_from_slice(dib); + Some(bmp) +} + +pub fn dibv5(bitmap: &Bitmap) -> Vec { + let row_bytes = (bitmap.width as usize) * 4; + let image_bytes = row_bytes * (bitmap.height as usize); + let mut out = Vec::with_capacity((BITMAPV5HEADER_SIZE as usize) + image_bytes); + + let neg_height = -i32::try_from(bitmap.height).unwrap_or(i32::MAX); + out.extend_from_slice(&BITMAPV5HEADER_SIZE.to_le_bytes()); + out.extend_from_slice(&(bitmap.width as i32).to_le_bytes()); + out.extend_from_slice(&neg_height.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&32u16.to_le_bytes()); + out.extend_from_slice(&BI_BITFIELDS.to_le_bytes()); + out.extend_from_slice(&(image_bytes as u32).to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0x00FF_0000u32.to_le_bytes()); + out.extend_from_slice(&0x0000_FF00u32.to_le_bytes()); + out.extend_from_slice(&0x0000_00FFu32.to_le_bytes()); + out.extend_from_slice(&0xFF00_0000u32.to_le_bytes()); + out.extend_from_slice(&LCS_SRGB.to_le_bytes()); + out.extend_from_slice(&[0u8; 36]); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&4u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + debug_assert_eq!(out.len(), BITMAPV5HEADER_SIZE as usize); + + let _ = BI_RGB; + out.extend_from_slice(&bitmap.bgra[..image_bytes]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_bitmap(width: u32, height: u32) -> Bitmap { + let mut bgra = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + bgra.push((x * 17) as u8); + bgra.push((y * 31) as u8); + bgra.push(((x + y) * 13) as u8); + bgra.push(0xFF); + } + } + Bitmap { width, height, bgra } + } + + #[test] + fn png_round_trip_preserves_dimensions_and_pixels() { + let original = sample_bitmap(16, 12); + let png = encode_png(&original).expect("encode_png"); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + + let decoded = decode(&png).expect("decode png"); + assert_eq!(decoded, original); + } + + #[test] + fn jpeg_encode_and_decode_round_trips_solid_color() { + let mut bgra = Vec::with_capacity(16 * 16 * 4); + for _ in 0..(16 * 16) { + bgra.extend_from_slice(&[40, 120, 220, 255]); // B=40, G=120, R=220 + } + let original = Bitmap { width: 16, height: 16, bgra }; + let jpeg = encode_jpeg(&original, 90).expect("encode_jpeg"); + assert!(jpeg.starts_with(&[0xFF, 0xD8, 0xFF])); + assert!(jpeg.ends_with(&[0xFF, 0xD9])); + + let decoded = decode(&jpeg).expect("decode jpeg"); + assert_eq!(decoded.width, 16); + assert_eq!(decoded.height, 16); + assert!(decoded.bgra[0].abs_diff(40) <= 8); + assert!(decoded.bgra[1].abs_diff(120) <= 8); + assert!(decoded.bgra[2].abs_diff(220) <= 8); + assert_eq!(decoded.bgra[3], 255); + } + + #[test] + fn crop_square_jpeg_crops_and_scales_rectangular_image() { + let rect = sample_bitmap(80, 40); + let png = encode_png(&rect).expect("encode_png"); + let square_jpg = crop_square_jpeg(&png, 24, 85).expect("crop_square_jpeg"); + assert!(square_jpg.starts_with(&[0xFF, 0xD8, 0xFF])); + + let decoded = decode(&square_jpg).expect("decode cropped jpeg"); + assert_eq!(decoded.width, 24); + assert_eq!(decoded.height, 24); + } + + #[test] + fn dibv5_through_bmp_round_trips_pixels() { + let original = sample_bitmap(8, 6); + let dib = dibv5(&original); + let bmp = bmp_from_dib(&dib).expect("bmp_from_dib"); + let decoded = decode(&bmp).expect("decode bmp"); + assert_eq!(decoded, original); + } + + #[test] + fn scale_to_shrinks_oversized_and_keeps_small() { + let small = sample_bitmap(32, 24); + assert_eq!(scale_to(&small, 64), small); + + let large = sample_bitmap(200, 100); + let scaled = scale_to(&large, 50); + assert_eq!(scaled.width, 50); + assert_eq!(scaled.height, 25); + } + + #[test] + fn decode_rejects_empty_and_garbage_input() { + assert!(decode(&[]).is_err()); + assert!(decode(b"not an image").is_err()); + assert!(bmp_from_dib(&[0u8; 12]).is_none()); + } + + #[test] + fn decode_handles_420_subsampled_camera_jpeg() { + let out = std::process::Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "color=c=0x2878dc:s=32x24:d=1", + "-frames:v", + "1", + "-f", + "image2pipe", + "-c:v", + "mjpeg", + "-pix_fmt", + "yuvj420p", + "pipe:1", + ]) + .output(); + let Ok(out) = out else { return }; + if !out.status.success() || out.stdout.is_empty() { + return; + } + let decoded = decode(&out.stdout).expect("decode 4:2:0 camera jpeg"); + assert_eq!(decoded.width, 32); + assert_eq!(decoded.height, 24); + assert_eq!(decoded.bgra[3], 255); + } } diff --git a/desktop/app/src/linux/jumplist.rs b/desktop/app/src/linux/jumplist.rs index 18aae30f..4a02a2d0 100644 --- a/desktop/app/src/linux/jumplist.rs +++ b/desktop/app/src/linux/jumplist.rs @@ -1,9 +1,204 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +// Linux equivalent of Jump List tasks via freedesktop.org `.desktop` Desktop Actions +// (`~/.local/share/applications/nectarlink.desktop` + hicolor app icon). +// +// Desktop environments (GNOME Dash, KDE Plasma Task Manager, Cinnamon, XFCE) expose +// `[Desktop Action ]` entries in the launcher right-click menu, providing full +// parity with Windows taskbar Jump List tasks. -use std::path::Path; +#![allow(dead_code)] +use std::path::{Path, PathBuf}; + +/// The command-line flag Jump List tasks pass back to NectarLink. pub const ARG: &str = "--task"; -pub fn register(_custom_data_dir: Option<&Path>) {} -pub fn unregister() {} +/// Jump-list tasks exposed on the launcher / dock right-click menu. +/// Kept in parity with the Windows Jump List (`win/jumplist.rs`) and +/// `bridge::app::NectarLinkApp::dispatch_jump_task`. +pub const TASKS: &[(&str, &str, &str)] = &[ + ("send-files", "Send files to phone", "Open the file picker to send files to your phone"), + ("send-clipboard", "Send clipboard to phone", "Send what's currently copied on this PC to your phone"), + ("find-phone", "Find my phone", "Make your paired phone ring"), + ("take-photo", "Take photo with phone", "Capture a photo with your phone camera and paste it here"), + ("open-timeline", "Open Timeline", "Open the chronological activity feed in NectarLink"), +]; + +fn applications_dir() -> Option { + let base = dirs::data_local_dir().filter(|p| p.is_absolute())?; + Some(base.join("applications")) +} + +fn hicolor_icon_path() -> Option { + let base = dirs::data_local_dir().filter(|p| p.is_absolute())?; + Some(base.join("icons/hicolor/256x256/apps/nectarlink.png")) +} + +/// Register the application `.desktop` entry and 256x256 PNG icon in the user's XDG data dir. +/// +/// Only installs into the user's real `~/.local/share/applications` when running as the +/// primary profile (`custom_data_dir.is_none()`), matching Windows Jump List registration. +pub fn register(custom_data_dir: Option<&Path>) { + if custom_data_dir.is_some() { + return; + } + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(apps_dir) = applications_dir() else { + return; + }; + let icon_path = hicolor_icon_path().unwrap_or_else(|| PathBuf::from("nectarlink")); + if icon_path.is_absolute() && !icon_path.exists() { + let _ = super::icon::write_png(&icon_path, 128); + } + if let Err(e) = write_desktop_file_to(&apps_dir, &exe, &icon_path, custom_data_dir) { + tracing::debug!(error = %e, "couldn't update the desktop launcher actions"); + } +} + +/// Write `nectarlink.desktop` into `dir` pointing at `exe` and `icon_path`. +/// Extracted so unit tests can verify `.desktop` generation inside a temp directory. +pub fn write_desktop_file_to( + dir: &Path, + exe: &Path, + icon_path: &Path, + custom_data_dir: Option<&Path>, +) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let desktop_path = dir.join("nectarlink.desktop"); + let exe_str: String = exe + .to_string_lossy() + .chars() + .filter(|&c| c != '\n' && c != '\r' && c != '"' && !c.is_control()) + .collect(); + let icon_str: String = + icon_path.to_string_lossy().chars().filter(|&c| c != '\n' && c != '\r' && !c.is_control()).collect(); + + let actions_list: Vec<&str> = TASKS.iter().map(|(id, _, _)| *id).collect(); + let mut content = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Version=1.1\n\ + Name=NectarLink\n\ + GenericName=Phone Companion\n\ + Comment=Connect your Android phone and Linux PC\n\ + Exec=\"{exe_str}\" %U\n\ + Icon={icon_str}\n\ + Terminal=false\n\ + Categories=Utility;FileTransfer;\n\ + MimeType=x-scheme-handler/nectarlink;\n\ + StartupWMClass=NectarLink\n\ + Actions={};\n", + actions_list.join(";") + ); + + for &(action, title, _comment) in TASKS { + let args = build_task_arguments(action, custom_data_dir); + content.push_str(&format!( + "\n[Desktop Action {action}]\n\ + Name={title}\n\ + Exec=\"{exe_str}\" {args}\n" + )); + } + + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .custom_flags(libc::O_NOFOLLOW) + .mode(0o644) + .open(&desktop_path)?; + file.write_all(content.as_bytes())?; + } + #[cfg(not(unix))] + { + std::fs::write(&desktop_path, content)?; + } + Ok(desktop_path) +} + +/// Remove the user-level `.desktop` file and icon installed by [`register`]. +pub fn unregister() { + if let Some(dir) = applications_dir() { + let _ = std::fs::remove_file(dir.join("nectarlink.desktop")); + } + if let Some(icon) = hicolor_icon_path() { + let _ = std::fs::remove_file(icon); + } +} + +/// Build the command-line argument string for a jump-list task. +/// Kept public so unit tests can verify `--data-dir` propagation without +/// touching the desktop environment. +pub fn build_task_arguments(action: &str, custom_data_dir: Option<&Path>) -> String { + match custom_data_dir { + Some(dir) => { + let clean: String = dir + .to_string_lossy() + .chars() + .filter(|&c| c != '\n' && c != '\r' && c != '"' && !c.is_control()) + .collect(); + format!("--data-dir \"{clean}\" {ARG} {action}") + } + None => format!("{ARG} {action}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_profile_omits_data_dir() { + assert_eq!(build_task_arguments("send-files", None), "--task send-files"); + } + + #[test] + fn custom_profile_quotes_data_dir() { + let p = Path::new("/home/Alice Smith/.local/share/NectarLink Dev"); + let args = build_task_arguments("find-phone", Some(p)); + assert_eq!(args, "--data-dir \"/home/Alice Smith/.local/share/NectarLink Dev\" --task find-phone"); + } + + #[test] + fn all_defined_tasks_have_non_empty_metadata() { + assert_eq!(TASKS.len(), 5); + let ids: Vec<&str> = TASKS.iter().map(|(id, _, _)| *id).collect(); + assert_eq!(ids, vec!["send-files", "send-clipboard", "find-phone", "take-photo", "open-timeline"]); + for &(action, title, tip) in TASKS { + assert!(!action.is_empty()); + assert!(!title.is_empty()); + assert!(!tip.is_empty()); + } + } + + #[test] + fn write_desktop_file_generates_valid_actions_and_sections() { + let tmp = tempfile::tempdir().expect("tempdir"); + let exe = Path::new("/usr/bin/nectarlink-desktop"); + let icon = Path::new("/home/user/.local/share/icons/hicolor/256x256/apps/nectarlink.png"); + + let desktop_path = write_desktop_file_to(tmp.path(), exe, icon, None).expect("write desktop file"); + assert!(desktop_path.exists()); + + let text = std::fs::read_to_string(&desktop_path).expect("read desktop file"); + assert!(text.contains("[Desktop Entry]")); + assert!(text.contains("MimeType=x-scheme-handler/nectarlink;")); + assert!(text.contains("Actions=send-files;send-clipboard;find-phone;take-photo;open-timeline;")); + assert!(text.contains(&format!("Icon={}", icon.display()))); + + for &(action, title, _) in TASKS { + assert!(text.contains(&format!("[Desktop Action {action}]")), "missing section for {action}"); + assert!(text.contains(&format!("Name={title}")), "missing title for {action}"); + assert!( + text.contains(&format!("Exec=\"/usr/bin/nectarlink-desktop\" --task {action}")), + "missing Exec for {action}" + ); + } + } +} diff --git a/desktop/app/src/linux/share_target.rs b/desktop/app/src/linux/share_target.rs index f1351ed3..96eaef0a 100644 --- a/desktop/app/src/linux/share_target.rs +++ b/desktop/app/src/linux/share_target.rs @@ -1,12 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +// Linux Share Target / freedesktop MIME & URI-scheme handler (`nectarlink://`, `file://`, URLs). + +#![allow(dead_code)] use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::UnboundedSender; +/// Command-line flag to share files, URLs, or text with a paired phone. pub const ARG: &str = "--share"; -#[derive(Debug, Clone, Default, PartialEq, Eq)] +/// Payload extracted from a Share target activation or `--share` CLI invocation. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SharedPayload { pub files: Vec, pub url: Option, @@ -19,32 +25,191 @@ impl SharedPayload { } } +/// Register the Linux share target listener. +/// +/// On Linux, `.desktop` / `MimeType=x-scheme-handler/nectarlink;` invocations launch +/// a new process whose arguments are parsed via [`parse_targets`] and forwarded over +/// the single-instance Unix domain socket (`single_instance.rs`) or persisted to +/// `activated-share.json` and consumed by [`take_activated_share`]. pub fn register_listener(_tx: UnboundedSender) -> Result<(), String> { Ok(()) } -pub fn take_activated_share(_data_dir: &Path) -> Option { - None + +/// Consume a pending `activated-share.json` drop file inside `data_dir` if present. +pub fn take_activated_share(data_dir: &Path) -> Option { + let path = data_dir.join("activated-share.json"); + let bytes = std::fs::read(&path).ok()?; + let _ = std::fs::remove_file(&path); + let payload: SharedPayload = serde_json::from_slice(&bytes).ok()?; + if payload.is_empty() { None } else { Some(payload) } } + +/// Parse command-line targets (`%U` / `%F` from `.desktop` Exec lines, `file://` URIs, +/// `http(s)://` / `nectarlink://` URLs, local paths, or plain text) into a [`SharedPayload`]. pub fn parse_targets(targets: &[String]) -> SharedPayload { let mut payload = SharedPayload::default(); + let mut text_parts = Vec::new(); + for raw in targets { let trimmed = raw.trim(); if trimmed.is_empty() { continue; } - let lower = trimmed.to_ascii_lowercase(); - if lower.starts_with("http://") - || lower.starts_with("https://") - || lower.starts_with("mailto:") - || lower.starts_with("tel:") - || lower.starts_with("nectarlink:") - { + if let Some(file_path) = parse_file_uri(trimmed) { + payload.files.push(file_path); + } else if is_url_target(trimmed) { if payload.url.is_none() { - payload.url = Some(trimmed.to_owned()); + payload.url = Some(trimmed.to_string()); + } else { + text_parts.push(trimmed.to_string()); } } else { - payload.files.push(PathBuf::from(trimmed)); + let expanded = expand_tilde(trimmed); + if expanded.exists() || looks_like_local_path(trimmed) { + payload.files.push(expanded); + } else { + text_parts.push(trimmed.to_string()); + } } } + + if !text_parts.is_empty() { + payload.text = Some(text_parts.join(" ")); + } + payload } + +fn parse_file_uri(s: &str) -> Option { + let rest = s.strip_prefix("file://")?; + let path_part = if let Some(after_localhost) = rest.strip_prefix("localhost/") { + format!("/{after_localhost}") + } else if rest.starts_with('/') { + rest.to_string() + } else { + format!("/{rest}") + }; + Some(PathBuf::from(percent_decode(&path_part))) +} + +fn is_url_target(s: &str) -> bool { + if s.is_empty() || s.len() > 4096 || s.chars().any(char::is_whitespace) { + return false; + } + let lower = s.to_ascii_lowercase(); + lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("mailto:") + || lower.starts_with("tel:") + || lower.starts_with("nectarlink:") + || (lower.starts_with("geo:") && s.len() > 4) +} + +fn looks_like_local_path(s: &str) -> bool { + let bytes = s.as_bytes(); + s.contains('/') + || s.contains('\\') + || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') +} + +fn expand_tilde(s: &str) -> PathBuf { + if let Some(rest) = s.strip_prefix("~/") + && let Some(home) = std::env::var_os("HOME") + { + return PathBuf::from(home).join(rest); + } + PathBuf::from(s) +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let (Some(h), Some(l)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) + { + out.push((h << 4) | l); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn from_hex(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_targets_handles_file_uris_with_percent_encoding() { + let targets = vec![ + "file:///home/alice/My%20Documents/report%20final.pdf".to_string(), + "file://localhost/tmp/photo%2B1.png".to_string(), + ]; + let payload = parse_targets(&targets); + assert_eq!( + payload.files, + vec![ + PathBuf::from("/home/alice/My Documents/report final.pdf"), + PathBuf::from("/tmp/photo+1.png"), + ] + ); + assert!(payload.url.is_none()); + assert!(payload.text.is_none()); + } + + #[test] + fn parse_targets_handles_urls_and_plain_text() { + let targets = + vec!["https://example.com/article?id=42".to_string(), "Check out this article".to_string()]; + let payload = parse_targets(&targets); + assert!(payload.files.is_empty()); + assert_eq!(payload.url.as_deref(), Some("https://example.com/article?id=42")); + assert_eq!(payload.text.as_deref(), Some("Check out this article")); + } + + #[test] + fn parse_targets_handles_nectarlink_scheme_and_local_paths() { + let targets = + vec!["nectarlink://share?text=hello".to_string(), "/var/tmp/some-archive.tar.gz".to_string()]; + let payload = parse_targets(&targets); + assert_eq!(payload.url.as_deref(), Some("nectarlink://share?text=hello")); + assert_eq!(payload.files, vec![PathBuf::from("/var/tmp/some-archive.tar.gz")]); + } + + #[test] + fn parse_targets_returns_empty_on_empty_or_whitespace() { + assert!(parse_targets(&[]).is_empty()); + assert!(parse_targets(&[" ".to_string()]).is_empty()); + } + + #[test] + fn take_activated_share_reads_and_deletes_json_file() { + let tmp = tempfile::tempdir().expect("tempdir"); + let file_path = tmp.path().join("activated-share.json"); + let expected = SharedPayload { + files: vec![PathBuf::from("/tmp/shared.txt")], + url: Some("https://nectarlink.dev".to_string()), + text: Some("Hello from share".to_string()), + }; + std::fs::write(&file_path, serde_json::to_vec(&expected).unwrap()).unwrap(); + + let loaded = take_activated_share(tmp.path()).expect("should load activated share"); + assert_eq!(loaded, expected); + assert!(!file_path.exists(), "activated-share.json should be removed"); + assert!(take_activated_share(tmp.path()).is_none()); + } +} diff --git a/desktop/app/src/linux/shell.rs b/desktop/app/src/linux/shell.rs index 8d09c4fe..1234ff88 100644 --- a/desktop/app/src/linux/shell.rs +++ b/desktop/app/src/linux/shell.rs @@ -1,56 +1,439 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Opening URLs, files, folders, and power actions (lock, sleep) on Linux via +//! `xdg-open`, `dbus-send`, `loginctl`, and `systemctl`. -use std::path::Path; +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; -pub fn open_url(_url: &str) -> Result<(), String> { - Ok(()) +fn spawn_detached(program: &str, args: &[&str]) -> Result<(), String> { + Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|e| format!("{program}: {e}")) } -pub fn open_mailto(_address: &str) -> Result<(), String> { - Ok(()) + +/// Opens an `http://` or `https://` URL (or local settings URI) with `xdg-open`. +pub fn open_url(url: &str) -> Result<(), String> { + let lower = url.get(..8).map(str::to_ascii_lowercase).unwrap_or_default(); + if lower.starts_with("http://") || lower.starts_with("https://") { + return spawn_detached("xdg-open", &[url]); + } + // Map Windows settings deep-links used by Doctor / Bluetooth UI to desktop equivalents or no-op. + if url.starts_with("ms-settings:") || url.eq_ignore_ascii_case("firewall.cpl") { + return spawn_detached("xdg-open", &[url]).or(Ok(())); + } + Err("only http and https links can be opened".into()) } -pub fn open_maps(_query: &str) -> Result<(), String> { - Ok(()) + +/// Opens the default mail client for an email address (`mailto:`). +pub fn open_mailto(email: &str) -> Result<(), String> { + let trimmed = email.trim(); + if trimmed.is_empty() || !trimmed.contains('@') || trimmed.chars().any(char::is_whitespace) { + return Err("invalid email address".into()); + } + let uri = if trimmed.get(..7).is_some_and(|p| p.eq_ignore_ascii_case("mailto:")) { + trimmed.to_owned() + } else { + format!("mailto:{trimmed}") + }; + spawn_detached("xdg-open", &[&uri]) } -pub fn is_url_scheme_registered(_scheme: &str) -> bool { - false + +/// Opens a street address in OpenStreetMap in the default browser. +pub fn open_maps(address: &str) -> Result<(), String> { + let trimmed = address.trim(); + if trimmed.is_empty() { + return Err("empty address".into()); + } + open_url(&nectarlink_core::maps_web_url(trimmed)) } -pub fn open_file(_path: &Path) -> Result<(), String> { - Ok(()) + +/// Checks whether a custom URI scheme has a registered handler via `xdg-mime`. +pub fn is_url_scheme_registered(scheme: &str) -> bool { + let scheme = scheme.trim().trim_end_matches(':'); + if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return false; + } + let mime = format!("x-scheme-handler/{scheme}"); + Command::new("xdg-mime") + .args(["query", "default", &mime]) + .output() + .ok() + .filter(|o| o.status.success()) + .is_some_and(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty()) } -pub fn open_folder(_path: &Path) -> Result<(), String> { - Ok(()) + +/// Opens a file with the user's default application (`xdg-open`). +pub fn open_file(path: &Path) -> Result<(), String> { + spawn_detached("xdg-open", &[&path.to_string_lossy()]) } -pub fn select_in_explorer(_path: &Path) -> Result<(), String> { - Ok(()) + +/// Opens a directory in the file manager (`xdg-open`). +pub fn open_folder(path: &Path) -> Result<(), String> { + spawn_detached("xdg-open", &[&path.to_string_lossy()]) } -pub fn run(_program: &Path, _arguments: &str) -> Result<(), String> { - Ok(()) + +/// Reveals and selects `path` in the file manager via `org.freedesktop.FileManager1.ShowItems`, +/// falling back to opening the parent directory with `xdg-open`. +pub fn select_in_explorer(path: &Path) -> Result<(), String> { + let abs = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let file_uri = format!("file://{}", abs.to_string_lossy()); + let array_arg = format!("array:string:{file_uri}"); + let dbus_ok = Command::new("dbus-send") + .args([ + "--session", + "--dest=org.freedesktop.FileManager1", + "--type=method_call", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + &array_arg, + "string:", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + if dbus_ok { + return Ok(()); + } + let folder = abs.parent().unwrap_or(&abs); + open_folder(folder) +} + +/// Runs `program` with `arguments`. Special-cases Windows `"explorer.exe"` calls from shared UI code. +pub fn run(program: &Path, arguments: &str) -> Result<(), String> { + let prog_str = program.to_string_lossy(); + if prog_str.eq_ignore_ascii_case("explorer.exe") || prog_str.eq_ignore_ascii_case("explorer") { + let trimmed = arguments.trim(); + if let Some(rest) = trimmed.strip_prefix("/select,") { + let target = rest.trim().trim_matches('"'); + return select_in_explorer(Path::new(target)); + } + let target = trimmed.trim_matches('"'); + return open_folder(Path::new(target)); + } + + let args = split_args(arguments); + Command::new(program) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn split_args(arguments: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + for ch in arguments.chars() { + match ch { + '"' => in_quotes = !in_quotes, + c if c.is_whitespace() && !in_quotes => { + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + } + } + c => current.push(c), + } + } + if !current.is_empty() { + out.push(current); + } + out } + +/// Locks the current user session (`loginctl lock-session` or `xdg-screensaver lock`). pub fn lock() -> Result<(), String> { - Ok(()) + if Command::new("loginctl") + .arg("lock-session") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + return Ok(()); + } + spawn_detached("xdg-screensaver", &["lock"]) } + +/// Alias for [`lock`]. pub fn lock_workstation() -> Result<(), String> { - Ok(()) + lock() } + +/// Suspends the system (`systemctl suspend`). pub fn sleep() -> Result<(), String> { - Ok(()) + spawn_detached("systemctl", &["suspend"]) } + +/// Alias for [`sleep`]. pub fn sleep_system() -> Result<(), String> { - Ok(()) + sleep() } + +/// Launches an interactive or full-screen screenshot tool on Linux, falling back to `Print`. pub fn take_screenshot() -> Result<(), String> { + let candidates: &[(&str, &[&str])] = &[ + ("spectacle", &[]), + ("gnome-screenshot", &["-i"]), + ("flameshot", &["gui"]), + ("xfce4-screenshooter", &["-r"]), + ]; + for (prog, args) in candidates { + if spawn_detached(prog, args).is_ok() { + return Ok(()); + } + } + super::input::press_key("print", &[]); Ok(()) } -pub fn launch_app(_path: &Path) -> Result<(), String> { - Ok(()) + +/// Parses the `[Desktop Entry]` `Exec=` command from a `.desktop` file and strips +/// freedesktop field codes (`%f`, `%F`, `%u`, `%U`, `%i`, `%c`, `%k`, etc.). +pub(crate) fn parse_desktop_exec(contents: &str) -> Option<(String, Vec)> { + let mut in_desktop_entry = false; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + in_desktop_entry = line == "[Desktop Entry]"; + continue; + } + if !in_desktop_entry { + continue; + } + if let Some(rest) = line.strip_prefix("Exec=") { + let tokens = split_args(rest.trim()); + let mut filtered: Vec = Vec::new(); + for tok in tokens { + if tok.starts_with('%') && tok.len() == 2 && tok != "%%" { + continue; + } + let cleaned = tok.replace("%%", "%"); + if !cleaned.is_empty() { + filtered.push(cleaned); + } + } + let mut iter = filtered.into_iter(); + let program = iter.next()?; + let args: Vec = iter.collect(); + return Some((program, args)); + } + } + None } -pub fn launch_executable(_path: &Path, _args: &[&str]) -> Result<(), String> { - Ok(()) + +/// Searches standard XDG application directories (`~/.local/share/applications`, +/// `/usr/local/share/applications`, `/usr/share/applications`, `/var/lib/flatpak/exports/share/applications`) +/// for a `.desktop` file by filename. +fn find_desktop_file(desktop_name: &str) -> Option { + let mut dirs_to_check = Vec::new(); + if let Some(data_local) = dirs::data_local_dir() { + dirs_to_check.push(data_local.join("applications")); + } + dirs_to_check.push(PathBuf::from("/usr/local/share/applications")); + dirs_to_check.push(PathBuf::from("/usr/share/applications")); + dirs_to_check.push(PathBuf::from("/var/lib/flatpak/exports/share/applications")); + for dir in dirs_to_check { + let candidate = dir.join(desktop_name); + if candidate.is_file() { + return Some(candidate); + } + } + None } -pub fn run_command(_command: &str) -> Result<(), String> { - Ok(()) + +/// Launches an application, `.desktop` launcher, `.AppImage`, or executable binary. +pub fn launch_app(path: &Path) -> Result<(), String> { + let raw = path.to_string_lossy(); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("empty application path".into()); + } + + let lower = trimmed.to_ascii_lowercase(); + if lower.ends_with(".desktop") { + // Resolve the .desktop file path if it exists directly or in XDG applications directories. + let resolved = if path.is_file() { Some(path.to_path_buf()) } else { find_desktop_file(trimmed) }; + + if let Some(ref desktop_path) = resolved { + // 1. Try `gio launch ` + if Command::new("gio") + .args(["launch", &desktop_path.to_string_lossy()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .is_ok() + { + return Ok(()); + } + + // 2. Parse `[Desktop Entry]` `Exec=` directly and spawn the target binary. + if let Ok(contents) = fs::read_to_string(desktop_path) + && let Some((prog, args)) = parse_desktop_exec(&contents) + { + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + if spawn_detached(&prog, &arg_refs).is_ok() { + return Ok(()); + } + } + } + + // 3. Try `gtk-launch ` using the file stem / filename. + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) + && spawn_detached("gtk-launch", &[file_name]).is_ok() + { + return Ok(()); + } + return Err(format!("could not launch desktop entry {trimmed}")); + } + + if path.is_file() + && let Ok(child) = + Command::new(path).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() + { + drop(child); + return Ok(()); + } + + open_file(path) +} + +/// Alias for [`launch_app`]. +pub fn launch_executable(path: &Path, args: &[&str]) -> Result<(), String> { + Command::new(path) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) } + +/// Runs a shell command (`sh -c `) detached in the background. +pub fn run_command(command: &str) -> Result<(), String> { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err("empty command".into()); + } + spawn_detached("sh", &["-c", trimmed]) +} + +/// Moves `path` to the FreeDesktop Trash (`~/.local/share/Trash/files/` + `.trashinfo`), +/// or falls back to `gio trash`. pub fn recycle_file(path: &Path) -> Result<(), String> { - std::fs::remove_file(path).map_err(|e| e.to_string()) + let data_dir = dirs::data_local_dir().ok_or_else(|| "no XDG_DATA_HOME directory".to_owned())?; + recycle_file_in(&data_dir.join("Trash"), path) +} + +fn recycle_file_in(trash_dir: &Path, path: &Path) -> Result<(), String> { + if !path.exists() { + return Err(format!("{} does not exist", path.display())); + } + let abs = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let files_dir = trash_dir.join("files"); + let info_dir = trash_dir.join("info"); + fs::create_dir_all(&files_dir).map_err(|e| e.to_string())?; + fs::create_dir_all(&info_dir).map_err(|e| e.to_string())?; + + let base_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("trashed_item"); + let mut chosen_name = base_name.to_owned(); + let mut counter = 2u32; + while files_dir.join(&chosen_name).exists() || info_dir.join(format!("{chosen_name}.trashinfo")).exists() + { + chosen_name = format!("{base_name}.{counter}"); + counter += 1; + } + + let dest_file: PathBuf = files_dir.join(&chosen_name); + let dest_info: PathBuf = info_dir.join(format!("{chosen_name}.trashinfo")); + let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let info_contents = + format!("[Trash Info]\nPath={}\nDeletionDate=1970-01-01T00:00:{:02}\n", abs.display(), secs % 60); + fs::write(&dest_info, info_contents).map_err(|e| e.to_string())?; + if let Err(e) = fs::rename(path, &dest_file) { + let _ = fs::remove_file(&dest_info); + return Err(e.to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuses_non_web_urls() { + assert!(open_url("file:///etc/passwd").is_err()); + assert!(open_url("javascript:alert(1)").is_err()); + } + + #[test] + fn validates_mailto_and_maps() { + assert!(open_mailto("").is_err()); + assert!(open_mailto("not-an-email").is_err()); + assert!(open_maps(" ").is_err()); + } + + #[test] + fn moves_file_to_freedesktop_trash() { + let dir = tempfile::tempdir().unwrap(); + let trash = dir.path().join("Trash"); + let file = dir.path().join("note.txt"); + fs::write(&file, b"hello").unwrap(); + + recycle_file_in(&trash, &file).unwrap(); + assert!(!file.exists()); + assert!(trash.join("files").join("note.txt").is_file()); + assert!(trash.join("info").join("note.txt.trashinfo").is_file()); + } + + #[test] + fn parses_desktop_entry_exec_and_strips_field_codes() { + let desktop = "\ +[Desktop Entry] +Type=Application +Name=Firefox Web Browser +Exec=/usr/lib/firefox/firefox --new-window %u +Icon=firefox + +[Desktop Action new-private-window] +Name=Open a New Private Window +Exec=/usr/lib/firefox/firefox --private-window %u +"; + let (prog, args) = parse_desktop_exec(desktop).expect("should parse Exec="); + assert_eq!(prog, "/usr/lib/firefox/firefox"); + assert_eq!(args, vec!["--new-window"]); + } + + #[test] + fn launch_app_executes_desktop_file() { + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("launched.txt"); + let desktop_path = dir.path().join("test-app.desktop"); + let desktop_contents = format!( + "[Desktop Entry]\nType=Application\nName=TestApp\nExec=/bin/sh -c \"echo ok > '{}'\" %U\n", + marker.display() + ); + fs::write(&desktop_path, desktop_contents).unwrap(); + assert!(launch_app(&desktop_path).is_ok()); + } } diff --git a/desktop/app/src/linux/shortcut.rs b/desktop/app/src/linux/shortcut.rs index 35762b32..2cefc4c7 100644 --- a/desktop/app/src/linux/shortcut.rs +++ b/desktop/app/src/linux/shortcut.rs @@ -1,41 +1,423 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +// Linux file-manager "Send to" integration and `.lnk`-compatible shortcut metadata. +// +// `crate::send_to` reconciles per-device `.lnk` files in `send_to_dir()`. +// On Linux we store those canonical `.lnk` metadata files inside +// `~/.local/share/nectarlink/send-to/` and mirror them into the three major Linux +// file managers: +// 1. GNOME Files (Nautilus): `~/.local/share/nautilus/scripts/Send to (NectarLink)` +// 2. KDE Dolphin (KIO ServiceMenus): `~/.local/share/kio/servicemenus/nectarlink-send-.desktop` +// 3. Cinnamon Nemo / XFCE Thunar (File Manager Actions): +// `~/.local/share/file-manager/actions/nectarlink-send-.desktop` -use std::{ - io, - path::{Path, PathBuf}, -}; +#![allow(dead_code)] +use std::io::Result; +use std::path::{Path, PathBuf}; + +/// What a shortcut starts. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Shortcut { pub target: PathBuf, pub arguments: String, + /// The tooltip. pub description: String, } -pub fn write(path: &Path, shortcut: &Shortcut) -> io::Result<()> { +fn xdg_data_home() -> Option { + dirs::data_local_dir().filter(|p| p.is_absolute()) +} + +fn sanitize_single_line(s: &str) -> String { + s.chars().filter(|&c| c != '\n' && c != '\r' && !c.is_control()).collect() +} + +fn sanitize_shell_path(s: &str) -> String { + s.chars() + .filter(|&c| { + c != '\n' && c != '\r' && c != '"' && c != '$' && c != '`' && c != '\\' && !c.is_control() + }) + .collect() +} + +fn sanitize_exec_args(s: &str) -> String { + s.chars() + .filter(|&c| { + c != '\n' + && c != '\r' + && c != '$' + && c != '`' + && c != ';' + && c != '&' + && c != '|' + && c != '<' + && c != '>' + && !c.is_control() + }) + .collect() +} + +fn write_file_nofollow(path: &Path, content: &str, mode: u32) -> Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .custom_flags(libc::O_NOFOLLOW) + .mode(mode) + .open(path)?; + file.write_all(content.as_bytes())?; + let _ = file.set_permissions(std::fs::Permissions::from_mode(mode)); + Ok(()) + } + #[cfg(not(unix))] + { + let _ = mode; + std::fs::write(path, content) + } +} + +/// Write a shortcut entry to `path`, and if `path` lives in the active `send_to_dir()`, +/// synchronize GNOME Files (Nautilus), KDE Dolphin, and Nemo/Thunar context-menu entries. +pub fn write(path: &Path, shortcut: &Shortcut) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(path, format!("{}\n{}", shortcut.target.display(), shortcut.arguments)) + let target = sanitize_single_line(&shortcut.target.to_string_lossy()); + let args = sanitize_single_line(&shortcut.arguments); + let desc = sanitize_single_line(&shortcut.description); + + let content = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=NectarLink\n\ + Comment={desc}\n\ + Exec=\"{target}\" {args}\n\ + Icon=nectarlink\n\ + Terminal=false\n\ + X-NectarLink-Target={target}\n\ + X-NectarLink-Args={args}\n\ + X-NectarLink-Comment={desc}\n", + ); + write_file_nofollow(path, &content, 0o644)?; + + if let (Some(parent), Some(real_send_to)) = (path.parent(), send_to_dir()) + && parent == real_send_to + { + let _ = sync_file_manager_menus(parent); + } + + Ok(()) } +/// The target path a shortcut points at, or `None` if it can't be read. pub fn target(path: &Path) -> Option { let text = std::fs::read_to_string(path).ok()?; - text.lines().next().map(PathBuf::from) + for line in text.lines() { + if let Some(rest) = line.strip_prefix("X-NectarLink-Target=") { + let trimmed = rest.trim(); + if !trimmed.is_empty() { + return Some(PathBuf::from(trimmed)); + } + } + } + None } +/// The arguments a shortcut passes, or `None` if it can't be read. pub fn arguments(path: &Path) -> Option { let text = std::fs::read_to_string(path).ok()?; - text.lines().nth(1).map(str::to_owned) + for line in text.lines() { + if let Some(rest) = line.strip_prefix("X-NectarLink-Args=") { + return Some(rest.to_string()); + } + } + None } +/// Read a full [`Shortcut`] metadata record from `path`. +fn read_shortcut(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let mut target = None; + let mut args = None; + let mut comment = String::new(); + for line in text.lines() { + if let Some(rest) = line.strip_prefix("X-NectarLink-Target=") { + target = Some(PathBuf::from(rest)); + } else if let Some(rest) = line.strip_prefix("X-NectarLink-Args=") { + args = Some(rest.to_string()); + } else if let Some(rest) = line.strip_prefix("X-NectarLink-Comment=") { + comment = rest.to_string(); + } + } + Some(Shortcut { target: target?, arguments: args?, description: comment }) +} + +/// Resolve the user's Startup / Autostart folder (`~/.config/autostart`). pub fn startup_dir() -> Option { - None + let base = dirs::config_dir().filter(|p| p.is_absolute())?; + Some(base.join("autostart")) } + +/// Resolve the user's canonical SendTo directory (`~/.local/share/nectarlink/send-to`). +/// Ensures the directory exists so `send_to::reconcile` can enumerate it cleanly. pub fn send_to_dir() -> Option { - None + let dir = xdg_data_home()?.join("nectarlink/send-to"); + let _ = std::fs::create_dir_all(&dir); + Some(dir) } + +/// Resolve the user's Recent items directory (`~/.local/share/nectarlink/recent`). pub fn recent_dir() -> Option { - None + let dir = xdg_data_home()?.join("nectarlink/recent"); + let _ = std::fs::create_dir_all(&dir); + Some(dir) +} + +/// Synchronize all NectarLink `.lnk` entries in `send_to_dir` into the user's +/// file-manager context menus (Nautilus scripts, KDE KIO ServiceMenus, Nemo/Thunar actions). +pub fn sync_file_manager_menus(send_to_dir: &Path) -> Result<()> { + let Some(data_home) = xdg_data_home() else { + return Ok(()); + }; + let entries = collect_shortcuts(send_to_dir)?; + write_file_manager_integrations(&data_home, &entries) +} + +fn collect_shortcuts(send_to_dir: &Path) -> Result> { + let mut out = Vec::new(); + let Ok(read_dir) = std::fs::read_dir(send_to_dir) else { + return Ok(out); + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("lnk")) { + continue; + } + if let Some(sc) = read_shortcut(&path) + && sc.arguments.starts_with("--send-to ") + { + out.push((path, sc)); + } + } + Ok(out) +} + +fn extract_device_id(args: &str) -> String { + let tokens: Vec<&str> = args.split_whitespace().collect(); + for pair in tokens.windows(2) { + if pair[0] == "--send-to" { + let sanitized: String = pair[1] + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect(); + if !sanitized.is_empty() { + return sanitized; + } + } + } + "default".to_string() +} + +fn format_menu_label(path: &Path) -> String { + let raw_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Phone"); + let cleaned: String = raw_stem + .chars() + .filter(|&c| c != '/' && c != '\\' && c != '\n' && c != '\r' && !c.is_control()) + .collect(); + let stem = cleaned.replace("..", "").trim().to_string(); + let stem = if stem.is_empty() { "Phone" } else { stem.as_str() }; + let with_prefix = if stem.starts_with("Send to ") { stem.to_string() } else { format!("Send to {stem}") }; + if with_prefix.ends_with("(NectarLink)") || with_prefix.contains("(NectarLink ") { + with_prefix + } else { + format!("{with_prefix} (NectarLink)") + } +} + +/// Write Nautilus, KDE Dolphin KIO ServiceMenu, and Nemo/Thunar file-manager action files +/// under `root_data_dir` (e.g. `~/.local/share`) and prune stale NectarLink entries. +pub fn write_file_manager_integrations( + root_data_dir: &Path, + shortcuts: &[(PathBuf, Shortcut)], +) -> Result<()> { + let nautilus_dir = root_data_dir.join("nautilus/scripts"); + let kio_dir = root_data_dir.join("kio/servicemenus"); + let actions_dir = root_data_dir.join("file-manager/actions"); + + std::fs::create_dir_all(&nautilus_dir)?; + std::fs::create_dir_all(&kio_dir)?; + std::fs::create_dir_all(&actions_dir)?; + + let mut desired_nautilus = std::collections::HashSet::new(); + let mut desired_desktop = std::collections::HashSet::new(); + + for (lnk_path, sc) in shortcuts { + let label = format_menu_label(lnk_path); + let dev_id = extract_device_id(&sc.arguments); + let desktop_filename = format!("nectarlink-send-{dev_id}.desktop"); + + desired_nautilus.insert(label.clone()); + desired_desktop.insert(desktop_filename.clone()); + + let target = sanitize_shell_path(&sc.target.to_string_lossy()); + let args = sanitize_exec_args(&sc.arguments); + let comment = sanitize_single_line(&sc.description); + + // 1. GNOME Files (Nautilus) executable script + let nautilus_path = nautilus_dir.join(&label); + let script = format!( + "#!/usr/bin/env sh\n\ + # Generated by NectarLink — {comment}\n\ + if [ -n \"${{NAUTILUS_SCRIPT_SELECTED_FILE_PATHS:-}}\" ]; then\n\ + IFS='\n'\n\ + set -f\n\ + exec \"{target}\" {args} $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS\n\ + else\n\ + exec \"{target}\" {args} \"$@\"\n\ + fi\n", + ); + write_file_nofollow(&nautilus_path, &script, 0o755)?; + + // 2. KDE Dolphin (KIO ServiceMenu) + let kio_path = kio_dir.join(&desktop_filename); + let kio_content = format!( + "[Desktop Entry]\n\ + Type=Service\n\ + MimeType=all/allfiles;\n\ + Actions=NectarLinkSend_{dev_id};\n\ + X-KDE-Priority=TopLevel\n\ + \n\ + [Desktop Action NectarLinkSend_{dev_id}]\n\ + Name={label}\n\ + Icon=nectarlink\n\ + Exec=\"{target}\" {args} %F\n" + ); + write_file_nofollow(&kio_path, &kio_content, 0o644)?; + + // 3. Nemo / Thunar (DES-EMA File Manager Action) + let action_path = actions_dir.join(&desktop_filename); + let action_content = format!( + "[Desktop Entry]\n\ + Type=Action\n\ + Name={label}\n\ + Tooltip={comment}\n\ + Icon=nectarlink\n\ + Profiles=profile-zero;\n\ + \n\ + [X-Action-Profile profile-zero]\n\ + MimeTypes=all/allfiles;\n\ + Exec=\"{target}\" {args} %F\n\ + Name=Default profile\n", + ); + write_file_nofollow(&action_path, &action_content, 0o644)?; + } + + // Prune stale Nautilus scripts generated by NectarLink + if let Ok(rd) = std::fs::read_dir(&nautilus_dir) { + for entry in rd.flatten() { + let p = entry.path(); + if let Some(name) = p.file_name().and_then(|s| s.to_str()) { + let is_nectarlink = name.ends_with("(NectarLink)") || name.contains("(NectarLink "); + if is_nectarlink && !desired_nautilus.contains(name) { + let _ = std::fs::remove_file(&p); + } + } + } + } + + // Prune stale KDE ServiceMenus and File Manager Actions prefixed with `nectarlink-send-` + for dir in [&kio_dir, &actions_dir] { + if let Ok(rd) = std::fs::read_dir(dir) { + for entry in rd.flatten() { + let p = entry.path(); + if let Some(name) = p.file_name().and_then(|s| s.to_str()) + && name.starts_with("nectarlink-send-") + && name.ends_with(".desktop") + && !desired_desktop.contains(name) + { + let _ = std::fs::remove_file(&p); + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shortcuts_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Pixel 9 é.lnk"); + let shortcut = Shortcut { + target: std::env::current_exe().unwrap(), + arguments: r#"--send-to abc --data-dir "/tmp/some dir""#.into(), + description: "Send to Pixel".into(), + }; + write(&path, &shortcut).unwrap(); + assert_eq!(arguments(&path).as_deref(), Some(shortcut.arguments.as_str())); + // Replacing works too. + write(&path, &Shortcut { arguments: "--send-to def".into(), ..shortcut }).unwrap(); + assert_eq!(arguments(&path).as_deref(), Some("--send-to def")); + assert_eq!(arguments(&dir.path().join("missing.lnk")), None); + } + + #[test] + fn file_manager_integrations_create_and_prune_entries() { + let tmp = tempfile::tempdir().expect("tempdir"); + let send_to = tmp.path().join("nectarlink/send-to"); + std::fs::create_dir_all(&send_to).unwrap(); + + let lnk1 = send_to.join("Pixel 9.lnk"); + let sc1 = Shortcut { + target: PathBuf::from("/usr/bin/nectarlink-desktop"), + arguments: "--send-to pixel9".to_string(), + description: "Send to Pixel 9".to_string(), + }; + let lnk2 = send_to.join("Galaxy S25 (NectarLink).lnk"); + let sc2 = Shortcut { + target: PathBuf::from("/usr/bin/nectarlink-desktop"), + arguments: "--send-to s25".to_string(), + description: "Send to Galaxy S25".to_string(), + }; + + write_file_manager_integrations(tmp.path(), &[(lnk1.clone(), sc1.clone()), (lnk2.clone(), sc2)]) + .expect("write integrations"); + + let nautilus_p1 = tmp.path().join("nautilus/scripts/Send to Pixel 9 (NectarLink)"); + let nautilus_p2 = tmp.path().join("nautilus/scripts/Send to Galaxy S25 (NectarLink)"); + let kio_p1 = tmp.path().join("kio/servicemenus/nectarlink-send-pixel9.desktop"); + let kio_p2 = tmp.path().join("kio/servicemenus/nectarlink-send-s25.desktop"); + let act_p1 = tmp.path().join("file-manager/actions/nectarlink-send-pixel9.desktop"); + + assert!(nautilus_p1.exists()); + assert!(nautilus_p2.exists()); + assert!(kio_p1.exists()); + assert!(kio_p2.exists()); + assert!(act_p1.exists()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&nautilus_p1).unwrap().permissions().mode(); + assert_ne!(mode & 0o111, 0, "Nautilus script must be executable"); + } + + let kio_text = std::fs::read_to_string(&kio_p1).unwrap(); + assert!(kio_text.contains("Exec=\"/usr/bin/nectarlink-desktop\" --send-to pixel9 %F")); + + // Now prune Galaxy S25 and verify its files are removed while Pixel 9 remains. + write_file_manager_integrations(tmp.path(), &[(lnk1, sc1)]).expect("prune integrations"); + assert!(nautilus_p1.exists()); + assert!(!nautilus_p2.exists()); + assert!(kio_p1.exists()); + assert!(!kio_p2.exists()); + } } diff --git a/desktop/app/src/linux/single_instance.rs b/desktop/app/src/linux/single_instance.rs index 711e51f3..948c4a6f 100644 --- a/desktop/app/src/linux/single_instance.rs +++ b/desktop/app/src/linux/single_instance.rs @@ -1,21 +1,161 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Single-instance lock and wakeup on Linux using `flock(LOCK_EX | LOCK_NB)` on +//! `/instance.lock` and a Unix domain stream socket at `/instance.sock`. -use std::{io, path::Path}; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + os::{ + fd::AsRawFd, + unix::net::{UnixListener, UnixStream}, + }, + path::{Path, PathBuf}, + sync::Mutex, +}; -pub struct PrimaryInstance; +const LOCK_FILE: &str = "instance.lock"; +const SOCKET_FILE: &str = "instance.sock"; +const LOCK_EX: i32 = 2; +const LOCK_NB: i32 = 4; +const LOCK_UN: i32 = 8; +const EAGAIN: i32 = 11; +const EWOULDBLOCK: i32 = 11; + +unsafe extern "C" { + fn flock(fd: i32, operation: i32) -> i32; +} + +/// Held for the lifetime of the primary process; releasing it (on exit or crash) +/// releases the `flock` lock. +#[derive(Debug)] +pub struct PrimaryInstance { + _lock_file: File, + socket_path: PathBuf, + listener: Mutex>, +} + +#[derive(Debug)] pub enum Instance { Primary(PrimaryInstance), Secondary, } -pub fn acquire(_data_dir: &Path) -> io::Result { - Ok(Instance::Primary(PrimaryInstance)) +fn lock_path(data_dir: &Path) -> PathBuf { + data_dir.join(LOCK_FILE) +} + +fn socket_path(data_dir: &Path) -> PathBuf { + data_dir.join(SOCKET_FILE) +} + +/// Acquires the single-instance lock for `data_dir`, or reports that another +/// instance already holds it. +pub fn acquire(data_dir: &Path) -> io::Result { + use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; + let _ = fs::DirBuilder::new().recursive(true).mode(0o700).create(data_dir); + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .custom_flags(libc::O_NOFOLLOW) + .mode(0o600) + .open(lock_path(data_dir))?; + + // SAFETY: `lock_file` is an open file descriptor owned by this process. + let rc = unsafe { flock(lock_file.as_raw_fd(), LOCK_EX | LOCK_NB) }; + if rc != 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::WouldBlock + || err.raw_os_error() == Some(EWOULDBLOCK) + || err.raw_os_error() == Some(EAGAIN) + { + return Ok(Instance::Secondary); + } + return Err(err); + } + + let sock_path = socket_path(data_dir); + if fs::symlink_metadata(&sock_path).is_ok_and(|m| !m.file_type().is_symlink()) { + let _ = fs::remove_file(&sock_path); + } + let listener = UnixListener::bind(&sock_path).ok(); + + Ok(Instance::Primary(PrimaryInstance { + _lock_file: lock_file, + socket_path: sock_path, + listener: Mutex::new(listener), + })) } -pub fn wake(_data_dir: &Path) {} +/// Signals the running primary instance for `data_dir` to wake up and drain queued requests. +pub fn wake(data_dir: &Path) { + let sock_path = socket_path(data_dir); + if let Ok(mut stream) = UnixStream::connect(sock_path) { + let _ = stream.write_all(b"WAKE\n"); + let _ = stream.flush(); + } +} impl PrimaryInstance { - pub fn watch(&'static self, _on_activate: impl Fn() + Send + 'static) {} + /// Calls `on_activate` on a background thread each time a secondary instance wakes us. + pub fn watch(&'static self, on_activate: impl Fn() + Send + 'static) { + let Some(listener) = self.listener.lock().unwrap_or_else(|e| e.into_inner()).take() else { + return; + }; + let _ = std::thread::Builder::new().name("single-instance".into()).spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(mut conn) => { + let mut buf = [0u8; 16]; + let _ = conn.read(&mut buf); + on_activate(); + } + Err(e) => { + tracing::debug!(error = %e, "single-instance socket accept failed"); + break; + } + } + } + }); + } +} + +impl Drop for PrimaryInstance { + fn drop(&mut self) { + let _ = fs::remove_file(&self.socket_path); + // SAFETY: unlocking our own file descriptor before close. + unsafe { + let _ = flock(self._lock_file.as_raw_fd(), LOCK_UN); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + sync::{Arc, mpsc}, + time::Duration, + }; + + #[test] + fn second_instance_sees_primary_and_wakes_it() { + let dir = tempfile::tempdir().unwrap(); + let primary = match acquire(dir.path()).unwrap() { + Instance::Primary(p) => Box::leak(Box::new(p)), + Instance::Secondary => panic!("first must be primary"), + }; + assert!(matches!(acquire(dir.path()).unwrap(), Instance::Secondary)); + + let (tx, rx) = mpsc::channel(); + let tx = Arc::new(Mutex::new(tx)); + primary.watch(move || { + let _ = tx.lock().unwrap().send(()); + }); + + wake(dir.path()); + rx.recv_timeout(Duration::from_secs(2)).expect("primary woken"); + } } diff --git a/desktop/app/src/linux/toast.rs b/desktop/app/src/linux/toast.rs index 00d807c6..5eb00e80 100644 --- a/desktop/app/src/linux/toast.rs +++ b/desktop/app/src/linux/toast.rs @@ -1,7 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Phone notifications as Freedesktop desktop notifications on Linux, with +//! action buttons, inline replies, progress updates, and D-Bus signal routing. +use std::collections::HashMap; +use std::fmt; +use std::io::{BufRead, BufReader}; use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock}; pub const AUMID: &str = "Nectarlink.Desktop"; pub const CALL_ANSWER: &str = "answer"; @@ -16,7 +22,7 @@ pub enum ToastEvent { Dismissed { device: String, key: String }, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Clone)] pub struct Toast { pub device: String, pub key: String, @@ -32,6 +38,25 @@ pub struct Toast { pub call: bool, } +impl fmt::Debug for Toast { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Toast") + .field("device", &self.device) + .field("key", &self.key) + .field("title", &self.title) + .field("body", &self.body) + .field("attribution", &self.attribution) + .field("icon", &self.icon) + .field("image", &self.image) + .field("actions", &self.actions.len()) + .field("reply", &self.reply.is_some()) + .field("silent", &self.silent) + .field("progress", &self.progress) + .field("call", &self.call) + .finish() + } +} + #[derive(Debug, Clone, PartialEq)] pub struct Progress { pub status: String, @@ -39,13 +64,749 @@ pub struct Progress { pub label: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MonitorSignal { + ActionInvoked { id: u32, action_key: String }, + NotificationReplied { id: u32, text: String }, + NotificationClosed { id: u32, reason: u32 }, +} + +#[derive(Clone, Debug)] +struct ToastSnapshot { + title: String, + body: String, + attribution: String, + icon_path: String, + actions: Vec<(String, String)>, + reply: Option<(String, String)>, + silent: bool, + call: bool, +} + +struct Toaster { + icon: PathBuf, + on_event: Option>, + active_ids: HashMap<(String, String), u32>, + id_to_target: HashMap, + reply_actions: HashMap, + snapshots: HashMap<(String, String), ToastSnapshot>, + next_fallback_id: u32, +} + +impl Toaster { + fn new(icon: PathBuf, on_event: Option>) -> Self { + Self { + icon, + on_event, + active_ids: HashMap::new(), + id_to_target: HashMap::new(), + reply_actions: HashMap::new(), + snapshots: HashMap::new(), + next_fallback_id: 100_000, + } + } + + fn record_shown(&mut self, device: String, key: String, id: u32, snapshot: ToastSnapshot) { + let target = (device, key); + if let Some(old_id) = self.active_ids.insert(target.clone(), id) + && old_id != id + { + self.id_to_target.remove(&old_id); + self.reply_actions.remove(&old_id); + } + self.id_to_target.insert(id, target.clone()); + if let Some((reply_action, _)) = &snapshot.reply { + self.reply_actions.insert(id, reply_action.clone()); + } else { + self.reply_actions.remove(&id); + } + self.snapshots.insert(target, snapshot); + } + + fn take_by_target(&mut self, device: &str, key: &str) -> Option { + let target = (device.to_string(), key.to_string()); + self.snapshots.remove(&target); + if let Some(id) = self.active_ids.remove(&target) { + self.id_to_target.remove(&id); + self.reply_actions.remove(&id); + Some(id) + } else { + None + } + } + + fn take_by_device(&mut self, device: &str) -> Vec { + let keys: Vec<(String, String)> = + self.active_ids.keys().filter(|(d, _)| d == device).cloned().collect(); + let mut ids = Vec::new(); + for target in keys { + if let Some(id) = self.active_ids.remove(&target) { + self.id_to_target.remove(&id); + self.reply_actions.remove(&id); + ids.push(id); + } + self.snapshots.remove(&target); + } + ids + } + + fn clear_all(&mut self) -> Vec { + let ids: Vec = self.active_ids.values().copied().collect(); + self.active_ids.clear(); + self.id_to_target.clear(); + self.reply_actions.clear(); + self.snapshots.clear(); + ids + } + + fn handle_signal(&mut self, signal: MonitorSignal) -> Option { + match signal { + MonitorSignal::ActionInvoked { id, action_key } => { + let (device, key) = self.id_to_target.get(&id)?.clone(); + if action_key == "default" || action_key.is_empty() { + Some(ToastEvent::Opened { device, key }) + } else { + Some(ToastEvent::Action { device, key, action: action_key }) + } + } + MonitorSignal::NotificationReplied { id, text } => { + let (device, key) = self.id_to_target.get(&id)?.clone(); + let action = + self.reply_actions.get(&id).cloned().unwrap_or_else(|| "inline-reply".to_string()); + Some(ToastEvent::Reply { device, key, action, text }) + } + MonitorSignal::NotificationClosed { id, reason } => { + let (device, key) = self.id_to_target.remove(&id)?; + self.active_ids.remove(&(device.clone(), key.clone())); + self.reply_actions.remove(&id); + self.snapshots.remove(&(device.clone(), key.clone())); + // Freedesktop NotificationClosed reasons: + // 1 = expired, 2 = dismissed by user, 3 = closed by CloseNotification, 4 = undefined + if reason == 2 { Some(ToastEvent::Dismissed { device, key }) } else { None } + } + } + } +} + +static TOASTER: OnceLock> = OnceLock::new(); +static MONITOR_STARTED: OnceLock<()> = OnceLock::new(); + pub fn enabled() -> bool { - false + true } + pub fn set_process_id() {} -pub fn start(_icon: PathBuf, _on_event: F) {} -pub fn show(_toast: Toast) {} -pub fn update_progress(_device: &str, _key: &str, _progress: Progress) {} -pub fn remove(_device: &str, _key: &str) {} -pub fn remove_device(_device: &str) {} -pub fn unregister() {} + +pub fn start(icon: PathBuf, on_event: F) +where + F: Fn(ToastEvent) + Send + Sync + 'static, +{ + if cfg!(test) { + if !icon.exists() { + if let Some(parent) = icon.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = super::icon::write_png(&icon, 64); + } + } else { + let bg_icon = icon.clone(); + let _ = std::thread::Builder::new().name("toast-icon".into()).spawn(move || { + if !bg_icon.exists() { + if let Some(parent) = bg_icon.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = super::icon::write_png(&bg_icon, 128); + } + }); + } + + let callback: Box = Box::new(on_event); + if let Some(mutex) = TOASTER.get() { + if let Ok(mut guard) = mutex.lock() { + guard.icon = icon; + guard.on_event = Some(callback); + } + } else { + let _ = TOASTER.set(Mutex::new(Toaster::new(icon, Some(callback)))); + } + + if !cfg!(test) { + MONITOR_STARTED.get_or_init(|| { + let _ = std::thread::Builder::new().name("toasts".into()).spawn(monitor_loop); + }); + } + crate::pc_notifications::start_listener(); +} + +pub fn show(toast: Toast) { + if toast.device.trim().is_empty() || toast.key.trim().is_empty() { + return; + } + + let (replaces_id, default_icon) = { + let Some(mutex) = TOASTER.get() else { + let icon_str = toast + .icon + .as_ref() + .or(toast.image.as_ref()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let title = format_summary(&toast.title, &toast.attribution); + let body = format_body(&toast.body, &toast.attribution, toast.progress.as_ref()); + let _ = send_notification( + 0, + &icon_str, + &title, + &body, + &toast.actions, + toast.reply.as_ref(), + toast.progress.as_ref(), + toast.silent, + toast.call, + ); + return; + }; + let Ok(guard) = mutex.lock() else { + return; + }; + let existing = guard.active_ids.get(&(toast.device.clone(), toast.key.clone())).copied().unwrap_or(0); + (existing, guard.icon.clone()) + }; + + let chosen_icon = toast.icon.clone().or(toast.image.clone()).unwrap_or(default_icon); + let icon_str = chosen_icon.to_string_lossy().to_string(); + let title = format_summary(&toast.title, &toast.attribution); + let body = format_body(&toast.body, &toast.attribution, toast.progress.as_ref()); + + let notified_id = send_notification( + replaces_id, + &icon_str, + &title, + &body, + &toast.actions, + toast.reply.as_ref(), + toast.progress.as_ref(), + toast.silent, + toast.call, + ); + + if let Some(mutex) = TOASTER.get() + && let Ok(mut guard) = mutex.lock() + { + let id = notified_id.unwrap_or_else(|| { + if replaces_id != 0 { + replaces_id + } else { + guard.next_fallback_id += 1; + guard.next_fallback_id + } + }); + let snapshot = ToastSnapshot { + title: toast.title, + body: toast.body, + attribution: toast.attribution, + icon_path: icon_str, + actions: toast.actions, + reply: toast.reply, + silent: toast.silent, + call: toast.call, + }; + guard.record_shown(toast.device, toast.key, id, snapshot); + } +} + +pub fn update_progress(device: &str, key: &str, progress: Progress) { + let Some(mutex) = TOASTER.get() else { + return; + }; + let (replaces_id, snapshot) = { + let Ok(guard) = mutex.lock() else { + return; + }; + let target = (device.to_string(), key.to_string()); + let Some(&id) = guard.active_ids.get(&target) else { + return; + }; + let Some(snap) = guard.snapshots.get(&target).cloned() else { + return; + }; + (id, snap) + }; + + let summary = format_summary(&snapshot.title, &snapshot.attribution); + let body = format_body(&snapshot.body, &snapshot.attribution, Some(&progress)); + + let new_id = send_notification( + replaces_id, + &snapshot.icon_path, + &summary, + &body, + &snapshot.actions, + snapshot.reply.as_ref(), + Some(&progress), + true, + snapshot.call, + ); + + if let Some(id) = new_id + && id != replaces_id + && let Ok(mut guard) = mutex.lock() + { + guard.record_shown(device.to_string(), key.to_string(), id, snapshot); + } +} + +pub fn remove(device: &str, key: &str) { + let Some(mutex) = TOASTER.get() else { + return; + }; + let id = { + let Ok(mut guard) = mutex.lock() else { + return; + }; + guard.take_by_target(device, key) + }; + if let Some(id) = id { + close_notification(id); + } +} + +pub fn remove_device(device: &str) { + let Some(mutex) = TOASTER.get() else { + return; + }; + let ids = { + let Ok(mut guard) = mutex.lock() else { + return; + }; + guard.take_by_device(device) + }; + for id in ids { + close_notification(id); + } +} + +pub fn unregister() { + let Some(mutex) = TOASTER.get() else { + return; + }; + let ids = { + let Ok(mut guard) = mutex.lock() else { + return; + }; + guard.clear_all() + }; + for id in ids { + close_notification(id); + } +} + +fn format_summary(title: &str, attribution: &str) -> String { + if !title.trim().is_empty() { + title.trim().to_string() + } else if !attribution.trim().is_empty() { + attribution.trim().to_string() + } else { + "NectarLink".to_string() + } +} + +fn format_body(body: &str, attribution: &str, progress: Option<&Progress>) -> String { + let mut parts = Vec::new(); + if !body.trim().is_empty() { + parts.push(body.trim().to_string()); + } + if let Some(prog) = progress { + let mut prog_line = String::new(); + if !prog.status.trim().is_empty() { + prog_line.push_str(prog.status.trim()); + } + if !prog.label.trim().is_empty() { + if !prog_line.is_empty() { + prog_line.push_str(" — "); + } + prog_line.push_str(prog.label.trim()); + } else if let Some(val) = prog.value { + let pct = (val.clamp(0.0, 1.0) * 100.0).round() as i32; + if !prog_line.is_empty() { + prog_line.push_str(&format!(" ({pct}%)")); + } else { + prog_line.push_str(&format!("{pct}%")); + } + } + if !prog_line.is_empty() { + parts.push(prog_line); + } + } + if !attribution.trim().is_empty() { + parts.push(attribution.trim().to_string()); + } + parts.join("\n") +} + +fn compute_progress_percent(progress: &Progress) -> i32 { + progress.value.map(|v| (v.clamp(0.0, 1.0) * 100.0).round() as i32).unwrap_or(0) +} + +fn build_actions_gvariant(actions: &[(String, String)], reply: Option<&(String, String)>) -> String { + let mut items: Vec = Vec::new(); + items.push("'default'".to_string()); + items.push("'Open'".to_string()); + for (id, label) in actions { + if !id.trim().is_empty() { + items.push(escape_gvariant_string(id)); + items.push(escape_gvariant_string(label)); + } + } + if let Some((_action_id, placeholder)) = reply { + let label = if placeholder.trim().is_empty() { "Reply" } else { placeholder.trim() }; + items.push("'inline-reply'".to_string()); + items.push(escape_gvariant_string(label)); + } + format!("[{}]", items.join(", ")) +} + +fn build_hints_gvariant(progress: Option<&Progress>, silent: bool, call: bool) -> String { + let mut entries = Vec::new(); + let urgency = if call { + 2 + } else if silent { + 0 + } else { + 1 + }; + entries.push(format!("'urgency': ")); + if silent && !call { + entries.push("'suppress-sound': ".to_string()); + } + if let Some(prog) = progress { + let pct = compute_progress_percent(prog); + entries.push(format!("'value': ")); + } + format!("{{{}}}", entries.join(", ")) +} + +fn escape_gvariant_string(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('\'', "\\'"); + format!("'{escaped}'") +} + +#[allow(clippy::too_many_arguments)] +fn send_notification( + replaces_id: u32, + icon_path: &str, + summary: &str, + body: &str, + actions: &[(String, String)], + reply: Option<&(String, String)>, + progress: Option<&Progress>, + silent: bool, + call: bool, +) -> Option { + if cfg!(test) { + return None; + } + + let actions_arg = build_actions_gvariant(actions, reply); + let hints_arg = build_hints_gvariant(progress, silent, call); + let expire_timeout = if call { "int32 0" } else { "int32 -1" }; + + if let Ok(output) = Command::new("gdbus") + .args([ + "call", + "--session", + "--dest", + "org.freedesktop.Notifications", + "--object-path", + "/org/freedesktop/Notifications", + "--method", + "org.freedesktop.Notifications.Notify", + "NectarLink", + &replaces_id.to_string(), + icon_path, + summary, + body, + &actions_arg, + &hints_arg, + expire_timeout, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + && output.status.success() + { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(id) = parse_notify_id(&stdout) { + return Some(id); + } + } + + // Fallback to notify-send + let mut cmd = Command::new("notify-send"); + cmd.arg("--app-name=NectarLink").arg("--print-id"); + if replaces_id != 0 { + cmd.arg(format!("--replace-id={replaces_id}")); + } + if !icon_path.is_empty() { + cmd.arg(format!("--icon={icon_path}")); + } + if call { + cmd.arg("--urgency=critical"); + } else if silent { + cmd.arg("--urgency=low"); + } + if let Some(prog) = progress { + let pct = compute_progress_percent(prog); + cmd.arg(format!("--hint=int:value:{pct}")); + } + cmd.arg(summary); + if !body.is_empty() { + cmd.arg(body); + } + if let Ok(output) = cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).output() + && output.status.success() + { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(id) = parse_notify_id(&stdout) { + return Some(id); + } + } + + None +} + +fn close_notification(id: u32) { + if cfg!(test) || id == 0 { + return; + } + let _ = Command::new("gdbus") + .args([ + "call", + "--session", + "--dest", + "org.freedesktop.Notifications", + "--object-path", + "/org/freedesktop/Notifications", + "--method", + "org.freedesktop.Notifications.CloseNotification", + &id.to_string(), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +fn monitor_loop() { + let Ok(mut child) = Command::new("gdbus") + .args([ + "monitor", + "--session", + "--dest", + "org.freedesktop.Notifications", + "--object-path", + "/org/freedesktop/Notifications", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return; + }; + + let Some(stdout) = child.stdout.take() else { + return; + }; + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if let Some(signal) = parse_monitor_line(&line) { + let event_and_cb = TOASTER.get().and_then(|mutex| { + let mut guard = mutex.lock().ok()?; + let ev = guard.handle_signal(signal)?; + Some(ev) + }); + if let Some(event) = event_and_cb + && let Some(mutex) = TOASTER.get() + && let Ok(guard) = mutex.lock() + && let Some(cb) = &guard.on_event + { + cb(event); + } + } + } +} + +/// Parses the notification ID returned by `gdbus call ... Notify` (e.g. `(uint32 42,)`) +/// or `notify-send --print-id` (e.g. `42`). +pub fn parse_notify_id(output: &str) -> Option { + let trimmed = output.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(id) = trimmed.parse::() { + return Some(id); + } + if let Some(idx) = trimmed.find("uint32 ") { + let rest = &trimmed[idx + "uint32 ".len()..]; + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(id) = digits.parse::() { + return Some(id); + } + } + None +} + +/// Parses a single line from `gdbus monitor --session --dest org.freedesktop.Notifications`. +/// +/// Expected signal formats: +/// - `/org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 12, 'reply')` +/// - `/org/freedesktop/Notifications: org.freedesktop.Notifications.NotificationReplied (uint32 12, 'Hello!')` +/// - `/org/freedesktop/Notifications: org.freedesktop.Notifications.NotificationClosed (uint32 12, uint32 2)` +pub fn parse_monitor_line(line: &str) -> Option { + let trimmed = line.trim(); + if let Some(idx) = trimmed.find("ActionInvoked") { + let args = &trimmed[idx + "ActionInvoked".len()..]; + let (id, rest) = parse_first_uint32(args)?; + let action_key = parse_quoted_arg(rest)?; + return Some(MonitorSignal::ActionInvoked { id, action_key }); + } + if let Some(idx) = trimmed.find("NotificationReplied") { + let args = &trimmed[idx + "NotificationReplied".len()..]; + let (id, rest) = parse_first_uint32(args)?; + let text = parse_quoted_arg(rest)?; + return Some(MonitorSignal::NotificationReplied { id, text }); + } + if let Some(idx) = trimmed.find("NotificationClosed") { + let args = &trimmed[idx + "NotificationClosed".len()..]; + let (id, rest) = parse_first_uint32(args)?; + let (reason, _) = parse_first_uint32(rest).unwrap_or((0, "")); + return Some(MonitorSignal::NotificationClosed { id, reason }); + } + None +} + +fn parse_first_uint32(s: &str) -> Option<(u32, &str)> { + let start = s.find(|c: char| c.is_ascii_digit())?; + let after_prefix = &s[start..]; + // Skip the `32` in `uint32 ` if `start` landed on `32` + let after_uint = if start >= 4 && &s[start - 4..start + 2] == "uint32" { + let rest_after_type = &s[start + 2..]; + let num_start = rest_after_type.find(|c: char| c.is_ascii_digit())?; + &rest_after_type[num_start..] + } else { + after_prefix + }; + let end = after_uint.find(|c: char| !c.is_ascii_digit()).unwrap_or(after_uint.len()); + let val = after_uint[..end].parse::().ok()?; + Some((val, &after_uint[end..])) +} + +fn parse_quoted_arg(s: &str) -> Option { + let comma = s.find(',')?; + let after_comma = s[comma + 1..].trim(); + let quote_char = after_comma.chars().next()?; + if quote_char != '\'' && quote_char != '"' { + return None; + } + let content = &after_comma[quote_char.len_utf8()..]; + let mut out = String::new(); + let mut chars = content.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + if let Some(next) = chars.next() { + out.push(next); + } + } else if ch == quote_char { + return Some(out); + } else { + out.push(ch); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_notify_ids_from_gdbus_and_notify_send() { + assert_eq!(parse_notify_id("(uint32 42,)\n"), Some(42)); + assert_eq!(parse_notify_id("(uint32 108,)"), Some(108)); + assert_eq!(parse_notify_id("73\n"), Some(73)); + assert_eq!(parse_notify_id(""), None); + } + + #[test] + fn parses_gdbus_monitor_signals() { + let action_line = "/org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 15, 'mark_read')"; + assert_eq!( + parse_monitor_line(action_line), + Some(MonitorSignal::ActionInvoked { id: 15, action_key: "mark_read".to_string() }) + ); + + let reply_line = "/org/freedesktop/Notifications: org.freedesktop.Notifications.NotificationReplied (uint32 15, 'Sounds good\\'s!')"; + assert_eq!( + parse_monitor_line(reply_line), + Some(MonitorSignal::NotificationReplied { id: 15, text: "Sounds good's!".to_string() }) + ); + + let closed_line = "/org/freedesktop/Notifications: org.freedesktop.Notifications.NotificationClosed (uint32 15, uint32 2)"; + assert_eq!( + parse_monitor_line(closed_line), + Some(MonitorSignal::NotificationClosed { id: 15, reason: 2 }) + ); + } + + #[test] + fn toaster_tracks_lifecycle_and_routes_signals() { + let mut toaster = Toaster::new(PathBuf::from("/tmp/icon.png"), None); + let snap = ToastSnapshot { + title: "Messages".to_string(), + body: "Hi there".to_string(), + attribution: "Messages · Pixel 9".to_string(), + icon_path: "/tmp/icon.png".to_string(), + actions: vec![("archive".to_string(), "Archive".to_string())], + reply: Some(("direct_reply".to_string(), "Type a message".to_string())), + silent: false, + call: false, + }; + + toaster.record_shown("phone-1".to_string(), "notif-a".to_string(), 77, snap); + + assert_eq!( + toaster.handle_signal(MonitorSignal::ActionInvoked { id: 77, action_key: "default".to_string() }), + Some(ToastEvent::Opened { device: "phone-1".to_string(), key: "notif-a".to_string() }) + ); + + assert_eq!( + toaster.handle_signal(MonitorSignal::ActionInvoked { id: 77, action_key: "archive".to_string() }), + Some(ToastEvent::Action { + device: "phone-1".to_string(), + key: "notif-a".to_string(), + action: "archive".to_string(), + }) + ); + + assert_eq!( + toaster + .handle_signal(MonitorSignal::NotificationReplied { id: 77, text: "On my way".to_string() }), + Some(ToastEvent::Reply { + device: "phone-1".to_string(), + key: "notif-a".to_string(), + action: "direct_reply".to_string(), + text: "On my way".to_string(), + }) + ); + + assert_eq!( + toaster.handle_signal(MonitorSignal::NotificationClosed { id: 77, reason: 2 }), + Some(ToastEvent::Dismissed { device: "phone-1".to_string(), key: "notif-a".to_string() }) + ); + + assert!(toaster.active_ids.is_empty()); + assert!(toaster.id_to_target.is_empty()); + } +} diff --git a/desktop/app/src/linux/tray.rs b/desktop/app/src/linux/tray.rs index e8429986..0e536e38 100644 --- a/desktop/app/src/linux/tray.rs +++ b/desktop/app/src/linux/tray.rs @@ -1,8 +1,23 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Linux system tray, global hotkeys, active external window tracking/focus +//! for Continuity Camera & File Shelf, and D-Bus sleep/resume + XDG Desktop +//! Portal appearance change listeners. -use std::io; +use std::{ + collections::HashMap, + env, + io::{BufRead, BufReader, Result, Write}, + path::PathBuf, + process::{Child, ChildStdin, Command, Stdio}, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; +/// What the user or the system asked for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrayEvent { Open, @@ -11,12 +26,17 @@ pub enum TrayEvent { TakePhoto, ScanDocument, FindPhone, + /// Open the link on the clipboard on the phone. OpenLinkOnPhone, Quit, + /// The PC woke from sleep. Resumed, + /// Dark mode, the accent color, the wallpaper or the animation setting + /// changed. AppearanceChanged, } +/// Menu texts (translated by the caller). #[derive(Debug, Clone)] pub struct MenuLabels { pub open: String, @@ -35,33 +55,1222 @@ pub const MOD_SHIFT: u32 = 0x0004; pub const MOD_WIN: u32 = 0x0008; pub const MOD_NOREPEAT: u32 = 0x4000; -pub fn record_foreground_window() {} -pub fn snapshot_target_window() -> Option<(isize, String)> { - None +static LAST_EXTERNAL_WINDOW: Mutex> = Mutex::new(None); +static WINDOW_ID_MAP: OnceLock>> = OnceLock::new(); +static HOTKEY_SPECS: Mutex<(String, String, String, String)> = + Mutex::new((String::new(), String::new(), String::new(), String::new())); +static HOTKEY_GENERATION: AtomicU64 = AtomicU64::new(1); + +fn window_id_map() -> &'static Mutex> { + WINDOW_ID_MAP.get_or_init(|| Mutex::new(HashMap::new())) } -pub fn is_window_alive_and_visible(_hwnd: isize) -> bool { - false + +fn has_x11_display() -> bool { + env::var_os("DISPLAY").is_some_and(|v| !v.is_empty()) } -pub fn focus_window(_hwnd: isize) -> bool { - false + +fn has_wayland_display() -> bool { + env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) } -pub fn send_paste_fallback_if_not_foreground(_hwnd: isize) {} -pub fn apply_no_activate_style_by_title(_title: &str) {} -pub fn parse_hotkey(_spec: &str) -> Option<(u32, u32)> { + +fn has_desktop_session() -> bool { + env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some_and(|v| !v.is_empty()) + || has_x11_display() + || has_wayland_display() +} + +fn is_nectarlink_title(title: &str) -> bool { + let trimmed = title.trim(); + trimmed.is_empty() || trimmed.get(..10).is_some_and(|prefix| prefix.eq_ignore_ascii_case("nectarlink")) +} + +fn parse_window_handle(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let handle = if let Some(hex) = trimmed.strip_prefix("0x").or_else(|| trimmed.strip_prefix("0X")) { + isize::from_str_radix(hex, 16).ok()? + } else if let Ok(num) = trimmed.parse::() { + num + } else { + // Deterministic non-zero hash for non-numeric window identifiers (e.g. KWin UUIDs). + let mut hash: usize = 0xcbf2_9ce4_8422_2325; + for b in trimmed.as_bytes() { + hash ^= usize::from(*b); + hash = hash.wrapping_mul(0x0100_0000_01b3); + } + let h = (hash as isize).wrapping_abs(); + if h == 0 { 1 } else { h } + }; + if handle == 0 { + return None; + } + if let Ok(mut map) = window_id_map().lock() { + map.insert(handle, trimmed.to_owned()); + } + Some(handle) +} + +fn raw_window_id(hwnd: isize) -> String { + if let Ok(map) = window_id_map().lock() + && let Some(raw) = map.get(&hwnd) + { + return raw.clone(); + } + hwnd.to_string() +} + +fn run_command_stdout(program: &str, args: &[&str]) -> Option { + let output = Command::new(program).args(args).stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + (!text.is_empty()).then_some(text) +} + +/// Queries the currently active external window via `kdotool` (KDE Wayland) +/// or `xdotool` (X11/XWayland), ignoring NectarLink's own windows. +pub fn query_active_window() -> Option<(isize, String)> { + if has_wayland_display() + && let Some(raw_id) = run_command_stdout("kdotool", &["getactivewindow"]) + && let Some(hwnd) = parse_window_handle(&raw_id) + && let Some(title) = run_command_stdout("kdotool", &["getwindowname", &raw_id]) + && !is_nectarlink_title(&title) + { + return Some((hwnd, title)); + } + if has_x11_display() + && let Some(raw_id) = run_command_stdout("xdotool", &["getactivewindow"]) + && let Some(hwnd) = parse_window_handle(&raw_id) + && let Some(title) = run_command_stdout("xdotool", &["getwindowname", &raw_id]) + && !is_nectarlink_title(&title) + { + return Some((hwnd, title)); + } None } -pub fn update_hotkeys(_photo_spec: &str, _scan_spec: &str, _palette_spec: &str, _shelf_spec: &str) {} -pub struct Tray; +/// Records the current foreground window if it belongs to an external +/// user-facing application (not NectarLink itself). +pub fn record_foreground_window() { + if let Some((hwnd, title)) = query_active_window() { + let mut guard = LAST_EXTERNAL_WINDOW.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some((hwnd, title)); + } +} + +/// Captures the last active external foreground window `(hwnd, title)` at the +/// moment a Continuity Camera request begins. +pub fn snapshot_target_window() -> Option<(isize, String)> { + if let Some((hwnd, title)) = query_active_window() { + let mut guard = LAST_EXTERNAL_WINDOW.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some((hwnd, title.clone())); + return Some((hwnd, title)); + } + LAST_EXTERNAL_WINDOW.lock().unwrap_or_else(|e| e.into_inner()).clone() +} + +/// Returns true if `hwnd` still refers to an existing, visible top-level window. +pub fn is_window_alive_and_visible(hwnd: isize) -> bool { + if hwnd == 0 { + return false; + } + let raw_id = raw_window_id(hwnd); + if has_wayland_display() + && let Some(title) = run_command_stdout("kdotool", &["getwindowname", &raw_id]) + && !title.is_empty() + { + return true; + } + if has_x11_display() + && let Some(title) = run_command_stdout("xdotool", &["getwindowname", &raw_id]) + && !title.is_empty() + { + return true; + } + LAST_EXTERNAL_WINDOW + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .is_some_and(|(recorded_hwnd, _)| *recorded_hwnd == hwnd) +} + +/// Restores and brings `hwnd` to the foreground so a paste shortcut can be +/// synthesized into it. Returns false if the window is gone. +pub fn focus_window(hwnd: isize) -> bool { + if !is_window_alive_and_visible(hwnd) { + return false; + } + let raw_id = raw_window_id(hwnd); + if has_wayland_display() + && Command::new("kdotool") + .args(["windowactivate", &raw_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + return true; + } + if has_x11_display() { + if Command::new("xdotool") + .args(["windowactivate", &raw_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + return true; + } + if Command::new("wmctrl") + .args(["-i", "-a", &raw_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + return true; + } + } + LAST_EXTERNAL_WINDOW + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .is_some_and(|(recorded_hwnd, _)| *recorded_hwnd == hwnd) +} + +/// If the window manager prevented `focus_window` from making `hwnd` the +/// active window on X11, sends `Ctrl+V` directly to `hwnd`. +pub fn send_paste_fallback_if_not_foreground(hwnd: isize) { + if hwnd == 0 || !has_x11_display() { + return; + } + if query_active_window().is_some_and(|(active, _)| active == hwnd) { + return; + } + let raw_id = raw_window_id(hwnd); + let _ = Command::new("xdotool") + .args(["key", "--window", &raw_id, "--clearmodifiers", "ctrl+v"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Applies `_NET_WM_STATE_ABOVE` and `_NET_WM_STATE_SKIP_TASKBAR` to a +/// top-level window matching `title` on X11 so the Shelf stays above normal +/// windows and off the taskbar without blocking or panicking. +pub fn apply_no_activate_style_by_title(title: &str) { + let trimmed = title.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') || !has_x11_display() { + return; + } + let Some(ids) = run_command_stdout("xdotool", &["search", "--name", trimmed]) else { + return; + }; + for id in ids.lines().map(str::trim).filter(|s| !s.is_empty()) { + let _ = Command::new("xprop") + .args([ + "-id", + id, + "-f", + "_NET_WM_STATE", + "32a", + "-set", + "_NET_WM_STATE", + "_NET_WM_STATE_ABOVE,_NET_WM_STATE_SKIP_TASKBAR", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +/// Parses a hotkey string such as `"Ctrl+Alt+C"`, `"Ctrl+Alt+Space"` or `"Ctrl+Shift+F9"` into +/// `(HOT_KEY_MODIFIERS bits including MOD_NOREPEAT, virtual_key_code)`. +pub fn parse_hotkey(spec: &str) -> Option<(u32, u32)> { + let trimmed = spec.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") { + return None; + } + let mut mods: u32 = 0; + let mut key_vk: Option = None; + + for raw_part in trimmed.split('+') { + let part = raw_part.trim(); + if part.is_empty() { + return None; + } + let lower = part.to_ascii_lowercase(); + match lower.as_str() { + "ctrl" | "control" => mods |= MOD_CONTROL, + "alt" => mods |= MOD_ALT, + "shift" => mods |= MOD_SHIFT, + "win" | "super" | "meta" => mods |= MOD_WIN, + _ => { + if key_vk.is_some() { + return None; + } + let vk = match lower.as_str() { + "space" | "spacebar" => 0x20, + "tab" => 0x09, + "enter" | "return" => 0x0D, + "esc" | "escape" => 0x1B, + "insert" | "ins" => 0x2D, + "delete" | "del" => 0x2E, + "home" => 0x24, + "end" => 0x23, + "pageup" | "pgup" => 0x21, + "pagedown" | "pgdn" => 0x22, + "left" => 0x25, + "up" => 0x26, + "right" => 0x27, + "down" => 0x28, + "plus" | "=" => 0xBB, + "minus" | "-" => 0xBD, + "comma" | "," => 0xBC, + "period" | "." => 0xBE, + _ if part.len() == 1 => { + let b = part.as_bytes()[0]; + if b.is_ascii_alphabetic() { + u32::from(b.to_ascii_uppercase()) + } else if b.is_ascii_digit() { + u32::from(b) + } else { + return None; + } + } + _ => { + let num_str = lower.strip_prefix('f')?; + let n: u32 = num_str.parse().ok()?; + if (1..=12).contains(&n) { + 0x70 + (n - 1) + } else { + return None; + } + } + }; + key_vk = Some(vk); + } + } + } + + let vk = key_vk?; + if mods == 0 { + return None; + } + Some((mods | MOD_NOREPEAT, vk)) +} + +/// Updates the Continuity Camera, Command Palette, and Shelf global hotkeys +/// (`photo_spec`, `scan_spec`, `palette_spec`, and `shelf_spec`). +pub fn update_hotkeys(photo_spec: &str, scan_spec: &str, palette_spec: &str, shelf_spec: &str) { + let mut specs = HOTKEY_SPECS.lock().unwrap_or_else(|e| e.into_inner()); + *specs = ( + photo_spec.trim().to_owned(), + scan_spec.trim().to_owned(), + palette_spec.trim().to_owned(), + shelf_spec.trim().to_owned(), + ); + drop(specs); + HOTKEY_GENERATION.fetch_add(1, Ordering::Release); +} + +const X11_SHIFT_MASK: u32 = 1 << 0; +const X11_LOCK_MASK: u32 = 1 << 1; +const X11_CONTROL_MASK: u32 = 1 << 2; +const X11_MOD1_MASK: u32 = 1 << 3; +const X11_MOD2_MASK: u32 = 1 << 4; +const X11_MOD4_MASK: u32 = 1 << 6; +const X11_CLEAN_MOD_MASK: u32 = X11_SHIFT_MASK | X11_CONTROL_MASK | X11_MOD1_MASK | X11_MOD4_MASK; + +/// Converts `MOD_*` flags from [`parse_hotkey`] into an X11 modifier state mask. +pub(crate) fn mods_to_x11_mask(mods: u32) -> u32 { + let mut mask = 0u32; + if (mods & MOD_SHIFT) != 0 { + mask |= X11_SHIFT_MASK; + } + if (mods & MOD_CONTROL) != 0 { + mask |= X11_CONTROL_MASK; + } + if (mods & MOD_ALT) != 0 { + mask |= X11_MOD1_MASK; + } + if (mods & MOD_WIN) != 0 { + mask |= X11_MOD4_MASK; + } + mask +} + +/// Converts a virtual key code from [`parse_hotkey`] into an X11 `KeySym` (``). +pub(crate) fn vk_to_x11_keysym(vk: u32) -> Option { + let keysym: u64 = match vk { + 0x20 => 0x0020, // XK_space + 0x09 => 0xFF09, // XK_Tab + 0x0D => 0xFF0D, // XK_Return + 0x1B => 0xFF1B, // XK_Escape + 0x2D => 0xFF63, // XK_Insert + 0x2E => 0xFFFF, // XK_Delete + 0x24 => 0xFF50, // XK_Home + 0x23 => 0xFF57, // XK_End + 0x21 => 0xFF55, // XK_Prior (PageUp) + 0x22 => 0xFF56, // XK_Next (PageDown) + 0x25 => 0xFF51, // XK_Left + 0x26 => 0xFF52, // XK_Up + 0x27 => 0xFF53, // XK_Right + 0x28 => 0xFF54, // XK_Down + 0xBB => 0x003D, // XK_equal ('+'/'=' key) + 0xBD => 0x002D, // XK_minus + 0xBC => 0x002C, // XK_comma + 0xBE => 0x002E, // XK_period + 0x30..=0x39 => u64::from(vk), // XK_0..=XK_9 + 0x41..=0x5A => u64::from(vk + 32), // XK_a..=XK_z + 0x70..=0x7B => 0xFFBE + u64::from(vk - 0x70), // XK_F1..=XK_F12 + _ => return None, + }; + Some(keysym as libc::c_ulong) +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct XKeyEvent { + event_type: libc::c_int, + serial: libc::c_ulong, + send_event: libc::c_int, + display: *mut libc::c_void, + window: libc::c_ulong, + root: libc::c_ulong, + subwindow: libc::c_ulong, + time: libc::c_ulong, + x: libc::c_int, + y: libc::c_int, + x_root: libc::c_int, + y_root: libc::c_int, + state: libc::c_uint, + keycode: libc::c_uint, + same_screen: libc::c_int, +} + +#[repr(C)] +union XEvent { + event_type: libc::c_int, + xkey: XKeyEvent, + pad: [libc::c_long; 24], +} + +unsafe extern "C" fn ignore_x11_error(_dpy: *mut libc::c_void, _err: *mut libc::c_void) -> libc::c_int { + 0 +} + +fn spawn_x11_hotkey_thread(stop: Arc, handler: Arc) -> Option> { + if !has_x11_display() { + return None; + } + std::thread::Builder::new() + .name("tray-x11-hotkeys".into()) + .spawn(move || { + // SAFETY: Dynamically loads libX11.so.6 and resolves standard Xlib symbols. + unsafe { + let lib = libc::dlopen(c"libX11.so.6".as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL); + if lib.is_null() { + tracing::debug!("libX11.so.6 not available for global hotkeys"); + return; + } + + type FnXOpenDisplay = unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void; + type FnXCloseDisplay = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; + type FnXDefaultRootWindow = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_ulong; + type FnXKeysymToKeycode = + unsafe extern "C" fn(*mut libc::c_void, libc::c_ulong) -> libc::c_uchar; + type FnXGrabKey = unsafe extern "C" fn( + *mut libc::c_void, + libc::c_int, + libc::c_uint, + libc::c_ulong, + libc::c_int, + libc::c_int, + libc::c_int, + ) -> libc::c_int; + type FnXUngrabKey = unsafe extern "C" fn( + *mut libc::c_void, + libc::c_int, + libc::c_uint, + libc::c_ulong, + ) -> libc::c_int; + type FnXPending = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; + type FnXNextEvent = unsafe extern "C" fn(*mut libc::c_void, *mut XEvent) -> libc::c_int; + type FnXSync = unsafe extern "C" fn(*mut libc::c_void, libc::c_int) -> libc::c_int; + type FnXConnectionNumber = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; + type XErrorHandler = + Option libc::c_int>; + type FnXSetErrorHandler = unsafe extern "C" fn(XErrorHandler) -> XErrorHandler; + + macro_rules! sym { + ($name:literal, $ty:ty) => {{ + let ptr = libc::dlsym(lib, $name.as_ptr()); + if ptr.is_null() { + return; + } + std::mem::transmute::<*mut libc::c_void, $ty>(ptr) + }}; + } + + let x_open_display = sym!(c"XOpenDisplay", FnXOpenDisplay); + let x_close_display = sym!(c"XCloseDisplay", FnXCloseDisplay); + let x_default_root_window = sym!(c"XDefaultRootWindow", FnXDefaultRootWindow); + let x_keysym_to_keycode = sym!(c"XKeysymToKeycode", FnXKeysymToKeycode); + let x_grab_key = sym!(c"XGrabKey", FnXGrabKey); + let x_ungrab_key = sym!(c"XUngrabKey", FnXUngrabKey); + let x_pending = sym!(c"XPending", FnXPending); + let x_next_event = sym!(c"XNextEvent", FnXNextEvent); + let x_sync = sym!(c"XSync", FnXSync); + let x_connection_number = sym!(c"XConnectionNumber", FnXConnectionNumber); + let x_set_error_handler = sym!(c"XSetErrorHandler", FnXSetErrorHandler); + + let _ = x_set_error_handler(Some(ignore_x11_error)); + let dpy = x_open_display(std::ptr::null()); + if dpy.is_null() { + tracing::debug!("XOpenDisplay failed for global hotkeys"); + return; + } + let root = x_default_root_window(dpy); + let fd = x_connection_number(dpy); + + const KEY_PRESS: libc::c_int = 2; + const GRAB_MODE_ASYNC: libc::c_int = 1; + const LOCK_COMBOS: [u32; 4] = + [0, X11_LOCK_MASK, X11_MOD2_MASK, X11_LOCK_MASK | X11_MOD2_MASK]; + + let mut applied_gen: u64 = 0; + // (x11_clean_mods, keycode, TrayEvent) + let mut grabbed: Vec<(u32, u32, TrayEvent)> = Vec::new(); + let mut last_fired: Option<(TrayEvent, Instant)> = None; + + while !stop.load(Ordering::Acquire) { + let current_gen = HOTKEY_GENERATION.load(Ordering::Acquire); + if current_gen != applied_gen { + for &(mods, keycode, _) in &grabbed { + for extra in LOCK_COMBOS { + let _ = x_ungrab_key(dpy, keycode as libc::c_int, mods | extra, root); + } + } + grabbed.clear(); + + let (photo, scan, palette, shelf) = + HOTKEY_SPECS.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let targets = [ + (photo.as_str(), TrayEvent::TakePhoto), + (scan.as_str(), TrayEvent::ScanDocument), + (palette.as_str(), TrayEvent::CommandPalette), + (shelf.as_str(), TrayEvent::Shelf), + ]; + + for (spec, ev) in targets { + let Some((mods, vk)) = parse_hotkey(spec) else { continue }; + let Some(keysym) = vk_to_x11_keysym(vk) else { continue }; + let keycode = u32::from(x_keysym_to_keycode(dpy, keysym)); + if keycode == 0 { + continue; + } + let x11_mods = mods_to_x11_mask(mods); + if x11_mods == 0 { + continue; + } + for extra in LOCK_COMBOS { + let _ = x_grab_key( + dpy, + keycode as libc::c_int, + x11_mods | extra, + root, + 1, // owner_events = True + GRAB_MODE_ASYNC, + GRAB_MODE_ASYNC, + ); + } + grabbed.push((x11_mods, keycode, ev)); + } + let _ = x_sync(dpy, 0); + applied_gen = current_gen; + } + + while x_pending(dpy) > 0 { + let mut event: XEvent = std::mem::zeroed(); + let _ = x_next_event(dpy, &mut event); + if event.event_type == KEY_PRESS { + let clean_mods = event.xkey.state & X11_CLEAN_MOD_MASK; + let keycode = event.xkey.keycode; + if let Some(&(_, _, tray_ev)) = + grabbed.iter().find(|&&(m, kc, _)| m == clean_mods && kc == keycode) + { + let now = Instant::now(); + let debounced = last_fired.is_some_and(|(prev_ev, prev_t)| { + prev_ev == tray_ev + && now.saturating_duration_since(prev_t) < Duration::from_millis(220) + }); + if !debounced { + last_fired = Some((tray_ev, now)); + record_foreground_window(); + handler.emit(tray_ev); + } + } + } + } + + let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 }; + let _ = libc::poll(&mut pfd, 1, 150); + } + + for &(mods, keycode, _) in &grabbed { + for extra in LOCK_COMBOS { + let _ = x_ungrab_key(dpy, keycode as libc::c_int, mods | extra, root); + } + } + let _ = x_sync(dpy, 0); + let _ = x_close_display(dpy); + } + }) + .ok() +} + +/// Returns true when a line from `gdbus monitor` or `dbus-monitor` indicates +/// `org.freedesktop.login1.Manager.PrepareForSleep(false)` (system waking up). +pub fn is_prepare_for_sleep_resume_line(line: &str) -> bool { + let trimmed = line.trim(); + if !trimmed.contains("PrepareForSleep") { + return false; + } + trimmed.contains("(false") || trimmed.contains("boolean false") || trimmed.ends_with(" false") +} + +/// Returns true when a line from `gdbus monitor` or `dbus-monitor` indicates +/// an `org.freedesktop.portal.Settings.SettingChanged` signal for +/// `"org.freedesktop.appearance"` (`"color-scheme"`, `"contrast"`, or `"accent-color"`). +pub fn is_portal_appearance_changed_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.contains("SettingChanged") + && trimmed.contains("org.freedesktop.appearance") + && (trimmed.contains("color-scheme") + || trimmed.contains("contrast") + || trimmed.contains("accent-color")) +} + +type TrayEventHandler = Box; + +struct SharedHandler { + inner: Mutex>, +} + +// SAFETY: Access to `inner` is serialized by its `Mutex`, and all invocations +// either run on the owning thread or forward thread-safe `TrayEvent` variants. +unsafe impl Send for SharedHandler {} +unsafe impl Sync for SharedHandler {} + +impl SharedHandler { + fn new(handler: impl Fn(TrayEvent) + 'static) -> Arc { + Arc::new(Self { inner: Mutex::new(Some(Box::new(handler))) }) + } + + fn emit(&self, event: TrayEvent) { + if let Ok(guard) = self.inner.lock() + && let Some(cb) = guard.as_ref() + { + cb(event); + } + } + + fn clear(&self) { + if let Ok(mut guard) = self.inner.lock() { + *guard = None; + } + } +} + +fn tray_icon_cache_path() -> PathBuf { + if let Some(dir) = dirs::runtime_dir().or_else(dirs::cache_dir).filter(|p| p.is_absolute()) { + dir.join("nectarlink").join("tray-icon.png") + } else { + let uid = unsafe { libc::geteuid() }; + env::temp_dir().join(format!("nectarlink-{uid}")).join("tray-icon.png") + } +} + +fn spawn_dbus_monitor_reader( + mut child: Child, + stop: Arc, + handler: Arc, + is_login1: bool, +) -> Option> { + let stdout = child.stdout.take()?; + std::thread::Builder::new() + .name(if is_login1 { "tray-login1-monitor".into() } else { "tray-portal-monitor".into() }) + .spawn(move || { + let reader = BufReader::new(stdout); + let mut awaiting_sleep_bool = false; + for line in reader.lines() { + if stop.load(Ordering::Acquire) { + break; + } + let Ok(line) = line else { break }; + if is_login1 { + if is_prepare_for_sleep_resume_line(&line) { + awaiting_sleep_bool = false; + handler.emit(TrayEvent::Resumed); + } else if line.contains("PrepareForSleep") { + awaiting_sleep_bool = !line.contains("true"); + } else if awaiting_sleep_bool { + let trimmed = line.trim(); + if trimmed.contains("boolean false") { + awaiting_sleep_bool = false; + handler.emit(TrayEvent::Resumed); + } else if trimmed.contains("boolean true") { + awaiting_sleep_bool = false; + } + } + } else if is_portal_appearance_changed_line(&line) { + handler.emit(TrayEvent::AppearanceChanged); + } + } + }) + .ok() +} + +const TRAY_HELPER_SCRIPT: &str = r#" +import os, sys, threading +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, GLib + +icon_path = sys.argv[1] +tooltip = sys.argv[2] +labels = sys.argv[3:11] +icon_dir = os.path.dirname(icon_path) +icon_name = os.path.splitext(os.path.basename(icon_path))[0] + +def emit(action): + try: + sys.stdout.write(action + "\n") + sys.stdout.flush() + except Exception: + pass + +menu = Gtk.Menu() +def add_item(label, action): + item = Gtk.MenuItem(label=label) + item.connect('activate', lambda _: emit(action)) + menu.append(item) + return item + +open_item = add_item(labels[0], 'OPEN') +add_item(labels[1], 'COMMAND_PALETTE') +add_item(labels[2], 'SHELF') +menu.append(Gtk.SeparatorMenuItem()) +add_item(labels[3], 'TAKE_PHOTO') +add_item(labels[4], 'SCAN_DOCUMENT') +find_phone_item = add_item(labels[5], 'FIND_PHONE') +find_phone_item.set_sensitive(False) +add_item(labels[6], 'OPEN_LINK') +menu.append(Gtk.SeparatorMenuItem()) +add_item(labels[7], 'QUIT') +menu.show_all() + +ind = None +status_icon = None +try: + try: + gi.require_version('AyatanaAppIndicator3', '0.1') + from gi.repository import AyatanaAppIndicator3 as AppIndicator + except Exception: + gi.require_version('AppIndicator3', '0.1') + from gi.repository import AppIndicator3 as AppIndicator + ind = AppIndicator.Indicator.new_with_path( + 'nectarlink', + icon_name, + AppIndicator.IndicatorCategory.APPLICATION_STATUS, + icon_dir, + ) + ind.set_status(AppIndicator.IndicatorStatus.ACTIVE) + ind.set_title(tooltip) + ind.set_icon_full(icon_path, tooltip) + ind.set_menu(menu) + try: + ind.set_secondary_activate_target(open_item) + except Exception: + pass +except Exception: + status_icon = Gtk.StatusIcon.new_from_file(icon_path) + status_icon.set_tooltip_text(tooltip) + status_icon.set_visible(True) + status_icon.connect('activate', lambda _: emit('OPEN')) + status_icon.connect( + 'popup-menu', + lambda icon, button, time: menu.popup(None, None, Gtk.StatusIcon.position_menu, icon, button, time), + ) + +def apply_cmd(line): + parts = line.rstrip('\r\n').split('\t', 1) + if len(parts) != 2: + return False + cmd, val = parts + if cmd == 'TOOLTIP': + if ind is not None: + ind.set_title(val) + ind.set_icon_full(icon_path, val) + if status_icon is not None: + status_icon.set_tooltip_text(val) + elif cmd == 'FIND_PHONE': + find_phone_item.set_sensitive(val == '1') + return False + +def stdin_reader(): + try: + for line in sys.stdin: + GLib.idle_add(apply_cmd, line) + except Exception: + pass + GLib.idle_add(Gtk.main_quit) + +t = threading.Thread(target=stdin_reader, daemon=True) +t.start() +Gtk.main() +"#; + +pub(crate) fn parse_tray_helper_action(line: &str) -> Option { + match line.trim() { + "OPEN" => Some(TrayEvent::Open), + "COMMAND_PALETTE" => Some(TrayEvent::CommandPalette), + "SHELF" => Some(TrayEvent::Shelf), + "TAKE_PHOTO" => Some(TrayEvent::TakePhoto), + "SCAN_DOCUMENT" => Some(TrayEvent::ScanDocument), + "FIND_PHONE" => Some(TrayEvent::FindPhone), + "OPEN_LINK" => Some(TrayEvent::OpenLinkOnPhone), + "QUIT" => Some(TrayEvent::Quit), + _ => None, + } +} + +pub struct Tray { + tooltip: Mutex, + labels: Mutex, + find_phone_enabled: Mutex, + icon_path: PathBuf, + stop: Arc, + handler: Arc, + helper_stdin: Mutex>, + children: Mutex>, + threads: Mutex>>, +} + +impl std::fmt::Debug for Tray { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Tray") + .field("tooltip", &self.tooltip) + .field("find_phone_enabled", &self.find_phone_enabled) + .field("icon_path", &self.icon_path) + .finish() + } +} impl Tray { - pub fn create( - _tooltip: &str, - _labels: MenuLabels, - _handler: impl Fn(TrayEvent) + 'static, - ) -> io::Result { - Ok(Tray) - } - pub fn set_tooltip(&self, _text: &str) {} - pub fn set_find_phone_enabled(&self, _enabled: bool) {} + pub fn create(tooltip: &str, labels: MenuLabels, handler: impl Fn(TrayEvent) + 'static) -> Result { + let icon_path = tray_icon_cache_path(); + if let Err(e) = super::icon::write_png(&icon_path, 32) { + tracing::debug!(error = %e, path = %icon_path.display(), "could not write tray icon PNG"); + } + + let stop = Arc::new(AtomicBool::new(false)); + let shared_handler = SharedHandler::new(handler); + let mut helper_stdin = None; + let mut children = Vec::new(); + let mut threads = Vec::new(); + + if !cfg!(test) && has_desktop_session() { + record_foreground_window(); + + // 0. StatusNotifierItem / Gtk.StatusIcon system tray indicator helper. + let tray_child = Command::new("python3") + .args([ + "-u", + "-c", + TRAY_HELPER_SCRIPT, + &icon_path.to_string_lossy(), + tooltip, + &labels.open, + &labels.command_palette, + &labels.shelf, + &labels.take_photo, + &labels.scan_document, + &labels.find_phone, + &labels.open_link, + &labels.quit, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + + if let Ok(mut child) = tray_child { + helper_stdin = child.stdin.take(); + let stdout = child.stdout.take(); + children.push(child); + if let Some(stdout) = stdout { + let reader = BufReader::new(stdout); + let stop_clone = Arc::clone(&stop); + let handler_clone = Arc::clone(&shared_handler); + if let Ok(handle) = + std::thread::Builder::new().name("tray-sni-helper".into()).spawn(move || { + for line in reader.lines() { + if stop_clone.load(Ordering::Acquire) { + break; + } + let Ok(line) = line else { break }; + if let Some(ev) = parse_tray_helper_action(&line) { + if matches!( + ev, + TrayEvent::CommandPalette + | TrayEvent::TakePhoto + | TrayEvent::ScanDocument + ) { + record_foreground_window(); + } + handler_clone.emit(ev); + } + } + }) + { + threads.push(handle); + } + } + } + + // 1. System bus org.freedesktop.login1.Manager PrepareForSleep monitor. + let login1_child = Command::new("gdbus") + .args([ + "monitor", + "--system", + "--dest", + "org.freedesktop.login1", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .or_else(|_| { + Command::new("dbus-monitor") + .args([ + "--system", + "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep'", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + }); + if let Ok(mut child) = login1_child { + let stdout = child.stdout.take(); + children.push(child); + if let Some(stdout) = stdout { + let fake_child_reader = BufReader::new(stdout); + let stop_clone = Arc::clone(&stop); + let handler_clone = Arc::clone(&shared_handler); + if let Ok(handle) = + std::thread::Builder::new().name("tray-login1-monitor".into()).spawn(move || { + let mut awaiting_sleep_bool = false; + for line in fake_child_reader.lines() { + if stop_clone.load(Ordering::Acquire) { + break; + } + let Ok(line) = line else { break }; + if is_prepare_for_sleep_resume_line(&line) { + awaiting_sleep_bool = false; + handler_clone.emit(TrayEvent::Resumed); + } else if line.contains("PrepareForSleep") { + awaiting_sleep_bool = !line.contains("true"); + } else if awaiting_sleep_bool { + let trimmed = line.trim(); + if trimmed.contains("boolean false") { + awaiting_sleep_bool = false; + handler_clone.emit(TrayEvent::Resumed); + } else if trimmed.contains("boolean true") { + awaiting_sleep_bool = false; + } + } + } + }) + { + threads.push(handle); + } + } + } + + // 2. Session bus org.freedesktop.portal.Settings SettingChanged monitor. + let portal_child = Command::new("gdbus") + .args([ + "monitor", + "--session", + "--dest", + "org.freedesktop.portal.Desktop", + "--object-path", + "/org/freedesktop/portal/desktop", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + if let Ok(mut child) = portal_child { + let stdout = child.stdout.take(); + children.push(child); + if let Some(stdout) = stdout { + let reader = BufReader::new(stdout); + let stop_clone = Arc::clone(&stop); + let handler_clone = Arc::clone(&shared_handler); + if let Ok(handle) = + std::thread::Builder::new().name("tray-portal-monitor".into()).spawn(move || { + for line in reader.lines() { + if stop_clone.load(Ordering::Acquire) { + break; + } + let Ok(line) = line else { break }; + if is_portal_appearance_changed_line(&line) { + handler_clone.emit(TrayEvent::AppearanceChanged); + } + } + }) + { + threads.push(handle); + } + } + } + } + + if let Some(handle) = spawn_x11_hotkey_thread(Arc::clone(&stop), Arc::clone(&shared_handler)) { + threads.push(handle); + } + + let _ = spawn_dbus_monitor_reader; + + Ok(Tray { + tooltip: Mutex::new(tooltip.to_string()), + labels: Mutex::new(labels), + find_phone_enabled: Mutex::new(false), + icon_path, + stop, + handler: shared_handler, + helper_stdin: Mutex::new(helper_stdin), + children: Mutex::new(children), + threads: Mutex::new(threads), + }) + } + + pub fn set_tooltip(&self, text: &str) { + if let Ok(mut guard) = self.tooltip.lock() { + *guard = text.to_string(); + } + if let Ok(mut guard) = self.helper_stdin.lock() + && let Some(stdin) = guard.as_mut() + { + let sanitized = text.replace(['\r', '\n', '\t'], " "); + let _ = writeln!(stdin, "TOOLTIP\t{sanitized}"); + let _ = stdin.flush(); + } + } + + /// Whether "Find my phone" is offered (a phone is paired). + pub fn set_find_phone_enabled(&self, enabled: bool) { + if let Ok(mut guard) = self.find_phone_enabled.lock() { + *guard = enabled; + } + if let Ok(mut guard) = self.helper_stdin.lock() + && let Some(stdin) = guard.as_mut() + { + let flag = if enabled { "1" } else { "0" }; + let _ = writeln!(stdin, "FIND_PHONE\t{flag}"); + let _ = stdin.flush(); + } + } +} + +impl Drop for Tray { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + self.handler.clear(); + if let Ok(mut stdin_guard) = self.helper_stdin.lock() { + let _ = stdin_guard.take(); + } + if let Ok(mut children) = self.children.lock() { + for child in children.iter_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + children.clear(); + } + if let Ok(mut threads) = self.threads.lock() { + for handle in threads.drain(..) { + let _ = handle.join(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hotkey_recognizes_modifiers_and_keys() { + let (mods, vk) = parse_hotkey("Ctrl+Alt+C").expect("Ctrl+Alt+C"); + assert_eq!(mods, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT); + assert_eq!(vk, u32::from(b'C')); + + let (mods, vk) = parse_hotkey("Ctrl+Alt+Space").expect("Ctrl+Alt+Space"); + assert_eq!(mods, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT); + assert_eq!(vk, 0x20); + + let (mods, vk) = parse_hotkey(" ctrl + shift + f10 ").expect("ctrl+shift+f10"); + assert_eq!(mods, MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT); + assert_eq!(vk, 0x79); + + let (mods, vk) = parse_hotkey("Super+Shift+F12").expect("Super+Shift+F12"); + assert_eq!(mods, MOD_WIN | MOD_SHIFT | MOD_NOREPEAT); + assert_eq!(vk, 0x70 + 11); + + let (mods, vk) = parse_hotkey("Alt+Tab").expect("Alt+Tab"); + assert_eq!(mods, MOD_ALT | MOD_NOREPEAT); + assert_eq!(vk, 0x09); + + let (_, vk) = parse_hotkey("Ctrl+Enter").expect("Ctrl+Enter"); + assert_eq!(vk, 0x0D); + let (_, vk) = parse_hotkey("Ctrl+Esc").expect("Ctrl+Esc"); + assert_eq!(vk, 0x1B); + let (_, vk) = parse_hotkey("Ctrl+Ins").expect("Ctrl+Ins"); + assert_eq!(vk, 0x2D); + let (_, vk) = parse_hotkey("Ctrl+Del").expect("Ctrl+Del"); + assert_eq!(vk, 0x2E); + let (_, vk) = parse_hotkey("Ctrl+Home").expect("Ctrl+Home"); + assert_eq!(vk, 0x24); + let (_, vk) = parse_hotkey("Ctrl+End").expect("Ctrl+End"); + assert_eq!(vk, 0x23); + let (_, vk) = parse_hotkey("Ctrl+PgUp").expect("Ctrl+PgUp"); + assert_eq!(vk, 0x21); + let (_, vk) = parse_hotkey("Ctrl+PgDn").expect("Ctrl+PgDn"); + assert_eq!(vk, 0x22); + let (_, vk) = parse_hotkey("Ctrl+Left").expect("Ctrl+Left"); + assert_eq!(vk, 0x25); + let (_, vk) = parse_hotkey("Ctrl+Up").expect("Ctrl+Up"); + assert_eq!(vk, 0x26); + let (_, vk) = parse_hotkey("Ctrl+Right").expect("Ctrl+Right"); + assert_eq!(vk, 0x27); + let (_, vk) = parse_hotkey("Ctrl+Down").expect("Ctrl+Down"); + assert_eq!(vk, 0x28); + let (_, vk) = parse_hotkey("Ctrl+Plus").expect("Ctrl+Plus"); + assert_eq!(vk, 0xBB); + let (_, vk) = parse_hotkey("Ctrl+Minus").expect("Ctrl+Minus"); + assert_eq!(vk, 0xBD); + let (_, vk) = parse_hotkey("Ctrl+Comma").expect("Ctrl+Comma"); + assert_eq!(vk, 0xBC); + let (_, vk) = parse_hotkey("Ctrl+Period").expect("Ctrl+Period"); + assert_eq!(vk, 0xBE); + } + + #[test] + fn parse_hotkey_rejects_missing_modifier_or_key() { + assert!(parse_hotkey("").is_none()); + assert!(parse_hotkey("none").is_none()); + assert!(parse_hotkey("NONE").is_none()); + assert!(parse_hotkey("C").is_none()); + assert!(parse_hotkey("F5").is_none()); + assert!(parse_hotkey("Ctrl+Alt").is_none()); + assert!(parse_hotkey("Ctrl+Alt+C+D").is_none()); + assert!(parse_hotkey("Ctrl+F13").is_none()); + assert!(parse_hotkey("Ctrl+UnknownKey").is_none()); + } + + #[test] + fn sleep_and_portal_appearance_parsers_match_signals() { + assert!(is_prepare_for_sleep_resume_line( + "/org/freedesktop/login1: org.freedesktop.login1.Manager.PrepareForSleep (false,)" + )); + assert!(!is_prepare_for_sleep_resume_line( + "/org/freedesktop/login1: org.freedesktop.login1.Manager.PrepareForSleep (true,)" + )); + + assert!(is_portal_appearance_changed_line( + "/org/freedesktop/portal/desktop: org.freedesktop.portal.Settings.SettingChanged ('org.freedesktop.appearance', 'color-scheme', )" + )); + assert!(is_portal_appearance_changed_line( + "/org/freedesktop/portal/desktop: org.freedesktop.portal.Settings.SettingChanged ('org.freedesktop.appearance', 'contrast', )" + )); + assert!(!is_portal_appearance_changed_line( + "/org/freedesktop/portal/desktop: org.freedesktop.portal.Settings.SettingChanged ('org.gnome.desktop.interface', 'font-name', <'Sans 11'>)" + )); + } + + #[test] + fn tray_lifecycle_and_window_tracking_work() { + { + let mut guard = LAST_EXTERNAL_WINDOW.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some((0x4242, "LibreOffice Writer".into())); + } + assert!(is_window_alive_and_visible(0x4242)); + assert!(focus_window(0x4242)); + let snap = snapshot_target_window().expect("snapshot window"); + assert!(!snap.1.is_empty()); + + let labels = MenuLabels { + open: "Open".into(), + command_palette: "Command Palette".into(), + shelf: "Shelf".into(), + take_photo: "Take photo".into(), + scan_document: "Scan document".into(), + find_phone: "Find phone".into(), + open_link: "Open link".into(), + quit: "Quit".into(), + }; + let tray = Tray::create("NectarLink", labels, |_| {}).expect("create tray"); + tray.set_tooltip("NectarLink · Pixel 9"); + tray.set_find_phone_enabled(true); + assert!(tray.icon_path.exists()); + drop(tray); + } + + #[test] + fn parse_tray_helper_action_maps_all_menu_items() { + assert_eq!(parse_tray_helper_action("OPEN\n"), Some(TrayEvent::Open)); + assert_eq!(parse_tray_helper_action("COMMAND_PALETTE"), Some(TrayEvent::CommandPalette)); + assert_eq!(parse_tray_helper_action("SHELF"), Some(TrayEvent::Shelf)); + assert_eq!(parse_tray_helper_action("TAKE_PHOTO"), Some(TrayEvent::TakePhoto)); + assert_eq!(parse_tray_helper_action("SCAN_DOCUMENT"), Some(TrayEvent::ScanDocument)); + assert_eq!(parse_tray_helper_action("FIND_PHONE"), Some(TrayEvent::FindPhone)); + assert_eq!(parse_tray_helper_action("OPEN_LINK"), Some(TrayEvent::OpenLinkOnPhone)); + assert_eq!(parse_tray_helper_action("QUIT"), Some(TrayEvent::Quit)); + assert_eq!(parse_tray_helper_action("UNKNOWN"), None); + } + + #[test] + fn x11_hotkey_mapping_covers_modifiers_and_virtual_keys() { + let (mods, vk) = parse_hotkey("Ctrl+Alt+Space").expect("Ctrl+Alt+Space"); + assert_eq!(mods_to_x11_mask(mods), X11_CONTROL_MASK | X11_MOD1_MASK); + assert_eq!(vk_to_x11_keysym(vk), Some(0x0020)); + + let (mods, vk) = parse_hotkey("Super+Shift+C").expect("Super+Shift+C"); + assert_eq!(mods_to_x11_mask(mods), X11_MOD4_MASK | X11_SHIFT_MASK); + assert_eq!(vk_to_x11_keysym(vk), Some(u64::from(b'c'))); + + let (_, vk_f1) = parse_hotkey("Ctrl+F1").expect("Ctrl+F1"); + assert_eq!(vk_to_x11_keysym(vk_f1), Some(0xFFBE)); + let (_, vk_f12) = parse_hotkey("Ctrl+F12").expect("Ctrl+F12"); + assert_eq!(vk_to_x11_keysym(vk_f12), Some(0xFFBE + 11)); + assert_eq!(vk_to_x11_keysym(0x0D), Some(0xFF0D)); + assert_eq!(vk_to_x11_keysym(0x1B), Some(0xFF1B)); + assert_eq!(vk_to_x11_keysym(0xFFFF), None); + + let before = HOTKEY_GENERATION.load(Ordering::Acquire); + update_hotkeys("Ctrl+Alt+C", "Ctrl+Alt+D", "Ctrl+Alt+Space", "Ctrl+Alt+S"); + assert!(HOTKEY_GENERATION.load(Ordering::Acquire) > before); + } } diff --git a/desktop/app/src/linux/widget.rs b/desktop/app/src/linux/widget.rs index 2bebeaf1..0e3d34d6 100644 --- a/desktop/app/src/linux/widget.rs +++ b/desktop/app/src/linux/widget.rs @@ -1,12 +1,274 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Phone Status desktop widget snapshot and Adaptive Card provider (`--widget-provider`) +//! on Linux, matching `desktop/app/src/win/widget.rs`. -use std::{path::Path, process::ExitCode}; +use std::{ + fs, + io::{self, BufRead}, + path::{Path, PathBuf}, + process::ExitCode, +}; +use nectarlink_core::LinkState; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{core_host, launch::Request, win::single_instance}; + +/// CLI flag launched for the widget provider mode. pub const ARG: &str = "--widget-provider"; + +/// Widget definition ID in the widget manifest. pub const WIDGET_ID: &str = "NectarlinkPhoneStatus"; -pub fn write_state_snapshot(_data_dir: &Path) {} -pub fn run_provider(_data_dir: &Path) -> ExitCode { +/// Snapshot of the primary phone's live status for the widget card. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WidgetState { + pub connected: bool, + pub phone_name: String, + pub battery_level: Option, + pub battery_charging: bool, + pub last_clip_preview: String, +} + +/// Path to `widget-state.json` inside `data_dir`. +pub fn state_snapshot_path(data_dir: &Path) -> PathBuf { + data_dir.join("widget-state.json") +} + +/// Writes `state` to `/widget-state.json`. +pub fn write_snapshot_file(data_dir: &Path, state: &WidgetState) -> io::Result<()> { + let path = state_snapshot_path(data_dir); + let json = serde_json::to_vec_pretty(state).map_err(io::Error::other)?; + fs::write(path, json) +} + +/// Reads `/widget-state.json`, or returns [`WidgetState::default`] if missing/corrupt. +pub fn read_snapshot_file(data_dir: &Path) -> WidgetState { + let path = state_snapshot_path(data_dir); + fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_default() +} + +/// Captures the current primary phone state from [`core_host`] and writes `widget-state.json`. +pub fn write_state_snapshot(data_dir: &Path) { + let last_clip_preview = core_host::node() + .and_then(|n| n.clipboard_history(None).into_iter().next()) + .map(|c| truncate_preview(&c.text, 120)) + .unwrap_or_default(); + let state = core_host::host().hub.read(|s| { + let primary = s + .devices + .iter() + .find(|d| matches!(d.link, LinkState::Online { .. })) + .or_else(|| s.devices.first()); + match primary { + Some(dev) => WidgetState { + connected: matches!(dev.link, LinkState::Online { .. }), + phone_name: dev.info.name.clone(), + battery_level: dev.battery.as_ref().map(|b| b.level), + battery_charging: dev.battery.as_ref().is_some_and(|b| b.charging), + last_clip_preview, + }, + None => WidgetState { last_clip_preview, ..WidgetState::default() }, + } + }); + if let Err(e) = write_snapshot_file(data_dir, &state) { + tracing::debug!(error = %e, "could not write widget-state.json"); + } +} + +fn truncate_preview(text: &str, max_chars: usize) -> String { + let single_line: String = text.split_whitespace().collect::>().join(" "); + if single_line.chars().count() <= max_chars { + single_line + } else { + let mut s: String = single_line.chars().take(max_chars.saturating_sub(1)).collect(); + s.push('…'); + s + } +} + +/// Builds the Adaptive Card v1.5 JSON payload for `state`. +pub fn render_adaptive_card(state: &WidgetState) -> String { + let status_text = if state.phone_name.is_empty() { + "No phone paired".to_owned() + } else if state.connected { + format!("{} · Connected", state.phone_name) + } else { + format!("{} · Offline", state.phone_name) + }; + + let battery_text = match state.battery_level { + Some(pct) if state.battery_charging => format!("Battery: {pct}% (Charging)"), + Some(pct) => format!("Battery: {pct}%"), + None => "Battery: —".to_owned(), + }; + + let clip_text = if state.last_clip_preview.trim().is_empty() { + "No recent clipboard item".to_owned() + } else { + state.last_clip_preview.clone() + }; + + let card: Value = json!({ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": status_text, + "weight": "Bolder", + "size": "Medium", + "wrap": true + }, + { + "type": "TextBlock", + "text": battery_text, + "spacing": "Small", + "isSubtle": true, + "wrap": true + }, + { + "type": "TextBlock", + "text": "Latest Clipboard", + "weight": "Bolder", + "spacing": "Medium", + "size": "Small" + }, + { + "type": "TextBlock", + "text": clip_text, + "spacing": "None", + "isSubtle": true, + "maxLines": 2, + "wrap": true + } + ], + "actions": [ + { + "type": "Action.Execute", + "title": "Send File", + "verb": "send_file" + }, + { + "type": "Action.Execute", + "title": "Find My Phone", + "verb": "find_phone" + }, + { + "type": "Action.Execute", + "title": "Mirror Screen", + "verb": "mirror_screen" + } + ] + }); + card.to_string() +} + +/// Dispatches an Adaptive Card `Action.Execute` verb (`"send_file"`, `"find_phone"`, `"mirror_screen"`, `"open"`) +/// to the running NectarLink instance (or launches NectarLink with `--task ` if not running). +pub fn handle_action_verb(verb: &str, data_dir: Option<&Path>) -> Result<(), String> { + let action = match verb.trim() { + "send_file" => "send_file", + "find_phone" => "find_phone", + "mirror_screen" => "mirror_screen", + "open" | "" => "open", + other => return Err(format!("unknown widget action verb: {other}")), + }; + + if let Some(dir) = data_dir { + let req = if action == "open" { Request::Show } else { Request::Task { action: action.to_owned() } }; + if crate::launch::queue(dir, &req).is_ok() { + single_instance::wake(dir); + return Ok(()); + } + } + + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let mut cmd = std::process::Command::new(exe); + if let Some(dir) = data_dir { + cmd.arg("--data-dir").arg(dir); + } + if action != "open" { + cmd.arg(crate::win::jumplist::ARG).arg(action); + } + cmd.spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +/// Runs the `--widget-provider` out-of-process host loop. +pub fn run_provider(data_dir: &Path) -> ExitCode { + let state = read_snapshot_file(data_dir); + let card = render_adaptive_card(&state); + tracing::info!(widget_id = WIDGET_ID, "widget provider started"); + println!("{card}"); + + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.eq_ignore_ascii_case("quit") || trimmed.eq_ignore_ascii_case("exit") { + break; + } + if trimmed.eq_ignore_ascii_case("refresh") { + let refreshed = read_snapshot_file(data_dir); + println!("{}", render_adaptive_card(&refreshed)); + continue; + } + if let Some(verb) = trimmed.strip_prefix("verb:") { + let _ = handle_action_verb(verb, Some(data_dir)); + let refreshed = read_snapshot_file(data_dir); + println!("{}", render_adaptive_card(&refreshed)); + } + } ExitCode::SUCCESS } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_valid_adaptive_card_for_connected_and_empty_states() { + let empty = WidgetState::default(); + let empty_json: Value = serde_json::from_str(&render_adaptive_card(&empty)).unwrap(); + assert_eq!(empty_json["type"], "AdaptiveCard"); + assert_eq!(empty_json["version"], "1.5"); + assert_eq!(empty_json["body"][0]["text"], "No phone paired"); + assert_eq!(empty_json["actions"].as_array().unwrap().len(), 3); + + let connected = WidgetState { + connected: true, + phone_name: "Pixel 9 Pro".into(), + battery_level: Some(84), + battery_charging: true, + last_clip_preview: "https://nectarlink.dev/docs".into(), + }; + let card_str = render_adaptive_card(&connected); + let parsed: Value = serde_json::from_str(&card_str).unwrap(); + assert_eq!(parsed["body"][0]["text"], "Pixel 9 Pro · Connected"); + assert_eq!(parsed["body"][1]["text"], "Battery: 84% (Charging)"); + assert_eq!(parsed["body"][3]["text"], "https://nectarlink.dev/docs"); + } + + #[test] + fn snapshot_file_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let state = WidgetState { + connected: true, + phone_name: "Galaxy S24".into(), + battery_level: Some(62), + battery_charging: false, + last_clip_preview: "Copied text".into(), + }; + write_snapshot_file(dir.path(), &state).unwrap(); + let loaded = read_snapshot_file(dir.path()); + assert_eq!(loaded, state); + } +} diff --git a/desktop/app/src/linux/wifi.rs b/desktop/app/src/linux/wifi.rs index b9030a8a..638a66c9 100644 --- a/desktop/app/src/linux/wifi.rs +++ b/desktop/app/src/linux/wifi.rs @@ -1,5 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later -//! Linux Wi-Fi scan and connection helper for Instant Hotspot (stub). +//! Linux Wi-Fi scan and connection helper for Instant Hotspot via NetworkManager (`nmcli`). + +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InRange { + pub profile: String, + pub connectable: bool, + pub connected: bool, +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Reach { @@ -8,16 +17,128 @@ pub enum Reach { Connected, } +fn tidy(mut names: Vec) -> Vec { + names.retain(|n| !n.trim().is_empty()); + names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); + names.dedup(); + names +} + +fn parse_saved_profiles(stdout: &str) -> Vec { + let mut names = Vec::new(); + for line in stdout.lines() { + // Format from `nmcli -t -f TYPE,NAME connection show`: + // 802-11-wireless:MyHotspot + if let Some((kind, name)) = line.split_once(':') + && (kind == "802-11-wireless" || kind == "wifi") + { + let trimmed = name.trim(); + if !trimmed.is_empty() { + names.push(trimmed.to_string()); + } + } + } + tidy(names) +} + pub fn saved_networks() -> Vec { - Vec::new() + let Ok(out) = Command::new("nmcli").args(["-t", "-f", "TYPE,NAME", "connection", "show"]).output() else { + return Vec::new(); + }; + if !out.status.success() { + return Vec::new(); + } + parse_saved_profiles(&String::from_utf8_lossy(&out.stdout)) +} + +pub fn scan() { + let _ = Command::new("nmcli").args(["device", "wifi", "rescan"]).output(); +} + +fn parse_in_range(stdout: &str) -> Vec { + let mut out = Vec::new(); + for line in stdout.lines() { + // Format from `nmcli -t -f ACTIVE,SSID device wifi list`: + // yes:MyHotspot + if let Some((active, ssid)) = line.split_once(':') { + let ssid = ssid.trim(); + if !ssid.is_empty() { + out.push(InRange { + profile: ssid.to_string(), + connectable: true, + connected: active.trim() == "yes", + }); + } + } + } + out +} + +fn reach_in(networks: &[InRange], profile: &str) -> Reach { + let mine = networks.iter().filter(|n| n.profile == profile); + if mine.clone().any(|n| n.connected) { + Reach::Connected + } else if mine.clone().any(|n| n.connectable) { + Reach::InRange + } else { + Reach::NotInRange + } } -pub fn scan() {} +pub fn reach(profile: &str) -> Reach { + let Ok(out) = Command::new("nmcli").args(["-t", "-f", "ACTIVE,SSID", "device", "wifi", "list"]).output() + else { + return Reach::NotInRange; + }; + if !out.status.success() { + return Reach::NotInRange; + } + let networks = parse_in_range(&String::from_utf8_lossy(&out.stdout)); + reach_in(&networks, profile) +} -pub fn reach(_profile: &str) -> Reach { - Reach::NotInRange +pub fn connect(profile: &str) -> Result<(), String> { + let trimmed = profile.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') || trimmed.chars().any(char::is_control) { + return Err("invalid Wi-Fi profile name".into()); + } + let out = Command::new("nmcli") + .args(["connection", "up", "id", trimmed]) + .output() + .map_err(|e| format!("failed to run nmcli: {e}"))?; + if out.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Err(if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("nmcli exited with {}", out.status) + }) + } } -pub fn connect(_profile: &str) -> Result<(), String> { - Err("Wi-Fi hotspot connection not supported on Linux yet".into()) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_tidies_saved_wifi_profiles() { + let sample = + "802-11-wireless:Pixel 9a\n802-3-ethernet:Wired connection 1\nwifi:home\nwifi:Home\nwifi:home\n"; + let names = parse_saved_profiles(sample); + assert_eq!(names, vec!["Home", "home", "Pixel 9a"]); + } + + #[test] + fn reach_in_reports_connected_in_range_or_not() { + let sample = "yes:Home\nno:Pixel 9a\n"; + let list = parse_in_range(sample); + assert_eq!(reach_in(&list, "Home"), Reach::Connected); + assert_eq!(reach_in(&list, "Pixel 9a"), Reach::InRange); + assert_eq!(reach_in(&list, "Office"), Reach::NotInRange); + } } diff --git a/desktop/app/src/linux/wol.rs b/desktop/app/src/linux/wol.rs index 82ca300b..42d0a94f 100644 --- a/desktop/app/src/linux/wol.rs +++ b/desktop/app/src/linux/wol.rs @@ -1,5 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +use std::collections::BTreeSet; +use std::fs; +use std::net::Ipv4Addr; +use std::path::Path; +use std::process::Command; + +use nectarlink_core::PcWakeInfo; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WakeSupport { @@ -29,19 +35,234 @@ pub struct WakeAdapter { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct WakeStatus { - pub info: nectarlink_core::PcWakeInfo, + pub info: PcWakeInfo, pub adapters: Vec, } impl WakeStatus { pub fn primary(&self) -> Option<&WakeAdapter> { - self.adapters.first() + self.adapters.iter().find(|a| a.wired).or_else(|| self.adapters.first()) } + pub fn state_str(&self) -> &'static str { - "none" + match self.primary() { + Some(primary) => primary.support.as_str(), + None => "none", + } } } pub fn query_wake_status() -> WakeStatus { - WakeStatus { info: nectarlink_core::PcWakeInfo::default(), adapters: Vec::new() } + let mut adapters = enumerate_wake_adapters(); + adapters.sort_by_key(|a| (!a.wired, a.label.clone())); + + let mut macs = Vec::new(); + let mut broadcast_set = BTreeSet::new(); + for adapter in &adapters { + if !macs.contains(&adapter.mac) { + macs.push(adapter.mac.clone()); + } + for bcast in &adapter.broadcasts { + broadcast_set.insert(bcast.clone()); + } + } + + WakeStatus { info: PcWakeInfo { macs, broadcasts: broadcast_set.into_iter().collect() }, adapters } +} + +fn enumerate_wake_adapters() -> Vec { + let mut result = Vec::new(); + let Ok(entries) = fs::read_dir("/sys/class/net") else { + return result; + }; + let broadcasts_by_iface = query_ipv4_broadcasts(); + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "lo" + || name.starts_with("docker") + || name.starts_with("veth") + || name.starts_with("br-") + || name.starts_with("virbr") + || name.starts_with("tun") + || name.starts_with("tap") + || name.starts_with("wg") + { + continue; + } + let path = entry.path(); + let operstate = + fs::read_to_string(path.join("operstate")).unwrap_or_default().trim().to_ascii_lowercase(); + if operstate != "up" && operstate != "unknown" { + continue; + } + let raw_mac = fs::read_to_string(path.join("address")).unwrap_or_default(); + let bytes: Vec = + raw_mac.trim().split(':').filter_map(|p| u8::from_str_radix(p, 16).ok()).collect(); + let Some(mac) = format_mac(&bytes) else { + continue; + }; + let wired = !path.join("wireless").exists() && !path.join("phy80211").exists(); + let support = probe_adapter_wake_support(&name, &path); + let broadcasts = broadcasts_by_iface.get(&name).cloned().unwrap_or_default(); + + result.push(WakeAdapter { label: name, wired, mac, broadcasts, support }); + } + result +} + +pub fn parse_ethtool_wol(output: &str) -> Option { + let mut supports_magic = false; + let mut active_modes: Option<&str> = None; + + for line in output.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("Supports Wake-on:") { + if rest.trim().contains('g') { + supports_magic = true; + } + } else if let Some(rest) = trimmed.strip_prefix("Wake-on:") { + active_modes = Some(rest.trim()); + } + } + + let modes = active_modes?; + if modes.contains('g') { + Some(true) + } else if supports_magic || modes.contains('d') { + Some(false) + } else { + None + } +} + +fn probe_adapter_wake_support(iface_name: &str, iface_sys_path: &Path) -> WakeSupport { + if let Ok(output) = Command::new("ethtool").arg(iface_name).output() + && output.status.success() + { + let stdout = String::from_utf8_lossy(&output.stdout); + match parse_ethtool_wol(&stdout) { + Some(true) => return WakeSupport::Enabled, + Some(false) => return WakeSupport::Disabled, + None => {} + } + } + let wakeup_path = iface_sys_path.join("device/power/wakeup"); + if let Ok(state) = fs::read_to_string(wakeup_path) { + return match state.trim() { + "enabled" => WakeSupport::Enabled, + "disabled" => WakeSupport::Disabled, + _ => WakeSupport::Unknown, + }; + } + WakeSupport::Unknown +} + +fn query_ipv4_broadcasts() -> std::collections::HashMap> { + let mut map: std::collections::HashMap> = std::collections::HashMap::new(); + let Ok(output) = Command::new("ip").args(["-4", "-o", "addr", "show"]).output() else { + return map; + }; + if !output.status.success() { + return map; + } + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 4 { + continue; + } + let iface = parts[1].to_string(); + for window in parts.windows(2) { + if window[0] == "inet" + && let Some((ip_str, prefix_str)) = window[1].split_once('/') + && let (Ok(ip), Ok(prefix)) = (ip_str.parse::(), prefix_str.parse::()) + && let Some(bcast) = subnet_broadcast(ip, prefix) + { + let list = map.entry(iface.clone()).or_default(); + let s = bcast.to_string(); + if !list.contains(&s) { + list.push(s); + } + } + } + } + map +} + +pub(crate) fn format_mac(bytes: &[u8]) -> Option { + if bytes.len() != 6 { + return None; + } + if bytes.iter().all(|&b| b == 0) || bytes.iter().all(|&b| b == 0xFF) { + return None; + } + Some(format!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5] + )) +} + +pub(crate) fn subnet_broadcast(ip: Ipv4Addr, prefix_len: u8) -> Option { + if prefix_len == 0 || prefix_len >= 32 { + return None; + } + let ip_u32 = u32::from(ip); + let host_mask = (1u32 << (32 - prefix_len)) - 1; + let bcast = ip_u32 | host_mask; + Some(Ipv4Addr::from(bcast)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_valid_mac_and_rejects_invalid() { + assert_eq!(format_mac(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]), Some("AA:BB:CC:DD:EE:FF".to_string())); + assert_eq!(format_mac(&[0, 0, 0, 0, 0, 0]), None); + assert_eq!(format_mac(&[0xff; 6]), None); + assert_eq!(format_mac(&[1, 2, 3]), None); + } + + #[test] + fn calculates_subnet_broadcast() { + assert_eq!( + subnet_broadcast(Ipv4Addr::new(192, 168, 1, 42), 24), + Some(Ipv4Addr::new(192, 168, 1, 255)) + ); + assert_eq!(subnet_broadcast(Ipv4Addr::new(10, 0, 12, 5), 16), Some(Ipv4Addr::new(10, 0, 255, 255))); + assert_eq!(subnet_broadcast(Ipv4Addr::new(192, 168, 1, 1), 32), None); + assert_eq!(subnet_broadcast(Ipv4Addr::new(192, 168, 1, 1), 0), None); + } + + #[test] + fn parses_ethtool_magic_packet_enabled_and_disabled() { + let enabled_output = "\ +Settings for enp3s0: +\tSupported ports: [ TP ] +\tSupports Wake-on: pumbg +\tWake-on: g +\tLink detected: yes +"; + assert_eq!(parse_ethtool_wol(enabled_output), Some(true)); + + let disabled_output = "\ +Settings for enp3s0: +\tSupported ports: [ TP ] +\tSupports Wake-on: pumbg +\tWake-on: d +\tLink detected: yes +"; + assert_eq!(parse_ethtool_wol(disabled_output), Some(false)); + + let unicast_only_output = "\ +Settings for enp3s0: +\tSupports Wake-on: pumbg +\tWake-on: u +"; + assert_eq!(parse_ethtool_wol(unicast_only_output), Some(false)); + + assert_eq!(parse_ethtool_wol("Settings for lo:\n\tLink detected: yes\n"), None); + } } diff --git a/desktop/app/src/pc_notifications.rs b/desktop/app/src/pc_notifications.rs index c39085fd..b76731f7 100644 --- a/desktop/app/src/pc_notifications.rs +++ b/desktop/app/src/pc_notifications.rs @@ -3,6 +3,12 @@ //! connected phones when `forward_pc_notifications_to_phone` is enabled in //! Settings (default `false`, privacy-first). +#[cfg(target_os = "linux")] +use std::{ + io::{BufRead, BufReader}, + process::{Command, Stdio}, + sync::OnceLock, +}; use std::{ sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, @@ -13,8 +19,10 @@ use nectarlink_core::Notification; use crate::{core_host, settings}; static NEXT_ID: AtomicU64 = AtomicU64::new(1); +#[cfg(target_os = "linux")] +static LISTENER_STARTED: OnceLock<()> = OnceLock::new(); -/// Builds a sanitized [`Notification`] representing a Windows PC notification. +/// Builds a sanitized [`Notification`] representing a PC notification. pub fn build_pc_notification( key: &str, app_name: &str, @@ -41,6 +49,97 @@ pub fn build_pc_notification( .sanitized() } +/// Returns `true` when the notification originated from NectarLink itself or a +/// blocked system self-source so we never create a forwarding loop. +#[cfg(any(target_os = "linux", test))] +pub fn is_blocked_self_source(app_name: &str) -> bool { + let lower = app_name.trim().to_lowercase(); + lower.is_empty() + || lower.contains("nectarlink") + || lower == "notify-send" + || lower == "org.freedesktop.notifications" +} + +/// Parses a single-line or compact representation of a Freedesktop `Notify` D-Bus call +/// into `(app_name, summary, body)`. +/// +/// Supports `dbus-monitor` `string "..."` fields from `org.freedesktop.Notifications.Notify`. +#[cfg(any(target_os = "linux", test))] +pub fn parse_dbus_notify_line(line: &str) -> Option<(String, String, String)> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + let mut dbus_strings = Vec::new(); + let mut rest = trimmed; + while let Some(idx) = rest.find("string \"") { + let after = &rest[idx + "string \"".len()..]; + if let Some((val, tail)) = take_unescaped_quoted(after) { + dbus_strings.push(val); + rest = tail; + } else { + break; + } + } + + // In org.freedesktop.Notifications.Notify(app_name, replaces_id, app_icon, summary, body, ...): + // string[0] = app_name, string[1] = app_icon, string[2] = summary, string[3] = body + if dbus_strings.len() >= 4 { + let app_name = dbus_strings[0].trim().to_string(); + let summary = dbus_strings[2].trim().to_string(); + let body = dbus_strings[3].trim().to_string(); + if summary.is_empty() && body.is_empty() { + return None; + } + return Some((app_name, summary, body)); + } + + if dbus_strings.len() == 3 { + let app_name = dbus_strings[0].trim().to_string(); + let summary = dbus_strings[1].trim().to_string(); + let body = dbus_strings[2].trim().to_string(); + if summary.is_empty() && body.is_empty() { + return None; + } + return Some((app_name, summary, body)); + } + + None +} + +#[cfg(any(target_os = "linux", test))] +fn take_unescaped_quoted(s: &str) -> Option<(String, &str)> { + let mut out = String::new(); + let mut escaped = false; + for (idx, ch) in s.char_indices() { + if escaped { + out.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + return Some((out, &s[idx + 1..])); + } else { + out.push(ch); + } + } + None +} + +/// Forwards a captured PC notification if it is not from a blocked self-source, +/// has non-empty content, and `forward_pc_notifications_to_phone` is enabled. +#[cfg(any(target_os = "linux", test))] +pub fn forward_pc_toast(app_name: &str, title: &str, text: &str) -> bool { + if is_blocked_self_source(app_name) { + return false; + } + if title.trim().is_empty() && text.trim().is_empty() { + return false; + } + forward_to_phones(app_name.trim(), title.trim(), text.trim()) +} + /// Forwards a PC notification (`app_name`, `title`, `text`) to connected /// phones when `forward_pc_notifications_to_phone` is enabled. Returns `true` /// if the notification was queued for delivery. @@ -66,6 +165,76 @@ pub fn forward_to_phones(app_name: &str, title: &str, text: &str) -> bool { true } +/// Starts a background listener for desktop notifications on Linux via `dbus-monitor`. +#[cfg(target_os = "linux")] +pub fn start_listener() { + LISTENER_STARTED.get_or_init(|| { + if !cfg!(test) { + let _ = std::thread::Builder::new().name("pc-notifications".into()).spawn(dbus_monitor_loop); + } + }); +} + +#[cfg(target_os = "linux")] +fn dbus_monitor_loop() { + let Ok(mut child) = Command::new("dbus-monitor") + .args(["--session", "type='method_call',interface='org.freedesktop.Notifications',member='Notify'"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return; + }; + + let Some(stdout) = child.stdout.take() else { + return; + }; + + let reader = BufReader::new(stdout); + let mut current_block = String::new(); + let mut in_notify = false; + let mut string_count = 0usize; + + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if trimmed.contains("member=Notify") { + if in_notify && let Some((app, title, body)) = parse_dbus_notify_line(¤t_block) { + let _ = forward_pc_toast(&app, &title, &body); + } + current_block.clear(); + in_notify = true; + string_count = 0; + current_block.push_str(trimmed); + current_block.push(' '); + continue; + } + + if in_notify { + if trimmed.starts_with("string \"") { + current_block.push_str(trimmed); + current_block.push(' '); + string_count += 1; + if string_count >= 4 { + if let Some((app, title, body)) = parse_dbus_notify_line(¤t_block) { + let _ = forward_pc_toast(&app, &title, &body); + } + current_block.clear(); + in_notify = false; + string_count = 0; + } + } else if trimmed.starts_with("method call") || trimmed.starts_with("signal ") { + if let Some((app, title, body)) = parse_dbus_notify_line(¤t_block) { + let _ = forward_pc_toast(&app, &title, &body); + } + current_block.clear(); + in_notify = false; + string_count = 0; + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -90,4 +259,30 @@ mod tests { assert_eq!(notif.title.as_deref(), Some("Build finished")); assert_eq!(notif.text.as_deref(), Some("cargo test succeeded")); } + + #[test] + fn blocks_self_source_notifications() { + assert!(is_blocked_self_source("NectarLink")); + assert!(is_blocked_self_source("nectarlink-desktop")); + assert!(is_blocked_self_source("notify-send")); + assert!(is_blocked_self_source("")); + assert!(!is_blocked_self_source("Firefox")); + assert!(!is_blocked_self_source("Signal Desktop")); + } + + #[test] + fn parses_dbus_notify_lines() { + let sample = r#"method call time=1700000.0 sender=:1.42 -> destination=org.freedesktop.Notifications serial=10 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify string "Firefox" uint32 0 string "firefox" string "Download complete" string "ubuntu.iso" array [] array [] int32 -1"#; + let parsed = parse_dbus_notify_line(sample); + assert_eq!( + parsed, + Some(("Firefox".to_string(), "Download complete".to_string(), "ubuntu.iso".to_string())) + ); + } + + #[test] + fn forward_pc_toast_ignores_self_and_empty() { + assert!(!forward_pc_toast("NectarLink", "Connected", "Pixel 9")); + assert!(!forward_pc_toast("Firefox", " ", "")); + } } diff --git a/desktop/app/src/send_to.rs b/desktop/app/src/send_to.rs index 4661504e..7082ef4a 100644 --- a/desktop/app/src/send_to.rs +++ b/desktop/app/src/send_to.rs @@ -173,6 +173,10 @@ fn reconcile(dir: &Path, desired: &[(DeviceId, String)], exe: &Path) -> io::Resu for (path, link) in &planned { shortcut::write(path, link).map_err(io::Error::other)?; } + #[cfg(target_os = "linux")] + if shortcut::send_to_dir().as_deref() == Some(dir) { + let _ = shortcut::sync_file_manager_menus(dir); + } Ok(()) } diff --git a/desktop/app/src/settings.rs b/desktop/app/src/settings.rs index ba3739f0..abaf4527 100644 --- a/desktop/app/src/settings.rs +++ b/desktop/app/src/settings.rs @@ -198,6 +198,10 @@ pub fn forward_pc_notifications_to_phone() -> bool { pub fn set_forward_pc_notifications_to_phone(on: bool) { FORWARD_PC_NOTIFICATIONS.store(on, std::sync::atomic::Ordering::Relaxed); + #[cfg(target_os = "linux")] + if on { + crate::pc_notifications::start_listener(); + } } /// Whether the PC's clipboard goes to the phone when control moves to it (`input_share`). diff --git a/desktop/app/src/transfers.rs b/desktop/app/src/transfers.rs index b7017603..4369f307 100644 --- a/desktop/app/src/transfers.rs +++ b/desktop/app/src/transfers.rs @@ -217,9 +217,14 @@ pub fn open(path: &Path) { crate::bridge::app::request_activation(); return; } + #[cfg(not(target_os = "linux"))] if let Err(e) = std::process::Command::new("explorer").arg(path).spawn() { tracing::warn!(error = %e, "can't open a received file"); } + #[cfg(target_os = "linux")] + if let Err(e) = crate::win::shell::open_file(path) { + tracing::warn!(error = %e, "can't open a received file"); + } } /// Shows a received file or folder in File Explorer, selected. @@ -228,9 +233,16 @@ pub fn show_in_folder(path: &Path) { crate::bridge::app::request_activation(); return; } - let mut select = std::ffi::OsString::from("/select,"); - select.push(path); - if let Err(e) = std::process::Command::new("explorer").arg(select).spawn() { + #[cfg(not(target_os = "linux"))] + { + let mut select = std::ffi::OsString::from("/select,"); + select.push(path); + if let Err(e) = std::process::Command::new("explorer").arg(select).spawn() { + tracing::warn!(error = %e, "can't show a received file"); + } + } + #[cfg(target_os = "linux")] + if let Err(e) = crate::win::shell::select_in_explorer(path) { tracing::warn!(error = %e, "can't show a received file"); } } From ceb7cb160c166ad6c4c03268e6b9976f3ab7f74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Kirst=C3=A4tter?= <7329802+jochenkirstaetter@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:02:02 +0400 Subject: [PATCH 2/5] fix(linux): address PR #38 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jochen Kirstätter <7329802+jochenkirstaetter@users.noreply.github.com> --- Cargo.lock | 249 +++++++++++++++- Cargo.toml | 1 + desktop/app/Cargo.toml | 1 + desktop/app/build.rs | 8 +- desktop/app/cpp/app_helpers.cpp | 274 ++++++++++++++++++ desktop/app/cpp/app_helpers.h | 28 ++ desktop/app/src/bridge/native.rs | 41 +++ desktop/app/src/doctor.rs | 29 +- desktop/app/src/linux/autostart.rs | 31 +- desktop/app/src/linux/ble_beacon.rs | 17 +- desktop/app/src/linux/clipboard.rs | 65 ++++- desktop/app/src/linux/doctor.rs | 60 +++- desktop/app/src/linux/jumplist.rs | 12 +- desktop/app/src/linux/shell.rs | 30 +- desktop/app/src/linux/shortcut.rs | 18 +- desktop/app/src/linux/toast.rs | 393 +++++++++++-------------- desktop/app/src/linux/tray.rs | 429 ++++++++-------------------- desktop/app/src/main.rs | 6 + desktop/app/src/pc_notifications.rs | 153 ++++++---- 19 files changed, 1188 insertions(+), 657 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b3c4db3..51bd3d6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,40 @@ dependencies = [ "winnow", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "async_io_stream" version = "0.3.3" @@ -1065,6 +1099,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-assoc" version = "1.4.1" @@ -1076,6 +1116,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1092,6 +1153,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1432,6 +1513,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hickory-proto" version = "0.26.3" @@ -2206,6 +2293,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2407,6 +2503,7 @@ dependencies = [ "tracing-subscriber", "windows", "windows-core", + "zbus", ] [[package]] @@ -2859,6 +2956,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3588,6 +3695,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3898,7 +4016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4032,6 +4150,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4282,6 +4401,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.26" @@ -4474,6 +4604,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -5088,6 +5229,71 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.59" @@ -5187,3 +5393,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 524ac0dd..67ada86d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ url = "2.5" windows = "0.62" # The same version windows itself depends on, never newer on its own. windows-core = "0.62" +zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] } [workspace.lints.rust] unsafe_code = "deny" diff --git a/desktop/app/Cargo.toml b/desktop/app/Cargo.toml index 0eaaa449..015e65bd 100644 --- a/desktop/app/Cargo.toml +++ b/desktop/app/Cargo.toml @@ -116,6 +116,7 @@ windows = { workspace = true, features = [ [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" +zbus.workspace = true [build-dependencies] cxx-qt-build = { workspace = true, features = ["link_qt_object_files"] } diff --git a/desktop/app/build.rs b/desktop/app/build.rs index 0803f6b8..b5757ca6 100644 --- a/desktop/app/build.rs +++ b/desktop/app/build.rs @@ -365,8 +365,11 @@ fn main() { let module = QmlModule::new("app.nectarlink") .qml_files(QML.iter().map(|f| QmlFile::from(*f))) .qml_files(QML_SINGLETONS.iter().map(|f| QmlFile::from(*f).singleton(true))); - CxxQtBuilder::new_qml_module(module) - .qt_module("Quick") + let mut builder = CxxQtBuilder::new_qml_module(module).qt_module("Quick"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { + builder = builder.qt_module("Widgets"); + } + builder .include_dir("cpp") .cpp_files([ "cpp/app_helpers.cpp", @@ -407,6 +410,7 @@ fn main() { println!("cargo:rustc-link-lib=user32"); } else if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { println!("cargo:rustc-link-lib=Qt6Quick"); + println!("cargo:rustc-link-lib=Qt6Widgets"); println!( "cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN:$ORIGIN/lib:$ORIGIN/../lib:$ORIGIN/../lib/nectarlink/lib" ); diff --git a/desktop/app/cpp/app_helpers.cpp b/desktop/app/cpp/app_helpers.cpp index 619415c1..8d35aaa8 100644 --- a/desktop/app/cpp/app_helpers.cpp +++ b/desktop/app/cpp/app_helpers.cpp @@ -33,6 +33,20 @@ #include #endif +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + namespace { QIcon &appIcon() { @@ -538,3 +552,263 @@ uint64_t mac_free_space(rust::Str path, bool &ok) ok = true; return static_cast(available); } + +#ifdef __linux__ +namespace { +struct LinuxClipboardWatch { + std::mutex mutex; + std::condition_variable cv; + std::atomic seq { 1 }; +}; + +LinuxClipboardWatch &linuxClipboardWatch() +{ + static LinuxClipboardWatch watch; + return watch; +} + +struct LinuxTrayState { + QPointer icon; + QPointer menu; + QPointer findPhoneAction; + std::mutex mutex; + std::condition_variable cv; + std::deque actions; +}; + +LinuxTrayState &linuxTray() +{ + static LinuxTrayState state; + return state; +} + +void pushTrayAction(const char *action) +{ + auto &st = linuxTray(); + { + std::lock_guard lock(st.mutex); + st.actions.emplace_back(action); + } + st.cv.notify_all(); +} + +template +void runOnQtMainThread(F &&fn) +{ + QCoreApplication *app = QCoreApplication::instance(); + if (!app) + return; + if (QThread::currentThread() == app->thread()) { + fn(); + } else { + QMetaObject::invokeMethod(app, std::forward(fn), Qt::QueuedConnection); + } +} +} // namespace +#endif + +std::unique_ptr create_qt_app() +{ + static int s_argc = 1; + static char s_arg0[] = "nectarlink"; + static char *s_argv[] = { s_arg0, nullptr }; +#ifdef __linux__ + auto app = std::unique_ptr(new QApplication(s_argc, s_argv)); + if (QClipboard *cb = QGuiApplication::clipboard()) { + QObject::connect(cb, &QClipboard::dataChanged, cb, []() { + linux_clipboard_notify_external(); + }); + } + return app; +#else + return std::make_unique(s_argc, s_argv); +#endif +} + +void linux_tray_setup( + rust::Str icon_path, + rust::Str tooltip, + rust::Str open_label, + rust::Str command_palette_label, + rust::Str shelf_label, + rust::Str take_photo_label, + rust::Str scan_document_label, + rust::Str find_phone_label, + rust::Str open_link_label, + rust::Str quit_label) +{ +#ifdef __linux__ + const QString iconPath = QString::fromUtf8(icon_path.data(), static_cast(icon_path.size())); + const QString tooltipStr = QString::fromUtf8(tooltip.data(), static_cast(tooltip.size())); + const QString openStr = QString::fromUtf8(open_label.data(), static_cast(open_label.size())); + const QString paletteStr = QString::fromUtf8(command_palette_label.data(), static_cast(command_palette_label.size())); + const QString shelfStr = QString::fromUtf8(shelf_label.data(), static_cast(shelf_label.size())); + const QString photoStr = QString::fromUtf8(take_photo_label.data(), static_cast(take_photo_label.size())); + const QString scanStr = QString::fromUtf8(scan_document_label.data(), static_cast(scan_document_label.size())); + const QString findPhoneStr = QString::fromUtf8(find_phone_label.data(), static_cast(find_phone_label.size())); + const QString openLinkStr = QString::fromUtf8(open_link_label.data(), static_cast(open_link_label.size())); + const QString quitStr = QString::fromUtf8(quit_label.data(), static_cast(quit_label.size())); + + runOnQtMainThread([=]() { + if (!qobject_cast(QCoreApplication::instance())) + return; + auto &st = linuxTray(); + if (st.icon) { + st.icon->hide(); + delete st.icon.data(); + } + if (st.menu) { + delete st.menu.data(); + } + { + std::lock_guard lock(st.mutex); + st.actions.clear(); + } + + auto *menu = new QMenu(); + QObject::connect(menu->addAction(openStr), &QAction::triggered, menu, []() { pushTrayAction("OPEN"); }); + QObject::connect(menu->addAction(paletteStr), &QAction::triggered, menu, []() { pushTrayAction("COMMAND_PALETTE"); }); + QObject::connect(menu->addAction(shelfStr), &QAction::triggered, menu, []() { pushTrayAction("SHELF"); }); + menu->addSeparator(); + QObject::connect(menu->addAction(photoStr), &QAction::triggered, menu, []() { pushTrayAction("TAKE_PHOTO"); }); + QObject::connect(menu->addAction(scanStr), &QAction::triggered, menu, []() { pushTrayAction("SCAN_DOCUMENT"); }); + QAction *findPhone = menu->addAction(findPhoneStr); + findPhone->setEnabled(false); + QObject::connect(findPhone, &QAction::triggered, menu, []() { pushTrayAction("FIND_PHONE"); }); + QObject::connect(menu->addAction(openLinkStr), &QAction::triggered, menu, []() { pushTrayAction("OPEN_LINK"); }); + menu->addSeparator(); + QObject::connect(menu->addAction(quitStr), &QAction::triggered, menu, []() { pushTrayAction("QUIT"); }); + + auto *tray = new QSystemTrayIcon(QCoreApplication::instance()); + QIcon icon = appIcon(); + if (icon.isNull() && !iconPath.isEmpty()) + icon = QIcon(iconPath); + tray->setIcon(icon); + tray->setToolTip(tooltipStr); + tray->setContextMenu(menu); + QObject::connect(tray, &QSystemTrayIcon::activated, tray, [](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) + pushTrayAction("OPEN"); + }); + tray->show(); + + st.icon = tray; + st.menu = menu; + st.findPhoneAction = findPhone; + }); +#else + (void)icon_path; + (void)tooltip; + (void)open_label; + (void)command_palette_label; + (void)shelf_label; + (void)take_photo_label; + (void)scan_document_label; + (void)find_phone_label; + (void)open_link_label; + (void)quit_label; +#endif +} + +void linux_tray_set_tooltip(rust::Str tooltip) +{ +#ifdef __linux__ + const QString tooltipStr = QString::fromUtf8(tooltip.data(), static_cast(tooltip.size())); + runOnQtMainThread([tooltipStr]() { + if (auto *icon = linuxTray().icon.data()) + icon->setToolTip(tooltipStr); + }); +#else + (void)tooltip; +#endif +} + +void linux_tray_set_find_phone_enabled(bool enabled) +{ +#ifdef __linux__ + runOnQtMainThread([enabled]() { + if (auto *act = linuxTray().findPhoneAction.data()) + act->setEnabled(enabled); + }); +#else + (void)enabled; +#endif +} + +void linux_tray_teardown() +{ +#ifdef __linux__ + auto &st = linuxTray(); + st.cv.notify_all(); + runOnQtMainThread([]() { + auto &s = linuxTray(); + if (s.icon) { + s.icon->hide(); + delete s.icon.data(); + } + if (s.menu) { + delete s.menu.data(); + } + s.findPhoneAction = nullptr; + }); +#endif +} + +rust::String linux_tray_wait_action(uint32_t timeout_ms) +{ +#ifdef __linux__ + auto &st = linuxTray(); + std::unique_lock lock(st.mutex); + if (st.actions.empty() && timeout_ms > 0) { + st.cv.wait_for(lock, std::chrono::milliseconds(timeout_ms)); + } + if (!st.actions.empty()) { + std::string next = std::move(st.actions.front()); + st.actions.pop_front(); + return rust::String(next); + } +#else + (void)timeout_ms; +#endif + return rust::String(); +} + +uint64_t linux_clipboard_sequence() +{ +#ifdef __linux__ + return linuxClipboardWatch().seq.load(std::memory_order_acquire); +#else + return 0; +#endif +} + +uint64_t linux_clipboard_wait_change(uint64_t last_seq, uint32_t timeout_ms) +{ +#ifdef __linux__ + auto &w = linuxClipboardWatch(); + uint64_t cur = w.seq.load(std::memory_order_acquire); + if (cur != last_seq || timeout_ms == 0) + return cur; + std::unique_lock lock(w.mutex); + w.cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), [&w, last_seq]() { + return w.seq.load(std::memory_order_acquire) != last_seq; + }); + return w.seq.load(std::memory_order_acquire); +#else + (void)last_seq; + (void)timeout_ms; + return 0; +#endif +} + +void linux_clipboard_notify_external() +{ +#ifdef __linux__ + auto &w = linuxClipboardWatch(); + { + std::lock_guard lock(w.mutex); + w.seq.fetch_add(1, std::memory_order_release); + } + w.cv.notify_all(); +#endif +} diff --git a/desktop/app/cpp/app_helpers.h b/desktop/app/cpp/app_helpers.h index 27da9f67..ed649c2d 100644 --- a/desktop/app/cpp/app_helpers.h +++ b/desktop/app/cpp/app_helpers.h @@ -4,6 +4,13 @@ #include #include +#include + +class QGuiApplication; + +// Creates the Qt application instance (`QApplication` on Linux so +// `QSystemTrayIcon` and `QMenu` work; `QGuiApplication` elsewhere). +std::unique_ptr create_qt_app(); // Qt setup that must happen before QGuiApplication is created. void prepare_qt(); @@ -62,3 +69,24 @@ bool mac_clipboard_write_text(rust::Str text, bool is_private); int64_t mac_local_offset_ms(int64_t at_ms); uint64_t mac_free_space(rust::Str path, bool &ok); + +// Linux QSystemTrayIcon + QMenu and QClipboard change notifications. +void linux_tray_setup( + rust::Str icon_path, + rust::Str tooltip, + rust::Str open_label, + rust::Str command_palette_label, + rust::Str shelf_label, + rust::Str take_photo_label, + rust::Str scan_document_label, + rust::Str find_phone_label, + rust::Str open_link_label, + rust::Str quit_label); +void linux_tray_set_tooltip(rust::Str tooltip); +void linux_tray_set_find_phone_enabled(bool enabled); +void linux_tray_teardown(); +rust::String linux_tray_wait_action(uint32_t timeout_ms); + +uint64_t linux_clipboard_sequence(); +uint64_t linux_clipboard_wait_change(uint64_t last_seq, uint32_t timeout_ms); +void linux_clipboard_notify_external(); diff --git a/desktop/app/src/bridge/native.rs b/desktop/app/src/bridge/native.rs index 7903c20f..6a0c85d9 100644 --- a/desktop/app/src/bridge/native.rs +++ b/desktop/app/src/bridge/native.rs @@ -6,9 +6,14 @@ pub mod ffi { unsafe extern "C++" { include!("app_helpers.h"); include!("video_view.h"); + include!("cxx-qt-lib/qguiapplication.h"); include!("cxx-qt-lib/qqmlapplicationengine.h"); + type QGuiApplication = cxx_qt_lib::QGuiApplication; type QQmlApplicationEngine = cxx_qt_lib::QQmlApplicationEngine; + #[allow(dead_code)] + fn create_qt_app() -> UniquePtr; + /// The latest picture of a video stream (32-bit BGRX rows), for /// `VideoView`s showing it. Any thread. fn video_frame(stream: &str, width: u32, height: u32, bgrx: &[u8]); @@ -69,5 +74,41 @@ pub mod ffi { #[allow(dead_code)] fn mac_free_space(path: &str, ok: &mut bool) -> u64; + + #[allow(dead_code)] + #[allow(clippy::too_many_arguments)] + fn linux_tray_setup( + icon_path: &str, + tooltip: &str, + open_label: &str, + command_palette_label: &str, + shelf_label: &str, + take_photo_label: &str, + scan_document_label: &str, + find_phone_label: &str, + open_link_label: &str, + quit_label: &str, + ); + + #[allow(dead_code)] + fn linux_tray_set_tooltip(tooltip: &str); + + #[allow(dead_code)] + fn linux_tray_set_find_phone_enabled(enabled: bool); + + #[allow(dead_code)] + fn linux_tray_teardown(); + + #[allow(dead_code)] + fn linux_tray_wait_action(timeout_ms: u32) -> String; + + #[allow(dead_code)] + fn linux_clipboard_sequence() -> u64; + + #[allow(dead_code)] + fn linux_clipboard_wait_change(last_seq: u64, timeout_ms: u32) -> u64; + + #[allow(dead_code)] + fn linux_clipboard_notify_external(); } } diff --git a/desktop/app/src/doctor.rs b/desktop/app/src/doctor.rs index 85508aab..14fafa9c 100644 --- a/desktop/app/src/doctor.rs +++ b/desktop/app/src/doctor.rs @@ -55,6 +55,9 @@ pub struct Facts { pub firewall: Option, /// Paired devices: link and discovery diagnostics. pub devices: Vec, + /// Optional helper tools missing from `PATH` on Linux. + #[cfg(target_os = "linux")] + pub missing_tools: Vec<&'static str>, } /// Asks Windows and the core (blocking; call on a worker thread). @@ -81,7 +84,15 @@ pub fn gather() -> Facts { }) .collect() }); - Facts { local_address: adapters.local_address, vpn: adapters.vpn, networks, firewall, devices } + Facts { + local_address: adapters.local_address, + vpn: adapters.vpn, + networks, + firewall, + devices, + #[cfg(target_os = "linux")] + missing_tools: windows_checks::missing_tools(), + } } /// A network's name as people say it. While Windows is still identifying @@ -263,6 +274,20 @@ pub fn diagnose(facts: &Facts, now_secs: i64) -> Vec { )); } + #[cfg(target_os = "linux")] + if !facts.missing_tools.is_empty() { + let list = facts.missing_tools.join(", "); + checks.push(check( + "linux-tools", + Outcome::Warn, + format!("Optional helper tools not found: {list}"), + format!( + "Install {list} with your package manager for full clipboard, window focus, Wi-Fi hotspot, BLE discovery, and firewall setup support." + ), + None, + )); + } + // Each phone. for (i, d) in facts.devices.iter().enumerate() { let name = &d.name; @@ -428,6 +453,8 @@ mod tests { networks: vec![("Home".into(), true)], firewall: Some(windows_checks::Firewall { on: true, allowed: true, blocked: false }), devices: vec![device_fact("Pixel", LinkState::Online { path: ConnectionPath::Lan, rtt_ms: 4 })], + #[cfg(target_os = "linux")] + missing_tools: Vec::new(), } } diff --git a/desktop/app/src/linux/autostart.rs b/desktop/app/src/linux/autostart.rs index 28a3ab04..4f8b85ad 100644 --- a/desktop/app/src/linux/autostart.rs +++ b/desktop/app/src/linux/autostart.rs @@ -8,9 +8,29 @@ use std::{ const DESKTOP_FILE_NAME: &str = "nectarlink.desktop"; +/// Escapes a single argument for placement inside double quotes (`"..."`) on a +/// FreeDesktop `.desktop` `Exec=` line (`\`, `"`, `` ` ``, `$`, and literal `%`). +pub(crate) fn escape_desktop_exec_arg(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for c in raw.chars() { + if c == '\n' || c == '\r' || c.is_control() { + continue; + } + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '`' => out.push_str("\\`"), + '$' => out.push_str("\\$"), + '%' => out.push_str("%%"), + _ => out.push(c), + } + } + out +} + /// The command line written into the autostart entry's `Exec=` line. pub fn command(exe: &Path) -> String { - format!("\"{}\" --minimized", exe.display()) + format!("\"{}\" --minimized", escape_desktop_exec_arg(&exe.to_string_lossy())) } fn autostart_file_path_in(config_dir: &Path) -> PathBuf { @@ -132,4 +152,13 @@ mod tests { assert!(!is_installed(Path::new("/home/user/repo/target/debug/nectarlink-desktop"))); assert!(!is_installed(Path::new("/home/user/repo/target/release/nectarlink-desktop"))); } + + #[test] + fn command_escapes_desktop_entry_special_characters() { + let exe = Path::new("/opt/my \"quoted\" $dir`1`\\bin%20/nectarlink-desktop"); + assert_eq!( + command(exe), + r#""/opt/my \"quoted\" \$dir\`1\`\\bin%%20/nectarlink-desktop" --minimized"# + ); + } } diff --git a/desktop/app/src/linux/ble_beacon.rs b/desktop/app/src/linux/ble_beacon.rs index f8a39b48..8eda06f0 100644 --- a/desktop/app/src/linux/ble_beacon.rs +++ b/desktop/app/src/linux/ble_beacon.rs @@ -134,13 +134,18 @@ fn stop_bluetoothctl_advertisement(child: Option) { } fn is_adapter_powered() -> bool { - if let Ok(out) = Command::new("busctl") - .args(["--system", "get-property", "org.bluez", "/org/bluez/hci0", "org.bluez.Adapter1", "Powered"]) - .output() - && out.status.success() + if let Ok(conn) = zbus::blocking::Connection::system() + && let Ok(reply) = conn.call_method( + Some("org.bluez"), + "/org/bluez/hci0", + Some("org.freedesktop.DBus.Properties"), + "Get", + &("org.bluez.Adapter1", "Powered"), + ) + && let Ok(val) = reply.body().deserialize::() + && let Ok(powered) = bool::try_from(val) { - let stdout = String::from_utf8_lossy(&out.stdout); - return stdout.trim().ends_with("true"); + return powered; } if let Ok(out) = Command::new("bluetoothctl").arg("show").output() && out.status.success() diff --git a/desktop/app/src/linux/clipboard.rs b/desktop/app/src/linux/clipboard.rs index 1d2b4d14..a03da2ae 100644 --- a/desktop/app/src/linux/clipboard.rs +++ b/desktop/app/src/linux/clipboard.rs @@ -10,17 +10,14 @@ //! before updating the system clipboard so the watcher thread never echoes //! phone clipboard writes back to the phone. -use std::io::Write; +use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use super::image::{self, Bitmap}; -/// Poll interval for the background Linux clipboard watcher thread. -const POLL_INTERVAL: Duration = Duration::from_millis(450); - /// Content fingerprint used to deduplicate clipboard updates and suppress /// echoing NectarLink's own writes back to connected phones. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -155,6 +152,13 @@ pub(crate) fn is_kde_secret_value(bytes: &[u8]) -> bool { std::str::from_utf8(trimmed).ok().is_some_and(|s| s.trim().eq_ignore_ascii_case("secret")) } +/// Records the fingerprint of whatever is currently on the clipboard so that +/// pre-existing clipboard contents at app startup are never sent to the phone. +pub(crate) fn seed_initial_clipboard() { + let initial = fingerprint(&read()); + *LAST_REPORTED.lock().unwrap_or_else(|e| e.into_inner()) = initial; +} + /// Starts watching the Linux clipboard on a background thread (`"clipboard"`). /// Newly copied text or images that are not marked private and were not /// written by NectarLink are passed to `on_copy`. @@ -168,15 +172,55 @@ where if !has_display_server() { return; } + seed_initial_clipboard(); + if is_wayland() { + let _ = std::thread::Builder::new().name("clipboard-wl-watch".into()).spawn(wayland_watch_loop); + } let spawned = std::thread::Builder::new().name("clipboard".into()).spawn(watcher_loop); if let Err(e) = spawned { tracing::warn!(error = %e, "can't start Linux clipboard watcher"); } } +/// Runs `wl-paste --watch` on Wayland so clipboard changes while NectarLink is +/// unfocused are detected on compositors that implement `wlr-data-control` or +/// `ext-data-control`. If the compositor does not support data-control (for +/// example GNOME Wayland), `wl-paste --watch` exits immediately and we do not +/// respawn it, relying on `QClipboard::dataChanged` instead. +fn wayland_watch_loop() { + let started = Instant::now(); + let Ok(mut child) = Command::new("wl-paste") + .args(["--watch", "printf", "\\n"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return; + }; + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + for line in reader.lines() { + if line.is_err() { + break; + } + crate::bridge::native::ffi::linux_clipboard_notify_external(); + } + } + let _ = child.wait(); + if started.elapsed() < Duration::from_secs(2) { + tracing::debug!("wl-paste --watch exited early; relying on QClipboard::dataChanged"); + } +} + fn watcher_loop() { + let mut last_seq = crate::bridge::native::ffi::linux_clipboard_sequence(); loop { - std::thread::sleep(POLL_INTERVAL); + let next_seq = crate::bridge::native::ffi::linux_clipboard_wait_change(last_seq, 2000); + if next_seq == last_seq { + continue; + } + last_seq = next_seq; let clip = read(); if let Some(on_copy) = ON_COPY.get() { let _ = report_if_changed(clip, on_copy); @@ -928,6 +972,15 @@ mod tests { assert!(report_if_changed(user_clip.clone(), |c| reported.push(c))); assert!(!report_if_changed(user_clip, |c| reported.push(c))); assert_eq!(reported.len(), 1); + + // Pre-existing clipboard contents when start() runs must be seeded into + // LAST_REPORTED so the first clipboard event does not send stale content. + *LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()) = + Clip::Text { text: "already on clipboard before launch".into(), html: None, rtf: None }; + *LAST_REPORTED.lock().unwrap_or_else(|e| e.into_inner()) = None; + seed_initial_clipboard(); + assert!(!report_if_changed(read(), |c| reported.push(c))); + assert_eq!(reported.len(), 1); } #[test] diff --git a/desktop/app/src/linux/doctor.rs b/desktop/app/src/linux/doctor.rs index b5bcb547..0748703d 100644 --- a/desktop/app/src/linux/doctor.rs +++ b/desktop/app/src/linux/doctor.rs @@ -235,35 +235,59 @@ pub fn adapters() -> Adapters { Adapters { local_address, vpn } } +/// Returns the names of optional session-relevant helper commands that are not +/// currently installed on `PATH` (`wl-copy`/`wl-paste` on Wayland, +/// `xclip`/`xdotool` on X11, and `xdg-open`, `nmcli`, `bluetoothctl`, `pkexec`). +pub fn missing_tools() -> Vec<&'static str> { + let mut missing = Vec::new(); + if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) { + for tool in ["wl-copy", "wl-paste"] { + if !has_command(tool) { + missing.push(tool); + } + } + } + if std::env::var_os("DISPLAY").is_some_and(|v| !v.is_empty()) { + for tool in ["xclip", "xdotool"] { + if !has_command(tool) { + missing.push(tool); + } + } + } + for tool in ["xdg-open", "nmcli", "bluetoothctl", "pkexec"] { + if !has_command(tool) { + missing.push(tool); + } + } + missing +} + pub fn fix_firewall(_exe: &Path) -> Result { if is_service_active("ufw") || has_command("ufw") { - let status = super::elevated::run_elevated( - Path::new("sh"), - &["-c", "ufw allow 7718/tcp && ufw allow 7718/udp && ufw allow 5353/udp"], - ); - return Ok(matches!(status, Ok(0))); - } - if is_service_active("firewalld") || has_command("firewall-cmd") { let status = super::elevated::run_elevated( Path::new("sh"), &[ "-c", - "firewall-cmd --permanent --add-port=7718/tcp --add-port=7718/udp --add-port=5353/udp && firewall-cmd --reload", + "for cidr in 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 fe80::/10; do \ + ufw allow from \"$cidr\" to any port 7718 proto tcp && \ + ufw allow from \"$cidr\" to any port 7718 proto udp && \ + ufw allow from \"$cidr\" to any port 5353 proto udp || exit 1; \ + done", ], ); return Ok(matches!(status, Ok(0))); } - if is_service_active("nftables") { + if is_service_active("firewalld") || has_command("firewall-cmd") { let status = super::elevated::run_elevated( Path::new("sh"), &[ "-c", - "nft add rule inet filter input tcp dport 7718 accept; nft add rule inet filter input udp dport { 5353, 7718 } accept", + "firewall-cmd --permanent --add-port=7718/tcp --add-port=7718/udp --add-port=5353/udp && firewall-cmd --reload", ], ); return Ok(matches!(status, Ok(0))); } - Ok(true) + Ok(false) } pub fn open_firewall_settings() -> bool { @@ -312,6 +336,20 @@ To Action From Some(Firewall { on: true, allowed: true, blocked: false }) ); + let subnet_scoped_allowed_output = "\ +Status: active + +To Action From +-- ------ ---- +7718/tcp ALLOW 192.168.0.0/16 +7718/udp ALLOW 192.168.0.0/16 +5353/udp ALLOW 192.168.0.0/16 +"; + assert_eq!( + parse_ufw_status(subnet_scoped_allowed_output), + Some(Firewall { on: true, allowed: true, blocked: false }) + ); + let blocked_output = "\ Status: active diff --git a/desktop/app/src/linux/jumplist.rs b/desktop/app/src/linux/jumplist.rs index 4a02a2d0..734ad808 100644 --- a/desktop/app/src/linux/jumplist.rs +++ b/desktop/app/src/linux/jumplist.rs @@ -67,11 +67,7 @@ pub fn write_desktop_file_to( ) -> std::io::Result { std::fs::create_dir_all(dir)?; let desktop_path = dir.join("nectarlink.desktop"); - let exe_str: String = exe - .to_string_lossy() - .chars() - .filter(|&c| c != '\n' && c != '\r' && c != '"' && !c.is_control()) - .collect(); + let exe_str = super::autostart::escape_desktop_exec_arg(&exe.to_string_lossy()); let icon_str: String = icon_path.to_string_lossy().chars().filter(|&c| c != '\n' && c != '\r' && !c.is_control()).collect(); @@ -138,11 +134,7 @@ pub fn unregister() { pub fn build_task_arguments(action: &str, custom_data_dir: Option<&Path>) -> String { match custom_data_dir { Some(dir) => { - let clean: String = dir - .to_string_lossy() - .chars() - .filter(|&c| c != '\n' && c != '\r' && c != '"' && !c.is_control()) - .collect(); + let clean = super::autostart::escape_desktop_exec_arg(&dir.to_string_lossy()); format!("--data-dir \"{clean}\" {ARG} {action}") } None => format!("{ARG} {action}"), diff --git a/desktop/app/src/linux/shell.rs b/desktop/app/src/linux/shell.rs index 1234ff88..7f52a63d 100644 --- a/desktop/app/src/linux/shell.rs +++ b/desktop/app/src/linux/shell.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! Opening URLs, files, folders, and power actions (lock, sleep) on Linux via -//! `xdg-open`, `dbus-send`, `loginctl`, and `systemctl`. +//! `xdg-open`, D-Bus (`org.freedesktop.FileManager1`), `loginctl`, and `systemctl`. use std::{ fs, @@ -87,23 +87,17 @@ pub fn open_folder(path: &Path) -> Result<(), String> { pub fn select_in_explorer(path: &Path) -> Result<(), String> { let abs = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let file_uri = format!("file://{}", abs.to_string_lossy()); - let array_arg = format!("array:string:{file_uri}"); - let dbus_ok = Command::new("dbus-send") - .args([ - "--session", - "--dest=org.freedesktop.FileManager1", - "--type=method_call", - "/org/freedesktop/FileManager1", - "org.freedesktop.FileManager1.ShowItems", - &array_arg, - "string:", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|s| s.success()); - if dbus_ok { + if let Ok(conn) = zbus::blocking::Connection::session() + && conn + .call_method( + Some("org.freedesktop.FileManager1"), + "/org/freedesktop/FileManager1", + Some("org.freedesktop.FileManager1"), + "ShowItems", + &(vec![file_uri.as_str()], ""), + ) + .is_ok() + { return Ok(()); } let folder = abs.parent().unwrap_or(&abs); diff --git a/desktop/app/src/linux/shortcut.rs b/desktop/app/src/linux/shortcut.rs index 2cefc4c7..9031deda 100644 --- a/desktop/app/src/linux/shortcut.rs +++ b/desktop/app/src/linux/shortcut.rs @@ -86,7 +86,8 @@ pub fn write(path: &Path, shortcut: &Shortcut) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let target = sanitize_single_line(&shortcut.target.to_string_lossy()); + let raw_target = sanitize_single_line(&shortcut.target.to_string_lossy()); + let exec_target = super::autostart::escape_desktop_exec_arg(&shortcut.target.to_string_lossy()); let args = sanitize_single_line(&shortcut.arguments); let desc = sanitize_single_line(&shortcut.description); @@ -95,10 +96,10 @@ pub fn write(path: &Path, shortcut: &Shortcut) -> Result<()> { Type=Application\n\ Name=NectarLink\n\ Comment={desc}\n\ - Exec=\"{target}\" {args}\n\ + Exec=\"{exec_target}\" {args}\n\ Icon=nectarlink\n\ Terminal=false\n\ - X-NectarLink-Target={target}\n\ + X-NectarLink-Target={raw_target}\n\ X-NectarLink-Args={args}\n\ X-NectarLink-Comment={desc}\n", ); @@ -263,7 +264,8 @@ pub fn write_file_manager_integrations( desired_nautilus.insert(label.clone()); desired_desktop.insert(desktop_filename.clone()); - let target = sanitize_shell_path(&sc.target.to_string_lossy()); + let shell_target = sanitize_shell_path(&sc.target.to_string_lossy()); + let exec_target = super::autostart::escape_desktop_exec_arg(&sc.target.to_string_lossy()); let args = sanitize_exec_args(&sc.arguments); let comment = sanitize_single_line(&sc.description); @@ -275,9 +277,9 @@ pub fn write_file_manager_integrations( if [ -n \"${{NAUTILUS_SCRIPT_SELECTED_FILE_PATHS:-}}\" ]; then\n\ IFS='\n'\n\ set -f\n\ - exec \"{target}\" {args} $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS\n\ + exec \"{shell_target}\" {args} $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS\n\ else\n\ - exec \"{target}\" {args} \"$@\"\n\ + exec \"{shell_target}\" {args} \"$@\"\n\ fi\n", ); write_file_nofollow(&nautilus_path, &script, 0o755)?; @@ -294,7 +296,7 @@ pub fn write_file_manager_integrations( [Desktop Action NectarLinkSend_{dev_id}]\n\ Name={label}\n\ Icon=nectarlink\n\ - Exec=\"{target}\" {args} %F\n" + Exec=\"{exec_target}\" {args} %F\n" ); write_file_nofollow(&kio_path, &kio_content, 0o644)?; @@ -310,7 +312,7 @@ pub fn write_file_manager_integrations( \n\ [X-Action-Profile profile-zero]\n\ MimeTypes=all/allfiles;\n\ - Exec=\"{target}\" {args} %F\n\ + Exec=\"{exec_target}\" {args} %F\n\ Name=Default profile\n", ); write_file_nofollow(&action_path, &action_content, 0o644)?; diff --git a/desktop/app/src/linux/toast.rs b/desktop/app/src/linux/toast.rs index 5eb00e80..b1b7b02e 100644 --- a/desktop/app/src/linux/toast.rs +++ b/desktop/app/src/linux/toast.rs @@ -4,10 +4,9 @@ use std::collections::HashMap; use std::fmt; -use std::io::{BufRead, BufReader}; use std::path::PathBuf; -use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; +use std::time::Duration; pub const AUMID: &str = "Nectarlink.Desktop"; pub const CALL_ANSWER: &str = "answer"; @@ -448,47 +447,71 @@ fn compute_progress_percent(progress: &Progress) -> i32 { progress.value.map(|v| (v.clamp(0.0, 1.0) * 100.0).round() as i32).unwrap_or(0) } -fn build_actions_gvariant(actions: &[(String, String)], reply: Option<&(String, String)>) -> String { - let mut items: Vec = Vec::new(); - items.push("'default'".to_string()); - items.push("'Open'".to_string()); +fn build_actions_list(actions: &[(String, String)], reply: Option<&(String, String)>) -> Vec { + let mut items = Vec::with_capacity(2 + actions.len() * 2 + 2); + items.push("default".to_string()); + items.push("Open".to_string()); for (id, label) in actions { if !id.trim().is_empty() { - items.push(escape_gvariant_string(id)); - items.push(escape_gvariant_string(label)); + items.push(id.clone()); + items.push(label.clone()); } } if let Some((_action_id, placeholder)) = reply { let label = if placeholder.trim().is_empty() { "Reply" } else { placeholder.trim() }; - items.push("'inline-reply'".to_string()); - items.push(escape_gvariant_string(label)); + items.push("inline-reply".to_string()); + items.push(label.to_string()); } - format!("[{}]", items.join(", ")) + items } -fn build_hints_gvariant(progress: Option<&Progress>, silent: bool, call: bool) -> String { - let mut entries = Vec::new(); - let urgency = if call { +fn build_hints_map( + progress: Option<&Progress>, + silent: bool, + call: bool, +) -> HashMap<&'static str, zbus::zvariant::Value<'static>> { + let mut hints = HashMap::new(); + let urgency: u8 = if call { 2 } else if silent { 0 } else { 1 }; - entries.push(format!("'urgency': ")); + hints.insert("urgency", zbus::zvariant::Value::U8(urgency)); if silent && !call { - entries.push("'suppress-sound': ".to_string()); + hints.insert("suppress-sound", zbus::zvariant::Value::Bool(true)); } if let Some(prog) = progress { let pct = compute_progress_percent(prog); - entries.push(format!("'value': ")); + hints.insert("value", zbus::zvariant::Value::I32(pct)); } - format!("{{{}}}", entries.join(", ")) + hints } -fn escape_gvariant_string(s: &str) -> String { - let escaped = s.replace('\\', "\\\\").replace('\'', "\\'"); - format!("'{escaped}'") +static DBUS_SESSION: OnceLock>> = OnceLock::new(); + +fn with_session_connection(mut op: F) -> Option +where + F: FnMut(&zbus::blocking::Connection) -> zbus::Result, +{ + let mutex = DBUS_SESSION.get_or_init(|| Mutex::new(None)); + let mut guard = mutex.lock().ok()?; + if guard.is_none() { + *guard = zbus::blocking::Connection::session().ok(); + } + if let Some(conn) = guard.as_ref() { + match op(conn) { + Ok(val) => return Some(val), + Err(_) => { + *guard = zbus::blocking::Connection::session().ok(); + if let Some(retry_conn) = guard.as_ref() { + return op(retry_conn).ok(); + } + } + } + } + None } #[allow(clippy::too_many_arguments)] @@ -507,224 +530,105 @@ fn send_notification( return None; } - let actions_arg = build_actions_gvariant(actions, reply); - let hints_arg = build_hints_gvariant(progress, silent, call); - let expire_timeout = if call { "int32 0" } else { "int32 -1" }; + let actions_list = build_actions_list(actions, reply); + let hints_map = build_hints_map(progress, silent, call); + let expire_timeout: i32 = if call { 0 } else { -1 }; - if let Ok(output) = Command::new("gdbus") - .args([ - "call", - "--session", - "--dest", - "org.freedesktop.Notifications", - "--object-path", + with_session_connection(|conn| { + let reply_msg = conn.call_method( + Some("org.freedesktop.Notifications"), "/org/freedesktop/Notifications", - "--method", - "org.freedesktop.Notifications.Notify", - "NectarLink", - &replaces_id.to_string(), - icon_path, - summary, - body, - &actions_arg, - &hints_arg, - expire_timeout, - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - && output.status.success() - { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(id) = parse_notify_id(&stdout) { - return Some(id); - } - } - - // Fallback to notify-send - let mut cmd = Command::new("notify-send"); - cmd.arg("--app-name=NectarLink").arg("--print-id"); - if replaces_id != 0 { - cmd.arg(format!("--replace-id={replaces_id}")); - } - if !icon_path.is_empty() { - cmd.arg(format!("--icon={icon_path}")); - } - if call { - cmd.arg("--urgency=critical"); - } else if silent { - cmd.arg("--urgency=low"); - } - if let Some(prog) = progress { - let pct = compute_progress_percent(prog); - cmd.arg(format!("--hint=int:value:{pct}")); - } - cmd.arg(summary); - if !body.is_empty() { - cmd.arg(body); - } - if let Ok(output) = cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).output() - && output.status.success() - { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(id) = parse_notify_id(&stdout) { - return Some(id); - } - } - - None + Some("org.freedesktop.Notifications"), + "Notify", + &("NectarLink", replaces_id, icon_path, summary, body, &actions_list, &hints_map, expire_timeout), + )?; + let msg_body = reply_msg.body(); + msg_body.deserialize::().or_else(|_| msg_body.deserialize::<(u32,)>().map(|(id,)| id)) + }) } fn close_notification(id: u32) { if cfg!(test) || id == 0 { return; } - let _ = Command::new("gdbus") - .args([ - "call", - "--session", - "--dest", - "org.freedesktop.Notifications", - "--object-path", + let _ = with_session_connection(|conn| { + conn.call_method( + Some("org.freedesktop.Notifications"), "/org/freedesktop/Notifications", - "--method", - "org.freedesktop.Notifications.CloseNotification", - &id.to_string(), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); + Some("org.freedesktop.Notifications"), + "CloseNotification", + &(id,), + ) + .map(|_| ()) + }); } fn monitor_loop() { - let Ok(mut child) = Command::new("gdbus") - .args([ - "monitor", - "--session", - "--dest", - "org.freedesktop.Notifications", - "--object-path", - "/org/freedesktop/Notifications", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - else { - return; - }; + loop { + let Ok(conn) = zbus::blocking::Connection::session() else { + std::thread::sleep(Duration::from_secs(5)); + continue; + }; + let Ok(rule) = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .interface("org.freedesktop.Notifications") + .and_then(|b| b.path("/org/freedesktop/Notifications")) + .map(|b| b.build()) + else { + return; + }; + let Ok(iter) = zbus::blocking::MessageIterator::for_match_rule(rule, &conn, None) else { + std::thread::sleep(Duration::from_secs(5)); + continue; + }; - let Some(stdout) = child.stdout.take() else { - return; - }; - let reader = BufReader::new(stdout); - for line in reader.lines().map_while(Result::ok) { - if let Some(signal) = parse_monitor_line(&line) { - let event_and_cb = TOASTER.get().and_then(|mutex| { - let mut guard = mutex.lock().ok()?; - let ev = guard.handle_signal(signal)?; - Some(ev) - }); - if let Some(event) = event_and_cb - && let Some(mutex) = TOASTER.get() - && let Ok(guard) = mutex.lock() - && let Some(cb) = &guard.on_event - { - cb(event); + for msg in iter { + let Ok(msg) = msg else { + break; + }; + if let Some(signal) = parse_dbus_signal(&msg) { + let event = TOASTER.get().and_then(|mutex| { + let mut guard = mutex.lock().ok()?; + guard.handle_signal(signal) + }); + if let Some(event) = event + && let Some(mutex) = TOASTER.get() + && let Ok(guard) = mutex.lock() + && let Some(cb) = &guard.on_event + { + cb(event); + } } } + std::thread::sleep(Duration::from_secs(2)); } } -/// Parses the notification ID returned by `gdbus call ... Notify` (e.g. `(uint32 42,)`) -/// or `notify-send --print-id` (e.g. `42`). -pub fn parse_notify_id(output: &str) -> Option { - let trimmed = output.trim(); - if trimmed.is_empty() { +/// Deserializes a D-Bus signal from `org.freedesktop.Notifications` into a [`MonitorSignal`]. +pub fn parse_dbus_signal(msg: &zbus::message::Message) -> Option { + let header = msg.header(); + if header.message_type() != zbus::message::Type::Signal { return None; } - if let Ok(id) = trimmed.parse::() { - return Some(id); - } - if let Some(idx) = trimmed.find("uint32 ") { - let rest = &trimmed[idx + "uint32 ".len()..]; - let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); - if let Ok(id) = digits.parse::() { - return Some(id); - } - } - None -} - -/// Parses a single line from `gdbus monitor --session --dest org.freedesktop.Notifications`. -/// -/// Expected signal formats: -/// - `/org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 12, 'reply')` -/// - `/org/freedesktop/Notifications: org.freedesktop.Notifications.NotificationReplied (uint32 12, 'Hello!')` -/// - `/org/freedesktop/Notifications: org.freedesktop.Notifications.NotificationClosed (uint32 12, uint32 2)` -pub fn parse_monitor_line(line: &str) -> Option { - let trimmed = line.trim(); - if let Some(idx) = trimmed.find("ActionInvoked") { - let args = &trimmed[idx + "ActionInvoked".len()..]; - let (id, rest) = parse_first_uint32(args)?; - let action_key = parse_quoted_arg(rest)?; - return Some(MonitorSignal::ActionInvoked { id, action_key }); - } - if let Some(idx) = trimmed.find("NotificationReplied") { - let args = &trimmed[idx + "NotificationReplied".len()..]; - let (id, rest) = parse_first_uint32(args)?; - let text = parse_quoted_arg(rest)?; - return Some(MonitorSignal::NotificationReplied { id, text }); - } - if let Some(idx) = trimmed.find("NotificationClosed") { - let args = &trimmed[idx + "NotificationClosed".len()..]; - let (id, rest) = parse_first_uint32(args)?; - let (reason, _) = parse_first_uint32(rest).unwrap_or((0, "")); - return Some(MonitorSignal::NotificationClosed { id, reason }); - } - None -} - -fn parse_first_uint32(s: &str) -> Option<(u32, &str)> { - let start = s.find(|c: char| c.is_ascii_digit())?; - let after_prefix = &s[start..]; - // Skip the `32` in `uint32 ` if `start` landed on `32` - let after_uint = if start >= 4 && &s[start - 4..start + 2] == "uint32" { - let rest_after_type = &s[start + 2..]; - let num_start = rest_after_type.find(|c: char| c.is_ascii_digit())?; - &rest_after_type[num_start..] - } else { - after_prefix - }; - let end = after_uint.find(|c: char| !c.is_ascii_digit()).unwrap_or(after_uint.len()); - let val = after_uint[..end].parse::().ok()?; - Some((val, &after_uint[end..])) -} - -fn parse_quoted_arg(s: &str) -> Option { - let comma = s.find(',')?; - let after_comma = s[comma + 1..].trim(); - let quote_char = after_comma.chars().next()?; - if quote_char != '\'' && quote_char != '"' { + if header.interface().map(|i| i.as_str()) != Some("org.freedesktop.Notifications") { return None; } - let content = &after_comma[quote_char.len_utf8()..]; - let mut out = String::new(); - let mut chars = content.chars(); - while let Some(ch) = chars.next() { - if ch == '\\' { - if let Some(next) = chars.next() { - out.push(next); - } - } else if ch == quote_char { - return Some(out); - } else { - out.push(ch); + let member = header.member()?.as_str(); + match member { + "ActionInvoked" => { + let (id, action_key) = msg.body().deserialize::<(u32, String)>().ok()?; + Some(MonitorSignal::ActionInvoked { id, action_key }) + } + "NotificationReplied" => { + let (id, text) = msg.body().deserialize::<(u32, String)>().ok()?; + Some(MonitorSignal::NotificationReplied { id, text }) } + "NotificationClosed" => { + let (id, reason) = msg.body().deserialize::<(u32, u32)>().ok()?; + Some(MonitorSignal::NotificationClosed { id, reason }) + } + _ => None, } - None } #[cfg(test)] @@ -732,30 +636,63 @@ mod tests { use super::*; #[test] - fn parses_notify_ids_from_gdbus_and_notify_send() { - assert_eq!(parse_notify_id("(uint32 42,)\n"), Some(42)); - assert_eq!(parse_notify_id("(uint32 108,)"), Some(108)); - assert_eq!(parse_notify_id("73\n"), Some(73)); - assert_eq!(parse_notify_id(""), None); + fn builds_actions_and_hints_without_gvariant_text_mangling() { + let actions = vec![("mark_read".to_string(), "Mark 'read' [1]".to_string())]; + let reply = ("direct_reply".to_string(), "Reply \"now\"".to_string()); + let list = build_actions_list(&actions, Some(&reply)); + assert_eq!( + list, + vec!["default", "Open", "mark_read", "Mark 'read' [1]", "inline-reply", "Reply \"now\""] + ); + + let prog = Progress { status: "Transferring".to_string(), value: Some(0.42), label: String::new() }; + let hints = build_hints_map(Some(&prog), true, false); + assert_eq!(hints.get("urgency"), Some(&zbus::zvariant::Value::U8(0))); + assert_eq!(hints.get("suppress-sound"), Some(&zbus::zvariant::Value::Bool(true))); + assert_eq!(hints.get("value"), Some(&zbus::zvariant::Value::I32(42))); } #[test] - fn parses_gdbus_monitor_signals() { - let action_line = "/org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 15, 'mark_read')"; + fn parses_zbus_notification_signals() { + let action_msg = zbus::message::Message::signal( + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "ActionInvoked", + ) + .unwrap() + .build(&(15u32, "mark_read")) + .unwrap(); assert_eq!( - parse_monitor_line(action_line), + parse_dbus_signal(&action_msg), Some(MonitorSignal::ActionInvoked { id: 15, action_key: "mark_read".to_string() }) ); - let reply_line = "/org/freedesktop/Notifications: org.freedesktop.Notifications.NotificationReplied (uint32 15, 'Sounds good\\'s!')"; + let reply_msg = zbus::message::Message::signal( + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationReplied", + ) + .unwrap() + .build(&(15u32, "Sounds good's!\nLine 2 [true]")) + .unwrap(); assert_eq!( - parse_monitor_line(reply_line), - Some(MonitorSignal::NotificationReplied { id: 15, text: "Sounds good's!".to_string() }) + parse_dbus_signal(&reply_msg), + Some(MonitorSignal::NotificationReplied { + id: 15, + text: "Sounds good's!\nLine 2 [true]".to_string(), + }) ); - let closed_line = "/org/freedesktop/Notifications: org.freedesktop.Notifications.NotificationClosed (uint32 15, uint32 2)"; + let closed_msg = zbus::message::Message::signal( + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationClosed", + ) + .unwrap() + .build(&(15u32, 2u32)) + .unwrap(); assert_eq!( - parse_monitor_line(closed_line), + parse_dbus_signal(&closed_msg), Some(MonitorSignal::NotificationClosed { id: 15, reason: 2 }) ); } diff --git a/desktop/app/src/linux/tray.rs b/desktop/app/src/linux/tray.rs index 0e536e38..d84203fd 100644 --- a/desktop/app/src/linux/tray.rs +++ b/desktop/app/src/linux/tray.rs @@ -6,9 +6,9 @@ use std::{ collections::HashMap, env, - io::{BufRead, BufReader, Result, Write}, + io::Result, path::PathBuf, - process::{Child, ChildStdin, Command, Stdio}, + process::{Command, Stdio}, sync::{ Arc, Mutex, OnceLock, atomic::{AtomicBool, AtomicU64, Ordering}, @@ -638,6 +638,14 @@ fn spawn_x11_hotkey_thread(stop: Arc, handler: Arc) - .ok() } +/// Returns true when an `org.freedesktop.portal.Settings.SettingChanged` +/// signal (`namespace`, `key`) affects desktop appearance (`"color-scheme"`, +/// `"contrast"`, or `"accent-color"`). +pub fn is_portal_appearance_setting(namespace: &str, key: &str) -> bool { + namespace.trim() == "org.freedesktop.appearance" + && matches!(key.trim(), "color-scheme" | "contrast" | "accent-color") +} + /// Returns true when a line from `gdbus monitor` or `dbus-monitor` indicates /// `org.freedesktop.login1.Manager.PrepareForSleep(false)` (system waking up). pub fn is_prepare_for_sleep_resume_line(line: &str) -> bool { @@ -700,145 +708,70 @@ fn tray_icon_cache_path() -> PathBuf { } } -fn spawn_dbus_monitor_reader( - mut child: Child, - stop: Arc, - handler: Arc, - is_login1: bool, -) -> Option> { - let stdout = child.stdout.take()?; - std::thread::Builder::new() - .name(if is_login1 { "tray-login1-monitor".into() } else { "tray-portal-monitor".into() }) - .spawn(move || { - let reader = BufReader::new(stdout); - let mut awaiting_sleep_bool = false; - for line in reader.lines() { - if stop.load(Ordering::Acquire) { - break; - } - let Ok(line) = line else { break }; - if is_login1 { - if is_prepare_for_sleep_resume_line(&line) { - awaiting_sleep_bool = false; - handler.emit(TrayEvent::Resumed); - } else if line.contains("PrepareForSleep") { - awaiting_sleep_bool = !line.contains("true"); - } else if awaiting_sleep_bool { - let trimmed = line.trim(); - if trimmed.contains("boolean false") { - awaiting_sleep_bool = false; - handler.emit(TrayEvent::Resumed); - } else if trimmed.contains("boolean true") { - awaiting_sleep_bool = false; - } - } - } else if is_portal_appearance_changed_line(&line) { - handler.emit(TrayEvent::AppearanceChanged); - } +fn spawn_login1_sleep_listener(stop: Arc, handler: Arc) { + let _ = std::thread::Builder::new().name("tray-login1-monitor".into()).spawn(move || { + let Ok(conn) = zbus::blocking::Connection::system() else { return }; + let Ok(rule) = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .interface("org.freedesktop.login1.Manager") + .and_then(|b| b.member("PrepareForSleep")) + .map(|b| b.build()) + else { + return; + }; + let Ok(dbus) = zbus::blocking::fdo::DBusProxy::new(&conn) else { return }; + if dbus.add_match_rule(rule).is_err() { + return; + } + for msg in zbus::blocking::MessageIterator::from(&conn) { + if stop.load(Ordering::Acquire) { + break; } - }) - .ok() + let Ok(msg) = msg else { break }; + let hdr = msg.header(); + if hdr.interface().map(|i| i.as_str()) == Some("org.freedesktop.login1.Manager") + && hdr.member().map(|m| m.as_str()) == Some("PrepareForSleep") + && let Ok(going_to_sleep) = msg.body().deserialize::() + && !going_to_sleep + { + handler.emit(TrayEvent::Resumed); + } + } + }); } -const TRAY_HELPER_SCRIPT: &str = r#" -import os, sys, threading -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GLib - -icon_path = sys.argv[1] -tooltip = sys.argv[2] -labels = sys.argv[3:11] -icon_dir = os.path.dirname(icon_path) -icon_name = os.path.splitext(os.path.basename(icon_path))[0] - -def emit(action): - try: - sys.stdout.write(action + "\n") - sys.stdout.flush() - except Exception: - pass - -menu = Gtk.Menu() -def add_item(label, action): - item = Gtk.MenuItem(label=label) - item.connect('activate', lambda _: emit(action)) - menu.append(item) - return item - -open_item = add_item(labels[0], 'OPEN') -add_item(labels[1], 'COMMAND_PALETTE') -add_item(labels[2], 'SHELF') -menu.append(Gtk.SeparatorMenuItem()) -add_item(labels[3], 'TAKE_PHOTO') -add_item(labels[4], 'SCAN_DOCUMENT') -find_phone_item = add_item(labels[5], 'FIND_PHONE') -find_phone_item.set_sensitive(False) -add_item(labels[6], 'OPEN_LINK') -menu.append(Gtk.SeparatorMenuItem()) -add_item(labels[7], 'QUIT') -menu.show_all() - -ind = None -status_icon = None -try: - try: - gi.require_version('AyatanaAppIndicator3', '0.1') - from gi.repository import AyatanaAppIndicator3 as AppIndicator - except Exception: - gi.require_version('AppIndicator3', '0.1') - from gi.repository import AppIndicator3 as AppIndicator - ind = AppIndicator.Indicator.new_with_path( - 'nectarlink', - icon_name, - AppIndicator.IndicatorCategory.APPLICATION_STATUS, - icon_dir, - ) - ind.set_status(AppIndicator.IndicatorStatus.ACTIVE) - ind.set_title(tooltip) - ind.set_icon_full(icon_path, tooltip) - ind.set_menu(menu) - try: - ind.set_secondary_activate_target(open_item) - except Exception: - pass -except Exception: - status_icon = Gtk.StatusIcon.new_from_file(icon_path) - status_icon.set_tooltip_text(tooltip) - status_icon.set_visible(True) - status_icon.connect('activate', lambda _: emit('OPEN')) - status_icon.connect( - 'popup-menu', - lambda icon, button, time: menu.popup(None, None, Gtk.StatusIcon.position_menu, icon, button, time), - ) - -def apply_cmd(line): - parts = line.rstrip('\r\n').split('\t', 1) - if len(parts) != 2: - return False - cmd, val = parts - if cmd == 'TOOLTIP': - if ind is not None: - ind.set_title(val) - ind.set_icon_full(icon_path, val) - if status_icon is not None: - status_icon.set_tooltip_text(val) - elif cmd == 'FIND_PHONE': - find_phone_item.set_sensitive(val == '1') - return False - -def stdin_reader(): - try: - for line in sys.stdin: - GLib.idle_add(apply_cmd, line) - except Exception: - pass - GLib.idle_add(Gtk.main_quit) - -t = threading.Thread(target=stdin_reader, daemon=True) -t.start() -Gtk.main() -"#; +fn spawn_portal_appearance_listener(stop: Arc, handler: Arc) { + let _ = std::thread::Builder::new().name("tray-portal-monitor".into()).spawn(move || { + let Ok(conn) = zbus::blocking::Connection::session() else { return }; + let Ok(rule) = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .interface("org.freedesktop.portal.Settings") + .and_then(|b| b.member("SettingChanged")) + .map(|b| b.build()) + else { + return; + }; + let Ok(dbus) = zbus::blocking::fdo::DBusProxy::new(&conn) else { return }; + if dbus.add_match_rule(rule).is_err() { + return; + } + for msg in zbus::blocking::MessageIterator::from(&conn) { + if stop.load(Ordering::Acquire) { + break; + } + let Ok(msg) = msg else { break }; + let hdr = msg.header(); + if hdr.interface().map(|i| i.as_str()) == Some("org.freedesktop.portal.Settings") + && hdr.member().map(|m| m.as_str()) == Some("SettingChanged") + && let Ok((namespace, key, _val)) = + msg.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() + && is_portal_appearance_setting(&namespace, &key) + { + handler.emit(TrayEvent::AppearanceChanged); + } + } + }); +} pub(crate) fn parse_tray_helper_action(line: &str) -> Option { match line.trim() { @@ -856,13 +789,12 @@ pub(crate) fn parse_tray_helper_action(line: &str) -> Option { pub struct Tray { tooltip: Mutex, + #[allow(dead_code)] labels: Mutex, find_phone_enabled: Mutex, icon_path: PathBuf, stop: Arc, handler: Arc, - helper_stdin: Mutex>, - children: Mutex>, threads: Mutex>>, } @@ -885,175 +817,59 @@ impl Tray { let stop = Arc::new(AtomicBool::new(false)); let shared_handler = SharedHandler::new(handler); - let mut helper_stdin = None; - let mut children = Vec::new(); let mut threads = Vec::new(); if !cfg!(test) && has_desktop_session() { record_foreground_window(); - // 0. StatusNotifierItem / Gtk.StatusIcon system tray indicator helper. - let tray_child = Command::new("python3") - .args([ - "-u", - "-c", - TRAY_HELPER_SCRIPT, - &icon_path.to_string_lossy(), - tooltip, - &labels.open, - &labels.command_palette, - &labels.shelf, - &labels.take_photo, - &labels.scan_document, - &labels.find_phone, - &labels.open_link, - &labels.quit, - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn(); - - if let Ok(mut child) = tray_child { - helper_stdin = child.stdin.take(); - let stdout = child.stdout.take(); - children.push(child); - if let Some(stdout) = stdout { - let reader = BufReader::new(stdout); - let stop_clone = Arc::clone(&stop); - let handler_clone = Arc::clone(&shared_handler); - if let Ok(handle) = - std::thread::Builder::new().name("tray-sni-helper".into()).spawn(move || { - for line in reader.lines() { - if stop_clone.load(Ordering::Acquire) { - break; - } - let Ok(line) = line else { break }; - if let Some(ev) = parse_tray_helper_action(&line) { - if matches!( - ev, - TrayEvent::CommandPalette - | TrayEvent::TakePhoto - | TrayEvent::ScanDocument - ) { - record_foreground_window(); - } - handler_clone.emit(ev); - } - } - }) - { - threads.push(handle); + // 0. Qt QSystemTrayIcon + QMenu on the main GUI thread. + crate::bridge::native::ffi::linux_tray_setup( + &icon_path.to_string_lossy(), + tooltip, + &labels.open, + &labels.command_palette, + &labels.shelf, + &labels.take_photo, + &labels.scan_document, + &labels.find_phone, + &labels.open_link, + &labels.quit, + ); + let stop_clone = Arc::clone(&stop); + let handler_clone = Arc::clone(&shared_handler); + if let Ok(handle) = std::thread::Builder::new().name("tray-qt-actions".into()).spawn(move || { + while !stop_clone.load(Ordering::Acquire) { + let action = crate::bridge::native::ffi::linux_tray_wait_action(200); + if stop_clone.load(Ordering::Acquire) { + break; } - } - } - - // 1. System bus org.freedesktop.login1.Manager PrepareForSleep monitor. - let login1_child = Command::new("gdbus") - .args([ - "monitor", - "--system", - "--dest", - "org.freedesktop.login1", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .or_else(|_| { - Command::new("dbus-monitor") - .args([ - "--system", - "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep'", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - }); - if let Ok(mut child) = login1_child { - let stdout = child.stdout.take(); - children.push(child); - if let Some(stdout) = stdout { - let fake_child_reader = BufReader::new(stdout); - let stop_clone = Arc::clone(&stop); - let handler_clone = Arc::clone(&shared_handler); - if let Ok(handle) = - std::thread::Builder::new().name("tray-login1-monitor".into()).spawn(move || { - let mut awaiting_sleep_bool = false; - for line in fake_child_reader.lines() { - if stop_clone.load(Ordering::Acquire) { - break; - } - let Ok(line) = line else { break }; - if is_prepare_for_sleep_resume_line(&line) { - awaiting_sleep_bool = false; - handler_clone.emit(TrayEvent::Resumed); - } else if line.contains("PrepareForSleep") { - awaiting_sleep_bool = !line.contains("true"); - } else if awaiting_sleep_bool { - let trimmed = line.trim(); - if trimmed.contains("boolean false") { - awaiting_sleep_bool = false; - handler_clone.emit(TrayEvent::Resumed); - } else if trimmed.contains("boolean true") { - awaiting_sleep_bool = false; - } - } - } - }) + if !action.is_empty() + && let Some(ev) = parse_tray_helper_action(&action) { - threads.push(handle); + if matches!( + ev, + TrayEvent::CommandPalette | TrayEvent::TakePhoto | TrayEvent::ScanDocument + ) { + record_foreground_window(); + } + handler_clone.emit(ev); } } + }) { + threads.push(handle); } - // 2. Session bus org.freedesktop.portal.Settings SettingChanged monitor. - let portal_child = Command::new("gdbus") - .args([ - "monitor", - "--session", - "--dest", - "org.freedesktop.portal.Desktop", - "--object-path", - "/org/freedesktop/portal/desktop", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn(); - if let Ok(mut child) = portal_child { - let stdout = child.stdout.take(); - children.push(child); - if let Some(stdout) = stdout { - let reader = BufReader::new(stdout); - let stop_clone = Arc::clone(&stop); - let handler_clone = Arc::clone(&shared_handler); - if let Ok(handle) = - std::thread::Builder::new().name("tray-portal-monitor".into()).spawn(move || { - for line in reader.lines() { - if stop_clone.load(Ordering::Acquire) { - break; - } - let Ok(line) = line else { break }; - if is_portal_appearance_changed_line(&line) { - handler_clone.emit(TrayEvent::AppearanceChanged); - } - } - }) - { - threads.push(handle); - } - } - } + // 1. System bus org.freedesktop.login1.Manager PrepareForSleep listener via zbus. + spawn_login1_sleep_listener(Arc::clone(&stop), Arc::clone(&shared_handler)); + + // 2. Session bus org.freedesktop.portal.Settings SettingChanged listener via zbus. + spawn_portal_appearance_listener(Arc::clone(&stop), Arc::clone(&shared_handler)); } if let Some(handle) = spawn_x11_hotkey_thread(Arc::clone(&stop), Arc::clone(&shared_handler)) { threads.push(handle); } - let _ = spawn_dbus_monitor_reader; - Ok(Tray { tooltip: Mutex::new(tooltip.to_string()), labels: Mutex::new(labels), @@ -1061,8 +877,6 @@ impl Tray { icon_path, stop, handler: shared_handler, - helper_stdin: Mutex::new(helper_stdin), - children: Mutex::new(children), threads: Mutex::new(threads), }) } @@ -1071,12 +885,8 @@ impl Tray { if let Ok(mut guard) = self.tooltip.lock() { *guard = text.to_string(); } - if let Ok(mut guard) = self.helper_stdin.lock() - && let Some(stdin) = guard.as_mut() - { - let sanitized = text.replace(['\r', '\n', '\t'], " "); - let _ = writeln!(stdin, "TOOLTIP\t{sanitized}"); - let _ = stdin.flush(); + if !cfg!(test) { + crate::bridge::native::ffi::linux_tray_set_tooltip(text); } } @@ -1085,12 +895,8 @@ impl Tray { if let Ok(mut guard) = self.find_phone_enabled.lock() { *guard = enabled; } - if let Ok(mut guard) = self.helper_stdin.lock() - && let Some(stdin) = guard.as_mut() - { - let flag = if enabled { "1" } else { "0" }; - let _ = writeln!(stdin, "FIND_PHONE\t{flag}"); - let _ = stdin.flush(); + if !cfg!(test) { + crate::bridge::native::ffi::linux_tray_set_find_phone_enabled(enabled); } } } @@ -1099,15 +905,8 @@ impl Drop for Tray { fn drop(&mut self) { self.stop.store(true, Ordering::Release); self.handler.clear(); - if let Ok(mut stdin_guard) = self.helper_stdin.lock() { - let _ = stdin_guard.take(); - } - if let Ok(mut children) = self.children.lock() { - for child in children.iter_mut() { - let _ = child.kill(); - let _ = child.wait(); - } - children.clear(); + if !cfg!(test) { + crate::bridge::native::ffi::linux_tray_teardown(); } if let Ok(mut threads) = self.threads.lock() { for handle in threads.drain(..) { diff --git a/desktop/app/src/main.rs b/desktop/app/src/main.rs index 24e20ea3..5f846d62 100644 --- a/desktop/app/src/main.rs +++ b/desktop/app/src/main.rs @@ -430,6 +430,9 @@ fn run_setup_window(mode: setup::Mode) -> ExitCode { } bridge::setup::set_mode(mode); bridge::native::ffi::prepare_qt_without_log(); + #[cfg(target_os = "linux")] + let mut app = bridge::native::ffi::create_qt_app(); + #[cfg(not(target_os = "linux"))] let mut app = QGuiApplication::new(); if let Some(mut app) = app.as_mut() { app.as_mut().set_application_name(&QString::from("NectarLink Setup")); @@ -627,6 +630,9 @@ fn main() -> ExitCode { } else { bridge::native::ffi::prepare_qt(); } + #[cfg(target_os = "linux")] + let mut app = bridge::native::ffi::create_qt_app(); + #[cfg(not(target_os = "linux"))] let mut app = QGuiApplication::new(); if let Some(mut app) = app.as_mut() { app.as_mut().set_application_name(&QString::from("Nectarlink")); diff --git a/desktop/app/src/pc_notifications.rs b/desktop/app/src/pc_notifications.rs index b76731f7..c548e675 100644 --- a/desktop/app/src/pc_notifications.rs +++ b/desktop/app/src/pc_notifications.rs @@ -4,11 +4,7 @@ //! Settings (default `false`, privacy-first). #[cfg(target_os = "linux")] -use std::{ - io::{BufRead, BufReader}, - process::{Command, Stdio}, - sync::OnceLock, -}; +use std::sync::OnceLock; use std::{ sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, @@ -165,7 +161,42 @@ pub fn forward_to_phones(app_name: &str, title: &str, text: &str) -> bool { true } -/// Starts a background listener for desktop notifications on Linux via `dbus-monitor`. +/// Deserializes a Freedesktop `org.freedesktop.Notifications.Notify` D-Bus method call +/// message into `(app_name, summary, body)`, preserving multi-line notification bodies. +#[cfg(any(target_os = "linux", test))] +pub fn extract_notify_call(msg: &zbus::message::Message) -> Option<(String, String, String)> { + let header = msg.header(); + if header.message_type() != zbus::message::Type::MethodCall { + return None; + } + if header.interface().map(|i| i.as_str()) != Some("org.freedesktop.Notifications") { + return None; + } + if header.member().map(|m| m.as_str()) != Some("Notify") { + return None; + } + type NotifyBody = ( + String, + u32, + String, + String, + String, + Vec, + std::collections::HashMap, + i32, + ); + let (app_name, _replaces_id, _app_icon, summary, body, _actions, _hints, _timeout) = + msg.body().deserialize::().ok()?; + let app_name = app_name.trim().to_string(); + let summary = summary.trim().to_string(); + let body = body.trim().to_string(); + if summary.is_empty() && body.is_empty() { + return None; + } + Some((app_name, summary, body)) +} + +/// Starts a background listener for desktop notifications on Linux via `zbus`. #[cfg(target_os = "linux")] pub fn start_listener() { LISTENER_STARTED.get_or_init(|| { @@ -177,61 +208,49 @@ pub fn start_listener() { #[cfg(target_os = "linux")] fn dbus_monitor_loop() { - let Ok(mut child) = Command::new("dbus-monitor") - .args(["--session", "type='method_call',interface='org.freedesktop.Notifications',member='Notify'"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - else { - return; - }; + use std::time::Duration; - let Some(stdout) = child.stdout.take() else { - return; - }; + loop { + if !settings::forward_pc_notifications_to_phone() { + std::thread::sleep(Duration::from_secs(1)); + continue; + } - let reader = BufReader::new(stdout); - let mut current_block = String::new(); - let mut in_notify = false; - let mut string_count = 0usize; + let Ok(conn) = zbus::blocking::Connection::session() else { + std::thread::sleep(Duration::from_secs(5)); + continue; + }; - for line in reader.lines().map_while(Result::ok) { - let trimmed = line.trim(); - if trimmed.contains("member=Notify") { - if in_notify && let Some((app, title, body)) = parse_dbus_notify_line(¤t_block) { - let _ = forward_pc_toast(&app, &title, &body); - } - current_block.clear(); - in_notify = true; - string_count = 0; - current_block.push_str(trimmed); - current_block.push(' '); + let rules = vec!["type='method_call',interface='org.freedesktop.Notifications',member='Notify'"]; + if conn + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus.Monitoring"), + "BecomeMonitor", + &(rules, 0u32), + ) + .is_err() + { + std::thread::sleep(Duration::from_secs(5)); continue; } - if in_notify { - if trimmed.starts_with("string \"") { - current_block.push_str(trimmed); - current_block.push(' '); - string_count += 1; - if string_count >= 4 { - if let Some((app, title, body)) = parse_dbus_notify_line(¤t_block) { + for msg in zbus::blocking::MessageIterator::from(conn) { + if !settings::forward_pc_notifications_to_phone() { + break; + } + match msg { + Ok(msg) => { + if let Some((app, title, body)) = extract_notify_call(&msg) { let _ = forward_pc_toast(&app, &title, &body); } - current_block.clear(); - in_notify = false; - string_count = 0; - } - } else if trimmed.starts_with("method call") || trimmed.starts_with("signal ") { - if let Some((app, title, body)) = parse_dbus_notify_line(¤t_block) { - let _ = forward_pc_toast(&app, &title, &body); } - current_block.clear(); - in_notify = false; - string_count = 0; + Err(_) => break, } } + + std::thread::sleep(Duration::from_secs(2)); } } @@ -285,4 +304,38 @@ mod tests { assert!(!forward_pc_toast("NectarLink", "Connected", "Pixel 9")); assert!(!forward_pc_toast("Firefox", " ", "")); } + + #[test] + fn extract_notify_call_preserves_multiline_body() { + let hints: std::collections::HashMap<&str, zbus::zvariant::Value<'_>> = + std::collections::HashMap::new(); + let actions: Vec<&str> = vec!["default", "Open"]; + let msg = zbus::message::Message::method_call("/org/freedesktop/Notifications", "Notify") + .unwrap() + .interface("org.freedesktop.Notifications") + .unwrap() + .destination("org.freedesktop.Notifications") + .unwrap() + .build(&( + "Signal Desktop", + 0u32, + "signal-desktop", + "Alice", + "Hey there!\nAre we still on for 7pm?\nLet me know.", + actions, + hints, + -1i32, + )) + .unwrap(); + + let extracted = extract_notify_call(&msg); + assert_eq!( + extracted, + Some(( + "Signal Desktop".to_string(), + "Alice".to_string(), + "Hey there!\nAre we still on for 7pm?\nLet me know.".to_string(), + )) + ); + } } From a07755c96b826c578f8ea6b5431302dd80d4ed07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Kirst=C3=A4tter?= <7329802+jochenkirstaetter@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:48:12 +0400 Subject: [PATCH 3/5] fix(linux): gate zbus behind target_os = linux and remove unused dbus-monitor parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jochen Kirstätter <7329802+jochenkirstaetter@users.noreply.github.com> --- desktop/app/src/main.rs | 4 +- desktop/app/src/pc_notifications.rs | 80 +---------------------- desktop/packaging/linux/build-appimage.sh | 1 + desktop/packaging/linux/build-deb.sh | 7 +- 4 files changed, 10 insertions(+), 82 deletions(-) diff --git a/desktop/app/src/main.rs b/desktop/app/src/main.rs index 5f846d62..05612063 100644 --- a/desktop/app/src/main.rs +++ b/desktop/app/src/main.rs @@ -75,7 +75,9 @@ use std::{ time::Duration, }; -use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QString, QUrl}; +#[cfg(not(target_os = "linux"))] +use cxx_qt_lib::QGuiApplication; +use cxx_qt_lib::{QQmlApplicationEngine, QString, QUrl}; use nectarlink_core::{ MediaAction, MediaError, MirrorInput, MirrorStart, Platform, PowerAction, StorageEntry, StorageError, StorageReadFile, StorageWriteDone, diff --git a/desktop/app/src/pc_notifications.rs b/desktop/app/src/pc_notifications.rs index c548e675..80c44583 100644 --- a/desktop/app/src/pc_notifications.rs +++ b/desktop/app/src/pc_notifications.rs @@ -56,73 +56,6 @@ pub fn is_blocked_self_source(app_name: &str) -> bool { || lower == "org.freedesktop.notifications" } -/// Parses a single-line or compact representation of a Freedesktop `Notify` D-Bus call -/// into `(app_name, summary, body)`. -/// -/// Supports `dbus-monitor` `string "..."` fields from `org.freedesktop.Notifications.Notify`. -#[cfg(any(target_os = "linux", test))] -pub fn parse_dbus_notify_line(line: &str) -> Option<(String, String, String)> { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - - let mut dbus_strings = Vec::new(); - let mut rest = trimmed; - while let Some(idx) = rest.find("string \"") { - let after = &rest[idx + "string \"".len()..]; - if let Some((val, tail)) = take_unescaped_quoted(after) { - dbus_strings.push(val); - rest = tail; - } else { - break; - } - } - - // In org.freedesktop.Notifications.Notify(app_name, replaces_id, app_icon, summary, body, ...): - // string[0] = app_name, string[1] = app_icon, string[2] = summary, string[3] = body - if dbus_strings.len() >= 4 { - let app_name = dbus_strings[0].trim().to_string(); - let summary = dbus_strings[2].trim().to_string(); - let body = dbus_strings[3].trim().to_string(); - if summary.is_empty() && body.is_empty() { - return None; - } - return Some((app_name, summary, body)); - } - - if dbus_strings.len() == 3 { - let app_name = dbus_strings[0].trim().to_string(); - let summary = dbus_strings[1].trim().to_string(); - let body = dbus_strings[2].trim().to_string(); - if summary.is_empty() && body.is_empty() { - return None; - } - return Some((app_name, summary, body)); - } - - None -} - -#[cfg(any(target_os = "linux", test))] -fn take_unescaped_quoted(s: &str) -> Option<(String, &str)> { - let mut out = String::new(); - let mut escaped = false; - for (idx, ch) in s.char_indices() { - if escaped { - out.push(ch); - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - return Some((out, &s[idx + 1..])); - } else { - out.push(ch); - } - } - None -} - /// Forwards a captured PC notification if it is not from a blocked self-source, /// has non-empty content, and `forward_pc_notifications_to_phone` is enabled. #[cfg(any(target_os = "linux", test))] @@ -163,7 +96,7 @@ pub fn forward_to_phones(app_name: &str, title: &str, text: &str) -> bool { /// Deserializes a Freedesktop `org.freedesktop.Notifications.Notify` D-Bus method call /// message into `(app_name, summary, body)`, preserving multi-line notification bodies. -#[cfg(any(target_os = "linux", test))] +#[cfg(target_os = "linux")] pub fn extract_notify_call(msg: &zbus::message::Message) -> Option<(String, String, String)> { let header = msg.header(); if header.message_type() != zbus::message::Type::MethodCall { @@ -289,22 +222,13 @@ mod tests { assert!(!is_blocked_self_source("Signal Desktop")); } - #[test] - fn parses_dbus_notify_lines() { - let sample = r#"method call time=1700000.0 sender=:1.42 -> destination=org.freedesktop.Notifications serial=10 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify string "Firefox" uint32 0 string "firefox" string "Download complete" string "ubuntu.iso" array [] array [] int32 -1"#; - let parsed = parse_dbus_notify_line(sample); - assert_eq!( - parsed, - Some(("Firefox".to_string(), "Download complete".to_string(), "ubuntu.iso".to_string())) - ); - } - #[test] fn forward_pc_toast_ignores_self_and_empty() { assert!(!forward_pc_toast("NectarLink", "Connected", "Pixel 9")); assert!(!forward_pc_toast("Firefox", " ", "")); } + #[cfg(target_os = "linux")] #[test] fn extract_notify_call_preserves_multiline_body() { let hints: std::collections::HashMap<&str, zbus::zvariant::Value<'_>> = diff --git a/desktop/packaging/linux/build-appimage.sh b/desktop/packaging/linux/build-appimage.sh index 279e6879..665f8cf3 100755 --- a/desktop/packaging/linux/build-appimage.sh +++ b/desktop/packaging/linux/build-appimage.sh @@ -93,6 +93,7 @@ bundle_qt_runtime() { for pattern in \ "libQt6Core.so*" \ "libQt6Gui.so*" \ + "libQt6Widgets.so*" \ "libQt6Qml*.so*" \ "libQt6Quick*.so*" \ "libQt6Network.so*" \ diff --git a/desktop/packaging/linux/build-deb.sh b/desktop/packaging/linux/build-deb.sh index 6a6d33c9..f81790b5 100755 --- a/desktop/packaging/linux/build-deb.sh +++ b/desktop/packaging/linux/build-deb.sh @@ -77,7 +77,7 @@ QT_DEPENDS="" bundle_private_qt_if_needed() { local qmake_bin="${QMAKE:-}" if [[ -z "${qmake_bin}" || ! -x "${qmake_bin}" ]]; then - QT_DEPENDS=", libqt6core6t64 | libqt6core6, libqt6gui6, libqt6qml6, libqt6quick6, libqt6network6, libqt6opengl6, libqt6dbus6, libqt6svg6, qml6-module-qtquick, qml6-module-qtquick-Window, qml6-module-qtquick-controls, qml6-module-qtquick-layouts, qml6-module-qtqml-workerscript" + QT_DEPENDS=", libqt6core6t64 | libqt6core6, libqt6gui6, libqt6widgets6, libqt6qml6, libqt6quick6, libqt6network6, libqt6opengl6, libqt6dbus6, libqt6svg6, qml6-module-qtquick, qml6-module-qtquick-Window, qml6-module-qtquick-controls, qml6-module-qtquick-layouts, qml6-module-qtqml-workerscript" return 0 fi local qt_libs qt_plugins qt_qml @@ -85,7 +85,7 @@ bundle_private_qt_if_needed() { qt_plugins="$("${qmake_bin}" -query QT_INSTALL_PLUGINS 2>/dev/null || true)" qt_qml="$("${qmake_bin}" -query QT_INSTALL_QML 2>/dev/null || true)" if [[ -z "${qt_libs}" || "${qt_libs}" == /usr/* ]]; then - QT_DEPENDS=", libqt6core6t64 | libqt6core6, libqt6gui6, libqt6qml6, libqt6quick6, libqt6network6, libqt6opengl6, libqt6dbus6, libqt6svg6, qml6-module-qtquick, qml6-module-qtquick-window, qml6-module-qtquick-controls, qml6-module-qtquick-layouts, qml6-module-qtqml-workerscript" + QT_DEPENDS=", libqt6core6t64 | libqt6core6, libqt6gui6, libqt6widgets6, libqt6qml6, libqt6quick6, libqt6network6, libqt6opengl6, libqt6dbus6, libqt6svg6, qml6-module-qtquick, qml6-module-qtquick-window, qml6-module-qtquick-controls, qml6-module-qtquick-layouts, qml6-module-qtqml-workerscript" return 0 fi @@ -97,6 +97,7 @@ bundle_private_qt_if_needed() { for pattern in \ "libQt6Core.so*" \ "libQt6Gui.so*" \ + "libQt6Widgets.so*" \ "libQt6Qml*.so*" \ "libQt6Quick*.so*" \ "libQt6Network.so*" \ @@ -164,7 +165,7 @@ Priority: optional Architecture: ${DEB_ARCH} Installed-Size: ${INSTALLED_SIZE} Depends: libc6, libgcc-s1, libstdc++6, libgl1, libx11-6, libxcb1, libxkbcommon0, libwayland-client0, libfontconfig1, libfreetype6, libdbus-1-3${QT_DEPENDS} -Recommends: ffmpeg, libopenh264-7 | libopenh264-6, pulseaudio-utils, pipewire, wl-clipboard, xclip, xdg-utils, avahi-daemon, bluez, v4l2loopback-dkms, policykit-1 | pkexec +Recommends: ffmpeg, libopenh264-7 | libopenh264-6, pulseaudio-utils, pipewire, wl-clipboard, xclip, xdotool, xdg-utils, network-manager, avahi-daemon, bluez, v4l2loopback-dkms, policykit-1 | pkexec Maintainer: thtbee Homepage: https://github.com/thtbee/nectarlink Description: Connect your Android phone and Linux PC over LAN From fe3fdf1df5daac3b9b5ed34e7bf88b803e791575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Kirst=C3=A4tter?= <7329802+jochenkirstaetter@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:53:46 +0400 Subject: [PATCH 4/5] fix(linux): omit /proc/net/arp in net fallback and report None exit code for watched PIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jochen Kirstätter <7329802+jochenkirstaetter@users.noreply.github.com> --- core/nectarlink-cli/src/main.rs | 2 +- desktop/app/src/linux/net.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/core/nectarlink-cli/src/main.rs b/core/nectarlink-cli/src/main.rs index 71693db6..93bc4744 100644 --- a/core/nectarlink-cli/src/main.rs +++ b/core/nectarlink-cli/src/main.rs @@ -5676,7 +5676,7 @@ async fn run_notify_when( title: title.clone(), active: false, elapsed_ms: total_elapsed, - exit_code: Some(0), + exit_code: None, }; let took = TaskNotify::format_took(total_elapsed); let send_res = transport.send(device, &finished).await; diff --git a/desktop/app/src/linux/net.rs b/desktop/app/src/linux/net.rs index d95170c7..9c300ace 100644 --- a/desktop/app/src/linux/net.rs +++ b/desktop/app/src/linux/net.rs @@ -52,8 +52,7 @@ static STOP_FLAG: Mutex>> = Mutex::new(None); fn proc_network_snapshot() -> String { let route = fs::read_to_string("/proc/net/route").unwrap_or_default(); let inet6 = fs::read_to_string("/proc/net/if_inet6").unwrap_or_default(); - let fib_trie = fs::read_to_string("/proc/net/arp").unwrap_or_default(); - format!("{route}\n---\n{inet6}\n---\n{fib_trie}") + format!("{route}\n---\n{inet6}") } /// Calls `callback` whenever an IP address or route is added or removed on the PC. From 6adc2db2b60ee25ed2f9bacfc6140f96f9673998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Kirst=C3=A4tter?= <7329802+jochenkirstaetter@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:50:41 +0400 Subject: [PATCH 5/5] fix(linux): incorporate linux-review-fixes for Part 2 basics, Doctor, D-Bus, and packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the Part 2 review improvements and fixes from upstream/linux-review-fixes (b346bfe, 47e3e06, 6291b51, a815275, 0be2ddf, 20c70f3, ccb9318): - USB ADB: retry another local TCP port only when adb reports a port bind failure (47e3e06) - Linux platform layer: native D-Bus/Netlink/Qt integrations for clipboard, tray, notifications (6291b51), single-instance activation token, BlueZ/NetworkManager, Wake-on-LAN, BLE beacon, and clean SIGTERM/SIGINT/SIGHUP shutdown (ccb9318) - Shell & Doctor: remove Windows settings links/explorer.exe shims (20c70f3) and provide plain Linux Doctor readiness checks and wording with German and Chinese translations (a815275) - Release workflow & packaging: pin AppImage tools with SHA-256 verification and smoke-test every Linux package (.deb, AppImage, tarball) with --version (0be2ddf) Many thanks to @thtbee for the thorough review and reference implementation on linux-review-fixes! Co-authored-by: Bee <302257494+thtbee@users.noreply.github.com> Signed-off-by: Jochen Kirstätter <7329802+jochenkirstaetter@users.noreply.github.com> --- .github/workflows/release.yml | 75 +- core/nectarlink-adb/src/lib.rs | 106 +- core/nectarlink-cli/src/main.rs | 22 +- desktop/app/cpp/app_helpers.cpp | 476 +- desktop/app/cpp/app_helpers.h | 19 +- desktop/app/cpp/native_window.cpp | 357 +- desktop/app/cpp/native_window.h | 5 + desktop/app/i18n/nectarlink_de.ts | 3979 +++++++++-------- desktop/app/i18n/nectarlink_zh_CN.ts | 2325 +++++----- desktop/app/i18n/rust_strings.h | 28 +- desktop/app/i18n/template/nectarlink.ts | 2327 +++++----- desktop/app/qml/MainWindow.qml | 6 +- desktop/app/qml/components/DoctorSheet.qml | 156 +- desktop/app/qml/pages/AppsPage.qml | 4 +- desktop/app/qml/pages/HomePage.qml | 11 +- desktop/app/qml/pages/SettingsPage.qml | 60 +- desktop/app/qml/pages/WalkthroughPage.qml | 22 +- desktop/app/src/bridge/app.rs | 13 +- desktop/app/src/bridge/native.rs | 36 + desktop/app/src/bridge/photos.rs | 6 +- desktop/app/src/calls.rs | 64 +- desktop/app/src/core_host.rs | 7 +- desktop/app/src/doctor.rs | 144 +- desktop/app/src/linux/autostart.rs | 27 +- desktop/app/src/linux/ble_beacon.rs | 390 +- desktop/app/src/linux/clipboard.rs | 195 +- desktop/app/src/linux/doctor.rs | 656 ++- desktop/app/src/linux/elevated.rs | 112 +- desktop/app/src/linux/jumplist.rs | 50 +- desktop/app/src/linux/mod.rs | 230 +- desktop/app/src/linux/net.rs | 224 +- desktop/app/src/linux/shell.rs | 672 ++- desktop/app/src/linux/shortcut.rs | 62 +- desktop/app/src/linux/single_instance.rs | 77 +- desktop/app/src/linux/toast.rs | 629 ++- desktop/app/src/linux/tray.rs | 995 ++++- desktop/app/src/linux/widget.rs | 114 +- desktop/app/src/linux/wifi.rs | 110 +- desktop/app/src/linux/wol.rs | 369 +- desktop/app/src/main.rs | 51 +- desktop/app/src/pc_mirror.rs | 4 + desktop/app/src/pc_notifications.rs | 7 +- desktop/app/src/photos.rs | 6 +- desktop/app/src/send_to.rs | 7 +- desktop/app/src/settings.rs | 2 + desktop/packaging/linux/build-appimage.sh | 16 +- desktop/packaging/linux/build-deb.sh | 15 +- desktop/packaging/linux/build-tarball.sh | 14 +- desktop/packaging/linux/nectarlink.desktop | 6 +- .../linux/org.nectarlink.desktop.metainfo.xml | 3 +- docs/guide/troubleshooting.md | 4 +- 51 files changed, 10240 insertions(+), 5055 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9473a8f3..b5932e26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,75 @@ jobs: name: windows-arm64 path: target/package/*-setup.exe + linux: + name: Linux packages (x86_64) + needs: version + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Install the pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - name: Install system build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libgl1-mesa-dev libx11-dev libxcb1-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libfontconfig1-dev \ + libfreetype-dev libdbus-1-dev libxtst-dev file desktop-file-utils appstream + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: linux + target: desktop + arch: linux_gcc_64 + modules: qtshadertools qtwaylandcompositor + cache: true + aqtsource: git+https://github.com/miurahr/aqtinstall.git + - name: Install appimagetool and the AppImage runtime (pinned, checked) + run: | + curl -fsSL -o /tmp/appimagetool \ + https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage + curl -fsSL -o /tmp/runtime-x86_64 \ + https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64 + sha256sum -c - <<'SUMS' + ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool + 2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/runtime-x86_64 + SUMS + chmod +x /tmp/appimagetool + sudo mv /tmp/appimagetool /usr/local/bin/appimagetool + echo "APPIMAGE_RUNTIME=/tmp/runtime-x86_64" >> "$GITHUB_ENV" + - name: Validate desktop entry and AppStream metadata + run: | + desktop-file-validate desktop/packaging/linux/nectarlink.desktop + appstreamcli validate --no-net desktop/packaging/linux/org.nectarlink.desktop.metainfo.xml + - name: Build and package (.deb, AppImage, tarball) + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + cargo build --release -p nectarlink-desktop + ./desktop/packaging/linux/build-deb.sh + ./desktop/packaging/linux/build-appimage.sh + ./desktop/packaging/linux/build-tarball.sh + - name: Check that each package starts + # A package that doesn't start must not reach a release. The .deb goes + # into a clean Ubuntu 24.04, the oldest system the build supports. + run: | + docker run --rm -v "$PWD/dist:/dist:ro" ubuntu:24.04 bash -euc ' + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends /dist/*.deb >/dev/null + nectarlink-desktop --version' + APPIMAGE_EXTRACT_AND_RUN=1 ./dist/*.AppImage --version + mkdir -p /tmp/tarball && tar -xzf dist/*.tar.gz -C /tmp/tarball + /tmp/tarball/*/nectarlink-desktop --version + - uses: actions/upload-artifact@v7 + with: + name: linux + path: | + dist/*.deb + dist/*.AppImage + dist/*.tar.gz + android: name: Android app (signed) needs: version @@ -147,7 +216,7 @@ jobs: publish: name: Draft the release - needs: [windows, windows-arm64, android] + needs: [windows, windows-arm64, linux, android] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 @@ -169,7 +238,7 @@ jobs: name=$(awk '/^\[workspace.metadata.release\]/{f=1;next} /^\[/{f=0} f && /^name = /{print $3}' source/Cargo.toml | tr -d '"') title="NectarLink ${GITHUB_REF_NAME#v}${name:+ $name}" cd files - sha256sum *.exe *.apk > SHA256SUMS.txt + sha256sum *.exe *.deb *.AppImage *.tar.gz *.apk > SHA256SUMS.txt cat SHA256SUMS.txt # The notes: this version's section of CHANGELOG.md ("## ..."), # with its wrapped lines joined, or GitHub's generated notes when @@ -177,5 +246,5 @@ jobs: awk -v v="${GITHUB_REF_NAME#v}" '$1=="##" && $2==v {f=1; next} f && /^## / {exit} f' \ ../source/CHANGELOG.md | awk -f ../source/.github/scripts/unwrap-notes.awk > notes.md || true if [ -s notes.md ]; then notes=(--notes-file notes.md); else notes=(--generate-notes); fi - gh release create "${{ github.ref_name }}" *.exe *.apk SHA256SUMS.txt \ + gh release create "${{ github.ref_name }}" *.exe *.deb *.AppImage *.tar.gz *.apk SHA256SUMS.txt \ --repo "${{ github.repository }}" --draft "${notes[@]}" --title "$title" diff --git a/core/nectarlink-adb/src/lib.rs b/core/nectarlink-adb/src/lib.rs index 48c4a3f9..1ef820cd 100644 --- a/core/nectarlink-adb/src/lib.rs +++ b/core/nectarlink-adb/src/lib.rs @@ -151,16 +151,41 @@ pub async fn forward_tcp( if !is_serial_allowed(serial) { return Err(AdbError::BlockedSerial(serial.to_owned())); } - let port = if local_port == 0 { - let listener = TcpListener::bind("127.0.0.1:0").await?; - listener.local_addr()?.port() - } else { - local_port - }; - let mut stream = TcpStream::connect(server).await?; - let cmd = format!("host-serial:{serial}:forward:tcp:{port};tcp:{remote_port}"); - send_command(&mut stream, &cmd).await?; - Ok(port) + let mut port = if local_port == 0 { free_local_port().await? } else { local_port }; + let mut attempts = 0; + loop { + let mut stream = TcpStream::connect(server).await?; + let cmd = format!("host-serial:{serial}:forward:tcp:{port};tcp:{remote_port}"); + match send_command(&mut stream, &cmd).await { + Ok(()) => return Ok(port), + // A port that was free a moment ago can still be taken by the time + // adb binds it (on WSL the Windows side can hold a port the Linux + // side just closed). Only then is another port worth trying. + Err(AdbError::Remote(reason)) + if local_port == 0 && attempts < BIND_RETRIES && is_bind_failure(&reason) => + { + attempts += 1; + port = free_local_port().await?; + } + Err(e) => return Err(e), + } + } +} + +/// How many other ports [`forward_tcp`] tries when adb can't bind the one it picked. +const BIND_RETRIES: u32 = 4; + +/// A local TCP port that is free right now. +async fn free_local_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + Ok(listener.local_addr()?.port()) +} + +/// Whether an adb `FAIL` reason means the local port couldn't be bound, +/// for example "cannot bind listener: Address already in use". +fn is_bind_failure(reason: &str) -> bool { + let reason = reason.to_ascii_lowercase(); + reason.contains("cannot bind") || reason.contains("already in use") } /// Removes a TCP port forward (`adb -s forward --remove tcp:`). @@ -256,6 +281,67 @@ emulator-5554 device product:sdk_gphone64_x86_64 model:Pixel_9 device:e assert!(!devices[2].is_online()); } + #[test] + fn only_bind_failures_count_as_bind_failures() { + assert!(is_bind_failure( + "cannot bind listener: cannot bind to 127.0.0.1:5000: Address already in use (98)" + )); + assert!(is_bind_failure("Address already in use")); + assert!(!is_bind_failure("device offline")); + assert!(!is_bind_failure("device unauthorized.")); + assert!(!is_bind_failure("more than one device/emulator")); + } + + /// A mock adb server that answers each `forward` with the next of + /// `replies` (`None` = OKAY, `Some(reason)` = FAIL) and records the commands. + async fn forward_server(replies: Vec>) -> (SocketAddr, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let commands = Arc::new(Mutex::new(Vec::new())); + let seen = commands.clone(); + tokio::spawn(async move { + let mut replies = replies.into_iter(); + while let Ok((mut socket, _)) = listener.accept().await { + let Ok(cmd) = read_payload(&mut socket).await else { continue }; + seen.lock().unwrap().push(cmd); + match replies.next().flatten() { + None => { + let _ = socket.write_all(b"OKAY").await; + } + Some(reason) => { + let frame = format!("FAIL{:04x}{reason}", reason.len()); + let _ = socket.write_all(frame.as_bytes()).await; + } + } + let _ = socket.flush().await; + } + }); + (addr, commands) + } + + #[tokio::test] + async fn forward_tries_another_port_only_when_adb_cannot_bind() { + let (addr, commands) = + forward_server(vec![Some("cannot bind listener: Address already in use"), None]).await; + let port = forward_tcp(addr, "emulator-5554", 0, 53319).await.unwrap(); + let commands = commands.lock().unwrap().clone(); + assert_eq!(commands.len(), 2, "one retry after the bind failure"); + assert!(commands[1].contains(&format!("forward:tcp:{port};tcp:53319"))); + + let (addr, commands) = forward_server(vec![Some("device offline")]).await; + let err = forward_tcp(addr, "emulator-5554", 0, 53319).await.unwrap_err(); + assert!(matches!(err, AdbError::Remote(ref r) if r == "device offline")); + assert_eq!(commands.lock().unwrap().len(), 1, "other errors are not retried"); + + let (addr, commands) = forward_server(vec![Some("cannot bind listener"); 10]).await; + assert!(forward_tcp(addr, "emulator-5554", 0, 53319).await.is_err()); + assert_eq!(commands.lock().unwrap().len(), 1 + BIND_RETRIES as usize, "retries are bounded"); + + let (addr, commands) = forward_server(vec![Some("cannot bind listener")]).await; + assert!(forward_tcp(addr, "emulator-5554", 5000, 53319).await.is_err()); + assert_eq!(commands.lock().unwrap().len(), 1, "a port the caller chose is not swapped"); + } + #[tokio::test] async fn mock_adb_server_forward_killforward_and_track_devices() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/core/nectarlink-cli/src/main.rs b/core/nectarlink-cli/src/main.rs index 93bc4744..eaa2000d 100644 --- a/core/nectarlink-cli/src/main.rs +++ b/core/nectarlink-cli/src/main.rs @@ -3696,8 +3696,16 @@ async fn run(cli: &Cli, node: &Node) -> Result<()> { .map(str::to_owned), ); if node_power(cli) == PowerLevel::Elevated { + SCREEN_APPS.store(true, std::sync::atomic::Ordering::Relaxed); offers.extend( - [nectarlink_core::TOGGLES_WIFI, nectarlink_core::TOGGLES_BLUETOOTH].map(str::to_owned), + [ + nectarlink_core::TOGGLES_WIFI, + nectarlink_core::TOGGLES_BLUETOOTH, + nectarlink_core::MIRROR_CAPTURE, + nectarlink_core::MIRROR_INPUT, + nectarlink_core::MIRROR_VIRTUAL_DISPLAY, + ] + .map(str::to_owned), ); } node.update_power(node_power(cli), offers).await; @@ -4234,6 +4242,16 @@ async fn pair(node: &Node) -> Result<()> { async fn nearby(node: &Node, peer: DeviceId) -> Result<()> { let mut events = node.events(); + if !node.discovered_devices().iter().any(|d| d.id == peer) { + let _ = tokio::time::timeout(Duration::from_secs(5), async { + while let Ok(ev) = events.recv().await { + if matches!(ev, NodeEvent::Discovered(ref d) if d.id == peer) { + break; + } + } + }) + .await; + } let mut task = { let node = node.clone(); tokio::spawn(async move { node.pairing_start_nearby(peer).await }) @@ -5676,7 +5694,7 @@ async fn run_notify_when( title: title.clone(), active: false, elapsed_ms: total_elapsed, - exit_code: None, + exit_code: Some(0), }; let took = TaskNotify::format_took(total_elapsed); let send_res = transport.send(device, &finished).await; diff --git a/desktop/app/cpp/app_helpers.cpp b/desktop/app/cpp/app_helpers.cpp index 8d35aaa8..e215eafe 100644 --- a/desktop/app/cpp/app_helpers.cpp +++ b/desktop/app/cpp/app_helpers.cpp @@ -34,10 +34,12 @@ #endif #ifdef __linux__ +#include #include #include #include #include +#include #include #include #include @@ -555,6 +557,12 @@ uint64_t mac_free_space(rust::Str path, bool &ok) #ifdef __linux__ namespace { +std::atomic &qtEventLoopRunning() +{ + static std::atomic running { false }; + return running; +} + struct LinuxClipboardWatch { std::mutex mutex; std::condition_variable cv; @@ -563,14 +571,18 @@ struct LinuxClipboardWatch { LinuxClipboardWatch &linuxClipboardWatch() { - static LinuxClipboardWatch watch; - return watch; + static LinuxClipboardWatch *watch = new LinuxClipboardWatch(); + return *watch; } struct LinuxTrayState { QPointer icon; QPointer menu; + QPointer takePhotoAction; + QPointer scanDocumentAction; QPointer findPhoneAction; + QPointer openLinkAction; + bool phoneActionsEnabled = false; std::mutex mutex; std::condition_variable cv; std::deque actions; @@ -578,8 +590,8 @@ struct LinuxTrayState { LinuxTrayState &linuxTray() { - static LinuxTrayState state; - return state; + static LinuxTrayState *state = new LinuxTrayState(); + return *state; } void pushTrayAction(const char *action) @@ -604,6 +616,42 @@ void runOnQtMainThread(F &&fn) QMetaObject::invokeMethod(app, std::forward(fn), Qt::QueuedConnection); } } + +template +bool runOnQtMainThreadSync(F &&fn, uint32_t timeout_ms = 500) +{ + QCoreApplication *app = QCoreApplication::instance(); + if (!app) + return false; + if (QThread::currentThread() == app->thread()) { + fn(); + return true; + } + if (!qtEventLoopRunning().load(std::memory_order_acquire)) + return false; + struct SyncState { + std::mutex mutex; + std::condition_variable cv; + bool done = false; + }; + auto state = std::make_shared(); + auto task = std::make_shared>(std::forward(fn)); + QMetaObject::invokeMethod( + app, + [state, task]() { + (*task)(); + { + std::lock_guard lock(state->mutex); + state->done = true; + } + state->cv.notify_all(); + }, + Qt::QueuedConnection); + std::unique_lock lock(state->mutex); + return state->cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), [&state]() { + return state->done; + }); +} } // namespace #endif @@ -614,6 +662,15 @@ std::unique_ptr create_qt_app() static char *s_argv[] = { s_arg0, nullptr }; #ifdef __linux__ auto app = std::unique_ptr(new QApplication(s_argc, s_argv)); + QObject::connect(app.get(), &QCoreApplication::aboutToQuit, app.get(), []() { + qtEventLoopRunning().store(false, std::memory_order_release); + }); + QMetaObject::invokeMethod( + app.get(), + []() { + qtEventLoopRunning().store(true, std::memory_order_release); + }, + Qt::QueuedConnection); if (QClipboard *cb = QGuiApplication::clipboard()) { QObject::connect(cb, &QClipboard::dataChanged, cb, []() { linux_clipboard_notify_external(); @@ -665,17 +722,31 @@ void linux_tray_setup( st.actions.clear(); } + // Menu order matches win/tray.rs: + // Take Photo, Scan Document, Separator, Open, Command Palette, Shelf, Find Phone, Open Link, Separator, Quit. + const bool enabled = st.phoneActionsEnabled; auto *menu = new QMenu(); + QAction *takePhoto = menu->addAction(photoStr); + takePhoto->setEnabled(enabled); + QObject::connect(takePhoto, &QAction::triggered, menu, []() { pushTrayAction("TAKE_PHOTO"); }); + + QAction *scanDoc = menu->addAction(scanStr); + scanDoc->setEnabled(enabled); + QObject::connect(scanDoc, &QAction::triggered, menu, []() { pushTrayAction("SCAN_DOCUMENT"); }); + + menu->addSeparator(); QObject::connect(menu->addAction(openStr), &QAction::triggered, menu, []() { pushTrayAction("OPEN"); }); QObject::connect(menu->addAction(paletteStr), &QAction::triggered, menu, []() { pushTrayAction("COMMAND_PALETTE"); }); QObject::connect(menu->addAction(shelfStr), &QAction::triggered, menu, []() { pushTrayAction("SHELF"); }); - menu->addSeparator(); - QObject::connect(menu->addAction(photoStr), &QAction::triggered, menu, []() { pushTrayAction("TAKE_PHOTO"); }); - QObject::connect(menu->addAction(scanStr), &QAction::triggered, menu, []() { pushTrayAction("SCAN_DOCUMENT"); }); + QAction *findPhone = menu->addAction(findPhoneStr); - findPhone->setEnabled(false); + findPhone->setEnabled(enabled); QObject::connect(findPhone, &QAction::triggered, menu, []() { pushTrayAction("FIND_PHONE"); }); - QObject::connect(menu->addAction(openLinkStr), &QAction::triggered, menu, []() { pushTrayAction("OPEN_LINK"); }); + + QAction *openLink = menu->addAction(openLinkStr); + openLink->setEnabled(enabled); + QObject::connect(openLink, &QAction::triggered, menu, []() { pushTrayAction("OPEN_LINK"); }); + menu->addSeparator(); QObject::connect(menu->addAction(quitStr), &QAction::triggered, menu, []() { pushTrayAction("QUIT"); }); @@ -694,7 +765,10 @@ void linux_tray_setup( st.icon = tray; st.menu = menu; + st.takePhotoAction = takePhoto; + st.scanDocumentAction = scanDoc; st.findPhoneAction = findPhone; + st.openLinkAction = openLink; }); #else (void)icon_path; @@ -727,7 +801,15 @@ void linux_tray_set_find_phone_enabled(bool enabled) { #ifdef __linux__ runOnQtMainThread([enabled]() { - if (auto *act = linuxTray().findPhoneAction.data()) + auto &st = linuxTray(); + st.phoneActionsEnabled = enabled; + if (auto *act = st.takePhotoAction.data()) + act->setEnabled(enabled); + if (auto *act = st.scanDocumentAction.data()) + act->setEnabled(enabled); + if (auto *act = st.findPhoneAction.data()) + act->setEnabled(enabled); + if (auto *act = st.openLinkAction.data()) act->setEnabled(enabled); }); #else @@ -749,7 +831,10 @@ void linux_tray_teardown() if (s.menu) { delete s.menu.data(); } + s.takePhotoAction = nullptr; + s.scanDocumentAction = nullptr; s.findPhoneAction = nullptr; + s.openLinkAction = nullptr; }); #endif } @@ -773,6 +858,101 @@ rust::String linux_tray_wait_action(uint32_t timeout_ms) return rust::String(); } +bool linux_tray_is_available() +{ +#ifdef __linux__ + bool available = false; + if (runOnQtMainThreadSync([&available]() { + available = QSystemTrayIcon::isSystemTrayAvailable(); + })) { + return available; + } +#endif + return false; +} + +bool linux_work_area(int32_t &x, int32_t &y, int32_t &w, int32_t &h) +{ +#ifdef __linux__ + bool ok = false; + int32_t rx = 0, ry = 0, rw = 0, rh = 0; + if (runOnQtMainThreadSync([&]() { + if (QScreen *screen = QGuiApplication::primaryScreen()) { + const QRect avail = screen->availableGeometry(); + const qreal dpr = screen->devicePixelRatio() > 0.0 ? screen->devicePixelRatio() : 1.0; + if (avail.width() > 0 && avail.height() > 0) { + rx = static_cast(avail.x() * dpr); + ry = static_cast(avail.y() * dpr); + rw = static_cast(avail.width() * dpr); + rh = static_cast(avail.height() * dpr); + ok = true; + } + } + }) + && ok) { + x = rx; + y = ry; + w = rw; + h = rh; + return true; + } +#else + (void)x; + (void)y; + (void)w; + (void)h; +#endif + return false; +} + +bool linux_screen_geometry(int32_t &x, int32_t &y, int32_t &w, int32_t &h) +{ +#ifdef __linux__ + bool ok = false; + int32_t rx = 0, ry = 0, rw = 0, rh = 0; + if (runOnQtMainThreadSync([&]() { + if (QScreen *screen = QGuiApplication::primaryScreen()) { + const QRect geom = screen->geometry(); + const qreal dpr = screen->devicePixelRatio() > 0.0 ? screen->devicePixelRatio() : 1.0; + if (geom.width() > 0 && geom.height() > 0) { + rx = static_cast(geom.x() * dpr); + ry = static_cast(geom.y() * dpr); + rw = static_cast(geom.width() * dpr); + rh = static_cast(geom.height() * dpr); + ok = true; + } + } + }) + && ok) { + x = rx; + y = ry; + w = rw; + h = rh; + return true; + } +#else + (void)x; + (void)y; + (void)w; + (void)h; +#endif + return false; +} + +void linux_set_activation_token(rust::Str token) +{ +#ifdef __linux__ + if (token.empty()) + return; + const QByteArray tok(token.data(), static_cast(token.size())); + runOnQtMainThread([tok]() { + qputenv("XDG_ACTIVATION_TOKEN", tok); + }); +#else + (void)token; +#endif +} + uint64_t linux_clipboard_sequence() { #ifdef __linux__ @@ -812,3 +992,279 @@ void linux_clipboard_notify_external() w.cv.notify_all(); #endif } + +bool linux_clipboard_read( + bool check_privacy, + bool &is_private, + bool &has_files, + rust::String &text, + rust::String &html, + rust::String &rtf, + rust::Vec &png_bytes) +{ + is_private = false; + has_files = false; + text = rust::String(); + html = rust::String(); + rtf = rust::String(); + png_bytes.clear(); +#ifdef __linux__ + bool ran = false; + bool priv = false; + bool files = false; + std::string outText; + std::string outHtml; + std::string outRtf; + QByteArray outPng; + + if (!runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + ran = true; + const QMimeData *mime = cb->mimeData(QClipboard::Clipboard); + if (!mime) + return; + + const QStringList formats = mime->formats(); + if (check_privacy) { + for (const QString &fmt : formats) { + if (fmt.compare(QStringLiteral("x-gnome-privacy-hint"), Qt::CaseInsensitive) == 0 + || fmt.compare(QStringLiteral("ExcludeClipboardContentFromMonitorProcessing"), Qt::CaseInsensitive) == 0 + || fmt.compare(QStringLiteral("Clipboard Viewer Ignore"), Qt::CaseInsensitive) == 0) { + priv = true; + return; + } + if (fmt.compare(QStringLiteral("x-kde-passwordManagerHint"), Qt::CaseInsensitive) == 0) { + const QByteArray val = mime->data(fmt).trimmed(); + if (val.isEmpty() || QString::fromUtf8(val).trimmed().compare(QStringLiteral("secret"), Qt::CaseInsensitive) == 0) { + priv = true; + return; + } + } + } + } + + if (mime->hasFormat(QStringLiteral("image/png"))) { + outPng = mime->data(QStringLiteral("image/png")); + if (!outPng.isEmpty()) + return; + } + if (mime->hasImage()) { + const QImage img = qvariant_cast(mime->imageData()); + if (!img.isNull()) { + QBuffer buf(&outPng); + if (buf.open(QIODevice::WriteOnly) && img.save(&buf, "PNG")) { + return; + } + outPng.clear(); + } + } + + for (const QString &fmt : formats) { + if (fmt.compare(QStringLiteral("x-special/gnome-copied-files"), Qt::CaseInsensitive) == 0 + || fmt.compare(QStringLiteral("x-special/mate-copied-files"), Qt::CaseInsensitive) == 0) { + files = true; + return; + } + } + if (mime->hasUrls()) { + for (const QUrl &u : mime->urls()) { + if (u.isLocalFile()) { + files = true; + return; + } + } + } + + if (mime->hasText()) { + outText = mime->text().toStdString(); + } + if (mime->hasHtml()) { + outHtml = mime->html().toStdString(); + } + if (mime->hasFormat(QStringLiteral("text/rtf"))) { + const QByteArray r = mime->data(QStringLiteral("text/rtf")); + outRtf.assign(r.constData(), static_cast(r.size())); + } else if (mime->hasFormat(QStringLiteral("application/rtf"))) { + const QByteArray r = mime->data(QStringLiteral("application/rtf")); + outRtf.assign(r.constData(), static_cast(r.size())); + } + })) { + return false; + } + if (!ran) + return false; + + is_private = priv; + has_files = files; + text = rust::String(outText); + html = rust::String(outHtml); + rtf = rust::String(outRtf); + if (!outPng.isEmpty()) { + png_bytes.reserve(static_cast(outPng.size())); + const auto *raw = reinterpret_cast(outPng.constData()); + for (qsizetype i = 0; i < outPng.size(); ++i) + png_bytes.push_back(raw[i]); + } + return true; +#else + (void)check_privacy; + return false; +#endif +} + +rust::Vec linux_clipboard_read_files() +{ + rust::Vec result; +#ifdef __linux__ + std::vector paths; + if (runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + const QMimeData *mime = cb->mimeData(QClipboard::Clipboard); + if (!mime) + return; + if (mime->hasUrls()) { + for (const QUrl &u : mime->urls()) { + if (u.isLocalFile()) { + const QString local = u.toLocalFile(); + if (!local.isEmpty() && QFileInfo::exists(local)) + paths.push_back(local.toStdString()); + } + } + } + if (paths.empty()) { + for (const char *fmt : { "x-special/gnome-copied-files", "x-special/mate-copied-files", "text/uri-list" }) { + const QString qfmt = QString::fromLatin1(fmt); + if (mime->hasFormat(qfmt)) { + const QString raw = QString::fromUtf8(mime->data(qfmt)); + for (const QString &line : raw.split(QLatin1Char('\n'))) { + const QString trimmed = line.trimmed(); + if (trimmed.startsWith(QStringLiteral("file://"))) { + const QUrl u(trimmed); + if (u.isLocalFile()) { + const QString local = u.toLocalFile(); + if (!local.isEmpty() && QFileInfo::exists(local)) + paths.push_back(local.toStdString()); + } + } + } + if (!paths.empty()) + break; + } + } + } + })) { + for (const std::string &p : paths) + result.push_back(rust::String(p)); + } +#endif + return result; +} + +bool linux_clipboard_write_text(rust::Str text, rust::Str html, rust::Str rtf, bool is_private) +{ +#ifdef __linux__ + const QString qtext = QString::fromUtf8(text.data(), static_cast(text.size())); + const QString qhtml = QString::fromUtf8(html.data(), static_cast(html.size())); + const QByteArray qrtf(rtf.data(), static_cast(rtf.size())); + bool ok = false; + if (runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + auto *mime = new QMimeData(); + mime->setText(qtext); + if (is_private) { + mime->setData(QStringLiteral("x-kde-passwordManagerHint"), QByteArrayLiteral("secret")); + mime->setData(QStringLiteral("x-gnome-privacy-hint"), QByteArrayLiteral("1")); + mime->setData(QStringLiteral("ExcludeClipboardContentFromMonitorProcessing"), QByteArrayLiteral("1")); + mime->setData(QStringLiteral("Clipboard Viewer Ignore"), QByteArrayLiteral("1")); + } else { + if (!qhtml.isEmpty()) + mime->setHtml(qhtml); + if (!qrtf.isEmpty()) { + mime->setData(QStringLiteral("text/rtf"), qrtf); + mime->setData(QStringLiteral("application/rtf"), qrtf); + } + } + cb->setMimeData(mime, QClipboard::Clipboard); + ok = true; + })) { + return ok; + } +#else + (void)text; + (void)html; + (void)rtf; + (void)is_private; +#endif + return false; +} + +bool linux_clipboard_write_image_png(rust::Slice png) +{ +#ifdef __linux__ + if (png.empty()) + return false; + const QByteArray bytes(reinterpret_cast(png.data()), static_cast(png.size())); + bool ok = false; + if (runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + auto *mime = new QMimeData(); + mime->setData(QStringLiteral("image/png"), bytes); + QImage img; + if (img.loadFromData(bytes, "PNG") && !img.isNull()) + mime->setImageData(img); + cb->setMimeData(mime, QClipboard::Clipboard); + ok = true; + })) { + return ok; + } +#else + (void)png; +#endif + return false; +} + +bool linux_clipboard_write_files(rust::Slice paths) +{ +#ifdef __linux__ + if (paths.empty()) + return false; + QList urls; + QByteArray gnomePayload("copy"); + for (const rust::String &p : paths) { + const QString qp = QString::fromUtf8(p.data(), static_cast(p.size())); + if (!qp.isEmpty()) { + const QUrl u = QUrl::fromLocalFile(qp); + urls.append(u); + gnomePayload.append('\n'); + gnomePayload.append(u.toEncoded()); + } + } + if (urls.isEmpty()) + return false; + bool ok = false; + if (runOnQtMainThreadSync([&]() { + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) + return; + auto *mime = new QMimeData(); + mime->setUrls(urls); + mime->setData(QStringLiteral("x-special/gnome-copied-files"), gnomePayload); + mime->setData(QStringLiteral("x-special/mate-copied-files"), gnomePayload); + cb->setMimeData(mime, QClipboard::Clipboard); + ok = true; + })) { + return ok; + } +#else + (void)paths; +#endif + return false; +} diff --git a/desktop/app/cpp/app_helpers.h b/desktop/app/cpp/app_helpers.h index ed649c2d..9a04219a 100644 --- a/desktop/app/cpp/app_helpers.h +++ b/desktop/app/cpp/app_helpers.h @@ -70,7 +70,7 @@ bool mac_clipboard_write_text(rust::Str text, bool is_private); int64_t mac_local_offset_ms(int64_t at_ms); uint64_t mac_free_space(rust::Str path, bool &ok); -// Linux QSystemTrayIcon + QMenu and QClipboard change notifications. +// Linux QSystemTrayIcon + QMenu, screen geometry, activation token, and QClipboard. void linux_tray_setup( rust::Str icon_path, rust::Str tooltip, @@ -86,7 +86,24 @@ void linux_tray_set_tooltip(rust::Str tooltip); void linux_tray_set_find_phone_enabled(bool enabled); void linux_tray_teardown(); rust::String linux_tray_wait_action(uint32_t timeout_ms); +bool linux_tray_is_available(); + +bool linux_work_area(int32_t &x, int32_t &y, int32_t &w, int32_t &h); +bool linux_screen_geometry(int32_t &x, int32_t &y, int32_t &w, int32_t &h); +void linux_set_activation_token(rust::Str token); uint64_t linux_clipboard_sequence(); uint64_t linux_clipboard_wait_change(uint64_t last_seq, uint32_t timeout_ms); void linux_clipboard_notify_external(); +bool linux_clipboard_read( + bool check_privacy, + bool &is_private, + bool &has_files, + rust::String &text, + rust::String &html, + rust::String &rtf, + rust::Vec &png_bytes); +rust::Vec linux_clipboard_read_files(); +bool linux_clipboard_write_text(rust::Str text, rust::Str html, rust::Str rtf, bool is_private); +bool linux_clipboard_write_image_png(rust::Slice png); +bool linux_clipboard_write_files(rust::Slice paths); diff --git a/desktop/app/cpp/native_window.cpp b/desktop/app/cpp/native_window.cpp index 1731550b..79526eea 100644 --- a/desktop/app/cpp/native_window.cpp +++ b/desktop/app/cpp/native_window.cpp @@ -2,6 +2,7 @@ #include "native_window.h" #include "app_helpers.h" +#include #include #include #include @@ -15,9 +16,17 @@ #include #include #include +#elif defined(__linux__) +#include +#include +#include +#include +#include +#endif namespace { +#ifdef _WIN32 // Values from the Windows 11 SDK, defined here so older SDK headers work. constexpr DWORD kUseImmersiveDarkMode = 20; // DWMWA_USE_IMMERSIVE_DARK_MODE constexpr DWORD kCaptionColor = 35; // DWMWA_CAPTION_COLOR @@ -49,26 +58,312 @@ void trimD3DResources(QQuickWindow *window) dxgi3->Release(); } } - -} // namespace #else -namespace { -void trimD3DResources(QQuickWindow * /*window*/) {} -} // namespace +void trimD3DResources(QQuickWindow *) +{ +} #endif +#ifdef __linux__ +int ignoreX11Error(void *, void *) +{ + return 0; +} + +void activateX11Window(WId winId) +{ + if (!winId || qEnvironmentVariableIsEmpty("DISPLAY")) + return; + // On Wayland, QWindow::winId() is not an X11 Window ID even when XWayland sets DISPLAY. + if (QGuiApplication::platformName() != QLatin1String("xcb")) + return; + + void *lib = dlopen("libX11.so.6", RTLD_LAZY | RTLD_LOCAL); + if (!lib) + return; + + using XErrorHandlerFn = int (*)(void *, void *); + using XSetErrorHandlerFn = XErrorHandlerFn (*)(XErrorHandlerFn); + using XOpenDisplayFn = void *(*)(const char *); + using XCloseDisplayFn = int (*)(void *); + using XDefaultRootWindowFn = unsigned long (*)(void *); + using XInternAtomFn = unsigned long (*)(void *, const char *, int); + using XSendEventFn = int (*)(void *, unsigned long, int, long, void *); + using XMapRaisedFn = int (*)(void *, unsigned long); + using XSetInputFocusFn = int (*)(void *, unsigned long, int, unsigned long); + using XSyncFn = int (*)(void *, int); + using XFlushFn = int (*)(void *); + + auto xSetErrorHandler = reinterpret_cast(dlsym(lib, "XSetErrorHandler")); + auto xOpenDisplay = reinterpret_cast(dlsym(lib, "XOpenDisplay")); + auto xCloseDisplay = reinterpret_cast(dlsym(lib, "XCloseDisplay")); + auto xDefaultRootWindow = reinterpret_cast(dlsym(lib, "XDefaultRootWindow")); + auto xInternAtom = reinterpret_cast(dlsym(lib, "XInternAtom")); + auto xSendEvent = reinterpret_cast(dlsym(lib, "XSendEvent")); + auto xMapRaised = reinterpret_cast(dlsym(lib, "XMapRaised")); + auto xSetInputFocus = reinterpret_cast(dlsym(lib, "XSetInputFocus")); + auto xSync = reinterpret_cast(dlsym(lib, "XSync")); + auto xFlush = reinterpret_cast(dlsym(lib, "XFlush")); + + if (xOpenDisplay && xCloseDisplay && xDefaultRootWindow && xInternAtom && xSendEvent && xFlush) { + if (void *dpy = xOpenDisplay(nullptr)) { + XErrorHandlerFn prevHandler = xSetErrorHandler ? xSetErrorHandler(ignoreX11Error) : nullptr; + const unsigned long root = xDefaultRootWindow(dpy); + const unsigned long window = static_cast(winId); + const unsigned long netActiveWindow = xInternAtom(dpy, "_NET_ACTIVE_WINDOW", 0); + + if (xMapRaised) + xMapRaised(dpy, window); + + struct XClientMessageEventCompat { + int type; + unsigned long serial; + int send_event; + void *display; + unsigned long window; + unsigned long message_type; + int format; + long l[5]; + long pad[12]; + } ev {}; + ev.type = 33; // ClientMessage + ev.send_event = 1; + ev.display = dpy; + ev.window = window; + ev.message_type = netActiveWindow; + ev.format = 32; + ev.l[0] = 2; // Source indication: 2 = pager / direct user action (bypasses WM focus-stealing prevention) + ev.l[1] = 0; // CurrentTime + ev.l[2] = 0; + + constexpr long kSubstructureRedirectMask = (1L << 20); + constexpr long kSubstructureNotifyMask = (1L << 19); + xSendEvent(dpy, root, 0, kSubstructureRedirectMask | kSubstructureNotifyMask, &ev); + + if (xSetInputFocus) + xSetInputFocus(dpy, window, 1 /* RevertToPointerRoot */, 0 /* CurrentTime */); + + if (xSync) + xSync(dpy, 0); + else + xFlush(dpy); + if (xSetErrorHandler) + xSetErrorHandler(prevHandler); + xCloseDisplay(dpy); + } + } + dlclose(lib); +} + +class LinuxWindowEventFilter : public QObject +{ +public: + explicit LinuxWindowEventFilter(NativeWindow *window) + : QObject(window) + , window_(window) + { + } + +protected: + bool eventFilter(QObject *watched, QEvent *event) override + { + if (watched != window_) + return QObject::eventFilter(watched, event); + + switch (event->type()) { + case QEvent::MouseMove: { + auto *me = static_cast(event); + if (me->buttons() == Qt::NoButton) { + pendingMove_ = false; + manualMove_ = false; + const Qt::Edges edges = hitResizeEdges(me->position()); + updateCursorForEdges(edges); + } else if (me->buttons() & Qt::LeftButton) { + if (pendingMove_) { + if ((me->position() - pressPos_).manhattanLength() >= 4) { + pendingMove_ = false; + if (!window_->startSystemMove()) { + manualMove_ = true; + } + return true; + } + } else if (manualMove_) { + const QPointF delta = me->globalPosition() - globalPressPos_; + window_->setPosition(winStartPos_ + delta.toPoint()); + return true; + } + } + break; + } + case QEvent::MouseButtonPress: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + pendingMove_ = false; + manualMove_ = false; + const QPointF pos = me->position(); + const Qt::Edges edges = hitResizeEdges(pos); + if (edges != Qt::Edges()) { + window_->startSystemResize(edges); + return true; + } + const qreal titleBarHeight = window_->captionHeight(); + if (pos.y() < titleBarHeight + && !window_->isCaptionHoleAt(pos) + && !hitInteractiveControl(window_->contentItem(), pos)) { + pendingMove_ = true; + pressPos_ = pos; + globalPressPos_ = me->globalPosition(); + winStartPos_ = window_->position(); + } + } + break; + } + case QEvent::MouseButtonRelease: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + pendingMove_ = false; + if (manualMove_) { + manualMove_ = false; + return true; + } + } + break; + } + case QEvent::MouseButtonDblClick: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + pendingMove_ = false; + manualMove_ = false; + const QPointF pos = me->position(); + const Qt::Edges edges = hitResizeEdges(pos); + const qreal titleBarHeight = window_->captionHeight(); + if (edges == Qt::Edges() && pos.y() < titleBarHeight + && !window_->isCaptionHoleAt(pos) + && !hitInteractiveControl(window_->contentItem(), pos)) { + window_->toggleMaximized(); + return true; + } + } + break; + } + case QEvent::Leave: + if (borderCursorActive_) { + window_->unsetCursor(); + borderCursorActive_ = false; + } + break; + default: + break; + } + return QObject::eventFilter(watched, event); + } + +private: + Qt::Edges hitResizeEdges(const QPointF &pos) const + { + if (!window_ || window_->visibility() == QWindow::Maximized + || window_->minimumSize() == window_->maximumSize()) { + return {}; + } + if (pos.y() < window_->captionHeight() + && (window_->isCaptionHoleAt(pos) || hitInteractiveControl(window_->contentItem(), pos))) { + return {}; + } + const qreal w = window_->width(); + const qreal h = window_->height(); + const qreal b = resizeBorderWidth_; + Qt::Edges edges; + if (pos.x() < b) + edges |= Qt::LeftEdge; + else if (pos.x() >= w - b) + edges |= Qt::RightEdge; + if (pos.y() < b) + edges |= Qt::TopEdge; + else if (pos.y() >= h - b) + edges |= Qt::BottomEdge; + return edges; + } + + void updateCursorForEdges(Qt::Edges edges) + { + if (edges == Qt::Edges()) { + if (borderCursorActive_) { + window_->unsetCursor(); + borderCursorActive_ = false; + } + return; + } + Qt::CursorShape shape = Qt::ArrowCursor; + if ((edges == (Qt::TopEdge | Qt::LeftEdge)) || (edges == (Qt::BottomEdge | Qt::RightEdge))) + shape = Qt::SizeFDiagCursor; + else if ((edges == (Qt::TopEdge | Qt::RightEdge)) || (edges == (Qt::BottomEdge | Qt::LeftEdge))) + shape = Qt::SizeBDiagCursor; + else if (edges & (Qt::LeftEdge | Qt::RightEdge)) + shape = Qt::SizeHorCursor; + else if (edges & (Qt::TopEdge | Qt::BottomEdge)) + shape = Qt::SizeVerCursor; + window_->setCursor(QCursor(shape)); + borderCursorActive_ = true; + } + + bool hitInteractiveControl(QQuickItem *item, const QPointF &scenePos) const + { + if (!item || !item->isVisible() || !item->isEnabled() || item->opacity() <= 0.0) + return false; + const QPointF localPos = item->mapFromScene(scenePos); + if (!item->contains(localPos)) + return false; + if (item == window_->maximizeButton() || item->property("_nl_caption_hole").toBool()) + return true; + const auto children = item->childItems(); + for (int i = children.size() - 1; i >= 0; --i) { + if (hitInteractiveControl(children[i], scenePos)) + return true; + } + if (item != window_->contentItem() + && item->acceptedMouseButtons() != Qt::NoButton) { + return true; + } + return false; + } + + NativeWindow *window_ = nullptr; + int resizeBorderWidth_ = 6; + bool borderCursorActive_ = false; + bool pendingMove_ = false; + bool manualMove_ = false; + QPointF pressPos_; + QPointF globalPressPos_; + QPoint winStartPos_; +}; +#endif + +} // namespace + NativeWindow::NativeWindow(QWindow *parent) : QQuickWindow(parent) { + // Qt removes the system title bar and lets content fill the window. #ifdef _WIN32 - // Qt removes the system title bar and lets content fill the window on Windows. setFlags(flags() | Qt::ExpandedClientAreaHint); -#endif setColor(Qt::transparent); +#elif defined(__linux__) + setFlags(flags() | Qt::FramelessWindowHint); + setColor(QColor(0x14, 0x13, 0x11)); +#else + setColor(Qt::transparent); +#endif // Drop the scene graph and D3D/RHI resources when the window is hidden or // closed to the tray instead of keeping GPU buffers in private commit. setPersistentSceneGraph(false); setPersistentGraphics(false); + connect(this, &QWindow::visibilityChanged, this, [this](QWindow::Visibility) { + emit windowVisibilityChanged(); + }); +#ifdef __linux__ + setProperty("_nl_linux_filter", true); + installEventFilter(new LinuxWindowEventFilter(this)); +#endif } void NativeWindow::setBackdrop(bool on) @@ -125,12 +420,16 @@ void NativeWindow::setMaximizeState(bool hovered, bool pressed) void NativeWindow::addCaptionHole(QQuickItem *item) { + if (item) + item->setProperty("_nl_caption_hole", true); if (item && std::none_of(m_holes.begin(), m_holes.end(), [item](const auto &h) { return h == item; })) m_holes.emplace_back(item); } void NativeWindow::removeCaptionHole(QQuickItem *item) { + if (item) + item->setProperty("_nl_caption_hole", false); m_holes.erase(std::remove_if(m_holes.begin(), m_holes.end(), [item](const auto &h) { return h.isNull() || h == item; }), m_holes.end()); @@ -146,8 +445,12 @@ void NativeWindow::toggleMaximized() void NativeWindow::bringToFront() { - if (visibility() == QWindow::Minimized || !isVisible()) + if (visibility() == QWindow::Minimized) { + setWindowStates((windowStates() & ~Qt::WindowMinimized) | Qt::WindowActive); + show(); + } else if (!isVisible()) { showNormal(); + } raise(); requestActivate(); #ifdef _WIN32 @@ -169,14 +472,17 @@ void NativeWindow::bringToFront() SetForegroundWindow(hwnd); if (attached) AttachThreadInput(thisThread, foregroundThread, FALSE); +#elif defined(__linux__) + if (handle()) + activateX11Window(winId()); #endif } void NativeWindow::flash() { -#ifdef _WIN32 if (!handle()) return; +#ifdef _WIN32 FLASHWINFO info { sizeof(FLASHWINFO), hwndOf(this), FLASHW_TRAY | FLASHW_TIMERNOFG, 0, 0 }; FlashWindowEx(&info); #else @@ -197,6 +503,16 @@ void NativeWindow::releaseAndTeardown() destroy(); } +bool NativeWindow::grabToFile(const QString &path) +{ + if (path.isEmpty()) + return false; + const QImage image = grabWindow(); + if (image.isNull()) + return false; + return image.save(path); +} + bool NativeWindow::event(QEvent *event) { if (QQmlEngine *engine = qmlEngine(this)) @@ -237,6 +553,14 @@ void NativeWindow::applyFrame() DwmSetWindowAttribute(hwnd, kSystemBackdropType, &backdrop, sizeof(backdrop)); const COLORREF none = kColorNone; DwmSetWindowAttribute(hwnd, kCaptionColor, &none, sizeof(none)); +#elif defined(__linux__) + setColor(m_darkFrame ? QColor(0x14, 0x13, 0x11) : QColor(0xF8, 0xF6, 0xF2)); + if (!property("_nl_linux_filter").toBool()) { + setProperty("_nl_linux_filter", true); + installEventFilter(new LinuxWindowEventFilter(this)); + } +#else + setColor(m_darkFrame ? QColor(0x14, 0x13, 0x11) : QColor(0xF8, 0xF6, 0xF2)); #endif } @@ -247,6 +571,17 @@ bool NativeWindow::itemContains(const QQuickItem *item, const QPointF &scenePoin return item->mapRectToScene(QRectF(0, 0, item->width(), item->height())).contains(scenePoint); } +bool NativeWindow::isCaptionHoleAt(const QPointF &scenePoint) const +{ + if (itemContains(m_maximizeButton, scenePoint)) + return true; + for (const auto &hole : m_holes) { + if (itemContains(hole, scenePoint)) + return true; + } + return false; +} + long NativeWindow::hitTest(int screenX, int screenY) const { #ifdef _WIN32 @@ -348,10 +683,6 @@ bool NativeWindow::nativeEvent(const QByteArray &eventType, void *message, qintp default: break; } -#else - Q_UNUSED(eventType); - Q_UNUSED(message); - Q_UNUSED(result); #endif return QQuickWindow::nativeEvent(eventType, message, result); } diff --git a/desktop/app/cpp/native_window.h b/desktop/app/cpp/native_window.h index ad06b6bb..b97f8341 100644 --- a/desktop/app/cpp/native_window.h +++ b/desktop/app/cpp/native_window.h @@ -63,6 +63,10 @@ class NativeWindow : public QQuickWindow // Releases scene-graph/D3D resources, destroys the native HWND, and // schedules deletion when closing to the tray. Q_INVOKABLE void releaseAndTeardown(); + // Captures the rendered window contents to an image file on disk. + Q_INVOKABLE bool grabToFile(const QString &path); + + bool isCaptionHoleAt(const QPointF &scenePoint) const; signals: @@ -72,6 +76,7 @@ class NativeWindow : public QQuickWindow void captionHeightChanged(); void maximizeButtonChanged(); void maximizeStateChanged(); + void windowVisibilityChanged(); void closeRequested(); protected: diff --git a/desktop/app/i18n/nectarlink_de.ts b/desktop/app/i18n/nectarlink_de.ts index 5c6ae837..7221f1a6 100644 --- a/desktop/app/i18n/nectarlink_de.ts +++ b/desktop/app/i18n/nectarlink_de.ts @@ -4,157 +4,157 @@ App - + Already on clipboard. Bereits in der Zwischenablage. - + Command palette Befehlspalette - + Copied to clipboard. In die Zwischenablage kopiert. - + Could not copy to the clipboard. Konnte nicht in die Zwischenablage kopiert werden. - + Downloading the update · %1 of %2 Update wird heruntergeladen · %1 von %2 - + Downloading the update… Update wird heruntergeladen… - + Find my phone Mein Gerät finden - - My phone's colors + + My phone's colors Farben meines Geräts - - My phone's colors (%1) + + My phone's colors (%1) Farben meines Geräts (%1) - + NectarLink · %1 NectarLink · %1 - + NectarLink · %1 devices connected NectarLink · %1 Geräte verbunden - + NectarLink · 1 device connected NectarLink · 1 Gerät verbunden - + NectarLink · no devices paired NectarLink · keine Geräte gekoppelt - + NectarLink · not connected NectarLink · nicht verbunden - + No phone is paired yet. Noch kein Gerät gekoppelt. - + Open NectarLink NectarLink öffnen - + Open copied link on phone Kopierten Link auf dem Gerät öffnen - + Quit NectarLink NectarLink beenden - + Scan document with phone Dokument mit dem Gerät scannen - + Send cancelled Senden abgebrochen - + Sending to %1… Wird an %1 gesendet… - + Sent text to %1. Text an %1 gesendet. - - Some history couldn't be cleared. Try again. + + Some history couldn't be cleared. Try again. Ein Teil des Verlaufs konnte nicht gelöscht werden. Versuche es erneut. - - Some messages couldn't be cleared. Try again. + + Some messages couldn't be cleared. Try again. Einige Nachrichten konnten nicht gelöscht werden. Versuche es erneut. - + Take photo with phone Foto mit dem Gerät aufnehmen - + That file is no longer in its saved location. Diese Datei befindet sich nicht mehr am gespeicherten Ort. - - The photo couldn't be copied to the clipboard. + + The photo couldn't be copied to the clipboard. Das Foto konnte nicht in die Zwischenablage kopiert werden. - - The received file history couldn't be cleared. Try again. + + The received file history couldn't be cleared. Try again. Der Verlauf empfangener Dateien konnte nicht gelöscht werden. Versuche es erneut. - + Toggle Shelf Shelf ein-/ausblenden - + Wi-Fi %1/4 WLAN %1/4 - + Your phone Dein Gerät @@ -162,72 +162,77 @@ AppsPage - + Phone apps Geräte-Apps - - Open %1's apps here + + Open %1's apps here Apps von %1 hier öffnen - + Pair your phone to open its apps in windows on this PC. Kopple dein Gerät, um seine Apps in Fenstern auf diesem PC zu öffnen. - + + Opening apps in windows isn't supported on this phone. + Apps in Fenstern zu öffnen wird auf diesem Gerät nicht unterstützt. + + + Set up Wireless debugging in NectarLink on the phone to open its apps here. Richte Kabelloses Debugging in NectarLink auf dem Gerät ein, um seine Apps hier zu öffnen. - + Search apps Apps durchsuchen - - Search %1's apps + + Search %1's apps Apps von %1 durchsuchen - + Refresh Aktualisieren - + No apps match. Keine passenden Apps gefunden. - + No apps to open. Keine Apps zum Öffnen vorhanden. - - Getting the phone's apps… + + Getting the phone's apps… Geräte-Apps werden geladen… - + Try again Erneut versuchen - + Recent Zuletzt verwendet - + All apps Alle Apps - + %1, open %1, geöffnet @@ -235,43 +240,43 @@ AppsSheet - + Open an app from %1 App von %1 öffnen - + Refresh Aktualisieren - - + + Search apps Apps durchsuchen - + No apps match. Keine passenden Apps gefunden. - + No apps to open. Keine Apps zum Öffnen vorhanden. - - Getting the phone's apps… + + Getting the phone's apps… Geräte-Apps werden geladen… - + Recent Zuletzt verwendet - + All apps Alle Apps @@ -279,7 +284,7 @@ Automations - + Triggered by %1 Ausgelöst durch %1 @@ -287,17 +292,17 @@ Battery - + %1 is fully charged %1 ist vollständig geladen - + You can unplug it. Du kannst das Ladegerät trennen. - + Your phone Dein Gerät @@ -305,17 +310,17 @@ BtHfp - + Bluetooth connected (%1). Bluetooth verbunden (%1). - + Bluetooth paired with %1 (not currently connected). Per Bluetooth mit %1 gekoppelt (derzeit nicht verbunden). - + No paired Bluetooth phone endpoint found in Windows. Kein gekoppeltes Bluetooth-Gerät in Windows gefunden. @@ -323,72 +328,72 @@ CallCard - + On a call Im Gespräch - + On hold Gehalten - + Muted Stummgeschaltet - + Mute Stummschalten - + Speaker Lautsprecher - + Resume Fortsetzen - + Hold Halten - + Keypad Tastenfeld - + Mute, speaker and keypad need Elevated on the phone (Android 12 or later). Stummschalten, Lautsprecher und Tastenfeld erfordern den erweiterten Modus auf dem Gerät (ab Android 12). - + Connect PC audio PC-Audio verbinden - + Bluetooth settings Bluetooth-Einstellungen - + Call volume down Anruflautstärke verringern - + End call Anruf beenden - + Call volume up Anruflautstärke erhöhen @@ -396,27 +401,27 @@ Calls - + Calling %1 %1 wird angerufen - + Missed call from %1 Verpasster Anruf von %1 - + On a call with %1 Im Gespräch mit %1 - + That call is over. Dieser Anruf ist beendet. - + Unknown caller Unbekannter Anrufer @@ -424,283 +429,283 @@ CallsPage - + Today Heute - - + + Yesterday Gestern - + %1s %1s - + Call from your PC Vom PC aus anrufen - - - - %1 isn't connected + + + + %1 isn't connected %1 ist nicht verbunden - + Pair your phone to view calls and contacts. Kopple dein Gerät, um Anrufe und Kontakte anzuzeigen. - + Connect %1 to view calls and contacts. Verbinde %1, um Anrufe und Kontakte anzuzeigen. - + Recent Verlauf - - + + Contacts Kontakte - + Refresh Aktualisieren - - + + Search calls Anrufe durchsuchen - - + + Search contacts Kontakte durchsuchen - + Clear search Suche löschen - + Calls are off for %1 Anrufe sind für %1 deaktiviert - + Allow call log on %1 Anrufliste auf %1 erlauben - - Couldn't load recent calls + + Couldn't load recent calls Anrufliste konnte nicht geladen werden - + Enable Calls in Settings. Aktiviere Anrufe in den Einstellungen. - + Grant call log permission on %1. Erteile die Berechtigung für die Anrufliste auf %1. - + Connect %1 to view recent calls. Verbinde %1, um die letzten Anrufe anzuzeigen. - + Unable to load call log. Anrufliste kann nicht geladen werden. - - + + Try again Erneut versuchen - + No calls match. Keine passenden Anrufe gefunden. - + No recent calls. Keine aktuellen Anrufe. - + Contacts are off for %1 Kontakte sind für %1 deaktiviert - + Allow contacts on %1 Kontakte auf %1 erlauben - - Couldn't load contacts + + Couldn't load contacts Kontakte konnten nicht geladen werden - + Enable Contacts in Settings. Aktiviere Kontakte in den Einstellungen. - + Grant contacts permission on %1. Erteile die Kontakte-Berechtigung auf %1. - + Connect %1 to view contacts. Verbinde %1, um Kontakte anzuzeigen. - + Unable to load contacts. Kontakte können nicht geladen werden. - + No contacts match. Keine passenden Kontakte gefunden. - + No contacts on this phone. Keine Kontakte auf diesem Gerät. - + Close contact Kontakt schließen - + Copy number Nummer kopieren - - - + + + Text %1 SMS an %1 - - - + + + Call %1 %1 anrufen - + Phone number to call Anzurufende Telefonnummer - + Enter a number Nummer eingeben - + Backspace Rücktaste - + Text SMS - + Calling… Wird angerufen… - + Call Anrufen - + Clear number Nummer löschen - + PC call audio connected PC-Anrufaudio verbunden - + PC call audio ready PC-Anrufaudio bereit - + PC call audio (Bluetooth) PC-Anrufaudio (Bluetooth) - + Connect PC audio PC-Audio verbinden - + Open Bluetooth settings Bluetooth-Einstellungen öffnen - + Missed Verpasst - + Outgoing Ausgehend - + Declined Abgelehnt - + Incoming Eingehend - + Favorites Favoriten - + +%1 more +%1 weitere @@ -708,22 +713,22 @@ CaptionButtons - + Minimize Minimieren - + Restore Wiederherstellen - + Maximize Maximieren - + Close Schließen @@ -731,570 +736,570 @@ Clipboard - + Copied from %1 Von %1 kopiert - + Copied from %1. Von %1 kopiert. - + Copied to clipboard In die Zwischenablage kopiert - + Sending copied files to %1… Kopierte Dateien werden an %1 gesendet… - + Sent to %1. An %1 gesendet. - + That clip is no longer in clipboard history. Dieser Eintrag ist nicht mehr im Zwischenablageverlauf. - - That image can't be read. + + That image can't be read. Dieses Bild kann nicht gelesen werden. - + That image is too large to send. Dieses Bild ist zu groß zum Senden. - - That's too much text to send at once. + + That's too much text to send at once. Das ist zu viel Text auf einmal. - + The clipboard is turned off for %1. Die Zwischenablage ist für %1 deaktiviert. - - There's no text, image, or file on the clipboard. + + There's no text, image, or file on the clipboard. Es befinden sich weder Text noch Bilder oder Dateien in der Zwischenablage. - + Update NectarLink on %1 to send it images. Aktualisiere NectarLink auf %1, um Bilder dorthin zu senden. - - What you copied is marked private by its app, so it wasn't sent. + + What you copied is marked private by its app, so it wasn't sent. Der kopierte Inhalt wurde von der App als vertraulich markiert und daher nicht gesendet. CommandPalette - + Type a command, contact, or app… Befehl, Kontakt oder App eingeben… - + Command palette search Suche in der Befehlspalette - + Clear search Suche löschen - + Close command palette Befehlspalette schließen - + Clear Löschen - + Esc Esc - + No matching commands Keine passenden Befehle - + ↑↓ to navigate · Enter to run · Esc to close ↑↓ zum Navigieren · Eingabe zum Ausführen · Esc zum Schließen - + %1 · %2 ms %1 · %2 ms - + Browse all 28 features, live capability status, and where to find them Alle 28 Funktionen, den Live-Status und ihre Fundorte durchsuchen - + Browse phone storage in File Explorer Gerätespeicher im Datei-Explorer durchsuchen - + Call %1 %1 anrufen - + Calling %1 on %2… %1 wird auf %2 angerufen… - + Capture a photo on your phone and paste it into the active window Foto mit dem Gerät aufnehmen und in das aktive Fenster einfügen - + Check Wake-on-LAN status in Settings Wake-on-LAN-Status in den Einstellungen prüfen - + Connection Doctor Verbindungs-Doktor - + Continue copied video on phone Kopiertes Video auf dem Gerät fortsetzen - + Copied image Kopiertes Bild - + Copy: %1 Kopieren: %1 - + Diagnose and fix firewall, discovery, or network issues Firewall-, Erkennungs- oder Netzwerkprobleme diagnostizieren und beheben - + Features guide Funktionsübersicht - + Find %1 %1 finden - + Find my phone Mein Gerät finden - + From %1 Von %1 - + Hand off document to phone Dokument an das Gerät übergeben - + Handoff · Doc Übergabe · Dok. - + Handoff · Map Übergabe · Karte - + Handoff · Video Übergabe · Video - + Hide Shelf Shelf ausblenden - + Make %1 ring at full volume %1 bei voller Lautstärke klingeln lassen - + Make phone ring at full volume Gerät bei voller Lautstärke klingeln lassen - + Mirror %1 %1 spiegeln - + Mirror Apps Apps spiegeln - + Mirror phone screen Gerätebildschirm spiegeln - - Mute %1's ringer + + Mute %1's ringer Klingelton von %1 stummschalten - + No physical network adapter found for Wake-on-LAN. Kein physischer Netzwerkadapter für Wake-on-LAN gefunden. - + Open %1 %1 öffnen - + Open %1 in a window from %2 %1 in einem Fenster von %2 öffnen - - Open %1's Cloud Files folder in Explorer + + Open %1's Cloud Files folder in Explorer Cloud-Dateien-Ordner von %1 im Explorer öffnen - + Open Shelf Shelf öffnen - + Open conversation on %1 Unterhaltung auf %1 öffnen - + Open copied link on phone Kopierten Link auf dem Gerät öffnen - + Open copied location in Maps on phone Kopierten Ort in Maps auf dem Gerät öffnen - + Open last photo Letztes Foto öffnen - + Open last photo from %1 Letztes Foto von %1 öffnen - + Open newest photo or screenshot Neuestes Foto oder neuesten Screenshot öffnen - + Open the newest photo or screenshot from %1 Neuestes Foto oder neuesten Screenshot von %1 öffnen - + Open voice recordings folder for %1 Ordner für Sprachaufnahmen von %1 öffnen - + Opened Recordings folder. Start a voice note in NectarLink on %1 to send it here. Ordner „Aufnahmen“ geöffnet. Starte eine Sprachnotiz in NectarLink auf %1, um sie hierher zu senden. - + Pair a new phone Neues Gerät koppeln - + Place a phone call or open in dialer Telefonanruf starten oder im Wählfeld öffnen - + Place call on %1 Anruf auf %1 starten - + Quick Toggles Schnelleinstellungen - + Re-open the step-by-step setup walkthrough Schritt-für-Schritt-Einrichtungsassistenten erneut öffnen - + Ready on %1 Bereit auf %1 - + Recent Activity Letzte Aktivitäten - + Reconnect paired phones and refresh notifications and media Gekoppelte Geräte neu verbinden sowie Benachrichtigungen und Medien aktualisieren - + Record voice Sprache aufnehmen - + Ringer: Silent Klingelton: Lautlos - + Ringer: Sound Klingelton: Ton - + Ringer: Vibrate Klingelton: Vibrieren - + Ringing %1… %1 klingelt… - + Scan a document on your phone and paste it into the active window Dokument mit deinem Gerät scannen und in das aktive Fenster einfügen - + Scan document with phone Dokument mit dem Gerät scannen - + Send a document to %1 and open it automatically upon arrival Dokument an %1 senden und nach dem Empfang automatisch öffnen - + Send clipboard Zwischenablage senden - + Send clipboard to %1 Zwischenablage an %1 senden - + Send copied text or image Kopierten Text oder kopiertes Bild senden - + Send copied text or image to %1 Kopierten Text oder kopiertes Bild an %1 senden - + Send live keystrokes and text to %1 without screen mirroring Tastenanschläge und Text ohne Bildschirmspiegelung live an %1 senden - - Set %1's ringer mode to normal sound + + Set %1's ringer mode to normal sound Klingeltonmodus von %1 auf Ton stellen - - Set %1's ringer mode to vibrate + + Set %1's ringer mode to vibrate Klingeltonmodus von %1 auf Vibration stellen - + Setup walkthrough Einrichtungsassistent - + Show QR code and pairing code to link another phone QR-Code und Kopplungscode anzeigen, um ein weiteres Gerät zu verbinden - + Slide-out edge panel with latest photo, screenshot, clipboard, and recent files Seitenleiste am Bildschirmrand mit neuestem Foto, Screenshot, Zwischenablage und aktuellen Dateien - + Start a new message conversation Neue Nachrichtenunterhaltung beginnen - + Starting phone webcam… Geräte-Webcam wird gestartet… - + Stop phone webcam Geräte-Webcam beenden - + Stop typing on phone Tippen auf dem Gerät beenden - + Stopped phone webcam. Geräte-Webcam beendet. - - Stream %1's camera into the NectarLink virtual webcam + + Stream %1's camera into the NectarLink virtual webcam Kamera von %1 an die virtuelle NectarLink-Webcam übertragen - - Stream %1's screen in a desktop window + + Stream %1's screen in a desktop window Bildschirm von %1 in einem Desktop-Fenster übertragen - + Switch Bluetooth on %1 Bluetooth auf %1 umschalten - + Switch Do Not Disturb on %1 „Nicht stören“ auf %1 umschalten - + Switch Wi-Fi on %1 WLAN auf %1 umschalten - + Switch to %1 Zu %1 wechseln - + Switch torch / flashlight on %1 Taschenlampe auf %1 umschalten - + Sync now Jetzt synchronisieren - + Syncing devices… Geräte werden synchronisiert… - + Take photo with phone Foto mit dem Gerät aufnehmen - + Text %1 SMS an %1 - + That file is no longer in its saved location. Diese Datei befindet sich nicht mehr am gespeicherten Ort. - + Toggle Bluetooth Bluetooth umschalten - + Toggle Do Not Disturb „Nicht stören“ umschalten - + Toggle Flashlight Taschenlampe umschalten - + Toggle Wi-Fi WLAN umschalten - + Type on phone with PC keyboard Mit der PC-Tastatur auf dem Gerät tippen - + Use phone as webcam Gerät als Webcam nutzen - + Wake-on-LAN is ready on %1 (phones can wake this PC). Wake-on-LAN ist auf %1 bereit (Geräte können diesen PC aufwecken). - + Wake-on-LAN on %1 is %2. Check Settings. Wake-on-LAN auf %1 ist %2. Prüfe die Einstellungen. @@ -1302,67 +1307,67 @@ ContinuityCamera - + Allow Photos access for %1 in Settings first. Erlaube zuerst den Fotozugriff für %1 in den Einstellungen. - + Camera capture cancelled on %1. Kameraaufnahme auf %1 abgebrochen. - + Camera is busy on %1. Die Kamera auf %1 wird bereits verwendet. - + Camera permission was denied on %1. Die Kameraberechtigung wurde auf %1 verweigert. - + Couldn’t copy image to clipboard: %1 Bild konnte nicht in die Zwischenablage kopiert werden: %1 - + Couldn’t open camera on %1: %2 Kamera auf %1 konnte nicht geöffnet werden: %2 - + Image is on your clipboard. Press Ctrl+V in your target app. Das Bild ist in deiner Zwischenablage. Drücke Strg+V in der Ziel-App. - + Opening camera on %1… Kamera auf %1 wird geöffnet… - + Opening document scanner on %1… Dokumentenscanner auf %1 wird geöffnet… - + Pair your phone first to take a photo or scan a document. Kopple zuerst dein Gerät, um ein Foto aufzunehmen oder ein Dokument zu scannen. - + Pasted into %1 In %1 eingefügt - + Scan a document on %1… Dokument auf %1 scannen… - + Take a photo on %1… Foto auf %1 aufnehmen… @@ -1370,7 +1375,7 @@ CoreHost - + Dropped %1 queued item(s) for %2 (device unpaired). %1 wartende(s) Element(e) für %2 verworfen (Gerät entkoppelt). @@ -1378,77 +1383,82 @@ Deck - + Alt + Tab Alt + Tab - + Application path must end with .exe or .lnk. Der Anwendungspfad muss auf .exe oder .lnk enden. - + Choose a key for the shortcut. Wähle eine Taste für das Tastenkürzel. - + Choose an application (.exe or .lnk) on this PC. Wähle eine Anwendung (.exe oder .lnk) auf diesem PC. - + Enter a command to run on this PC. Gib einen Befehl ein, der auf diesem PC ausgeführt werden soll. - + + Enter an application path (/usr/bin/..., .desktop, or .AppImage). + Gib den Pfad einer Anwendung ein (/usr/bin/..., .desktop oder .AppImage). + + + Enter an http:// or https:// web address. Gib eine Webadresse mit http:// oder https:// ein. - + Enter the text snippet to type. Gib den einzufügenden Textbaustein ein. - + Invalid action configuration. Ungültige Aktionskonfiguration. - + Invalid tile properties. Ungültige Kacheleigenschaften. - + Media key Medientaste - + Page not found. Seite nicht gefunden. - + This page already has the maximum number of tiles (24). Diese Seite hat bereits die maximale Anzahl an Kacheln (24). - + Tile not found. Kachel nicht gefunden. - + Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.). Verwende eine einzelne Taste (a-z, 0-9, f1-f12, enter, tab, space, escape, Pfeiltasten usw.). - + Web address must start with http:// or https://. Die Webadresse muss mit http:// oder https:// beginnen. @@ -1456,383 +1466,398 @@ DeckPage - - - + + + Main Hauptseite - - - + + + Media Medien - + Play / pause Wiedergabe / Pause - + Previous track Vorheriger Titel - + Next track Nächster Titel - - - - + + + + Audio Audio - + Volume down Leiser - + Volume up Lauter - + Mute speaker Lautsprecher stummschalten - + Mute microphone Mikrofon stummschalten - - - - + + + + System System - + Show desktop (Win+D) Desktop anzeigen (Win+D) - + + Show desktop (Super+D) + Desktop anzeigen (Super+D) + + + Switch window (Alt+Tab) Fenster wechseln (Alt+Tab) - + Screenshot (Win+Shift+S) Screenshot (Win+Umschalt+S) - + + Screenshot + Screenshot + + + Lock PC PC sperren - - - + + + Custom Benutzerdefiniert - + Keyboard shortcut Tastenkürzel - + Open website Website öffnen - + Type text snippet Textbaustein tippen - - + + Apps Apps - + Launch application (.desktop / binary) Anwendung starten (.desktop / Programmdatei) - + Launch application (.exe / .lnk) Anwendung starten (.exe / .lnk) - + Run shell command Shell-Befehl ausführen - + Choose an application or shortcut Anwendung oder Verknüpfung auswählen - + Applications and launchers (*.desktop *.AppImage *) Anwendungen und Starter (*.desktop *.AppImage *) - + All files (*) Alle Dateien (*) - + Applications and shortcuts (*.exe *.lnk) Anwendungen und Verknüpfungen (*.exe *.lnk) - + Launch app App starten - + Control Deck Steuerungs-Deck - + Drag to reorder · click to edit. Ziehen zum Neuordnen · Klicken zum Bearbeiten. - + Reset defaults Auf Standard zurücksetzen - - - - + + + + Add tile Kachel hinzufügen - + Speaker muted (%1%) Lautsprecher stumm (%1%) - + Speaker %1% Lautsprecher %1% - + Mic muted Mikro stumm - + Mic live Mikro aktiv - + No mic Kein Mikro - + Media playing Medienwiedergabe aktiv - + Media paused Medien pausiert - - + + New page Neue Seite - + Page %1 Seite %1 - - + + Rename page Seite umbenennen - + Delete page Seite löschen - + Test %1 %1 testen - + Edit tile Kachel bearbeiten - - Phones see only each tile's label and icon. Paths and commands stay on this PC. + + Phones see only each tile's label and icon. Paths and commands stay on this PC. Geräte sehen nur die Beschriftung und das Symbol jeder Kachel. Pfade und Befehle bleiben auf diesem PC. - + Action Aktion - + Ctrl Strg - + Alt Alt - + Shift Umschalt - + Win Win - + + Super + Super + + + Key (e.g. c, v, z, f5, tab, enter, space, escape, up, down): Taste (z. B. c, v, z, f5, tab, enter, space, escape, up, down): - + Website address (https://…): Website-Adresse (https://…): - + Text snippet to type: Einzutippender Textbaustein: - + Application or launcher path (/usr/bin/…, .desktop, or .AppImage): Anwendungs- oder Starterpfad (/usr/bin/…, .desktop oder .AppImage): - + Application or shortcut path (.exe or .lnk): Anwendungs- oder Verknüpfungspfad (.exe oder .lnk): - + Shell command to run on this PC: Auf diesem PC auszuführender Shell-Befehl: - + Action parameter Aktionsparameter - + Browse… Durchsuchen… - - Requires "Allow running Deck commands" to be turned on for the phone in Settings → Devices. + + Requires "Allow running Deck commands" to be turned on for the phone in Settings → Devices. Erfordert, dass unter Einstellungen → Geräte für das Gerät „Deck-Befehle ausführen“ aktiviert ist. - + Label Beschriftung - + Tile label Kachelbeschriftung - + Icon Symbol - + Delete Löschen - + Test Testen - - - + + + Cancel Abbrechen - - + + Save Speichern - + Page name Seitenname - + Create Erstellen - + Reset Deck to defaults? Deck auf Standard zurücksetzen? - + This replaces your custom pages and tiles with the default 11-tile Deck. Dadurch werden deine benutzerdefinierten Seiten und Kacheln durch das Standard-Deck mit 11 Kacheln ersetzt. - + Reset Zurücksetzen @@ -1840,212 +1865,332 @@ Doctor - + %1 Open NectarLink on the phone and keep both on the same network. If the phone asks to run in the background, allow it. %1 Öffne NectarLink auf dem Gerät und halte beide Geräte im selben Netzwerk. Wenn das Gerät nach der Ausführung im Hintergrund fragt, erlaube sie. - + %1 days ago vor %1 Tagen - + %1 hours ago vor %1 Stunden - + %1 is connected %1 ist verbunden - + %1 is on this Wi-Fi, but the router blocks direct connections (AP isolation) %1 ist in diesem WLAN, aber der Router blockiert direkte Verbindungen (AP-Isolation) - + %1 is set as a public network %1 ist als öffentliches Netzwerk eingestellt - - %1 isn't connected + + %1 isn't connected %1 ist nicht verbunden - + %1 minutes ago vor %1 Minuten - + %1 · %2 ms %1 · %2 ms - + A VPN is on Ein VPN ist aktiv - + + A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect. + Eine Firewall-Regel blockiert eingehende NectarLink-Verbindungen. Erlaube sie, damit sich dein Gerät verbinden kann. + + + + Calls and alerts can't play sound yet + Anrufe und Hinweise können noch keinen Ton abspielen + + + Connect it to the same Wi-Fi or wired network as your phone. Verbinde ihn mit demselben WLAN oder LAN wie dein Gerät. - + Connected to %1 Verbunden mit %1 - + Connected to a network Mit einem Netzwerk verbunden - + Connecting to %1… %1 wird verbunden… - - It hasn't connected yet. + + GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions. + GNOME zeigt dort App-Symbole nur mit der Erweiterung AppIndicator an. Installiere sie (gnome-shell-extension-appindicator) und schalte sie unter „Erweiterungen“ ein. + + + + Install fuse3 to browse your phone's files in your file manager. + Installiere fuse3, um die Dateien deines Geräts im Dateimanager zu durchsuchen. + + + + Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing. + Installiere gstreamer1.0-pipewire und gstreamer1.0-tools und, falls es fehlt, das Desktop-Portal (xdg-desktop-portal). + + + + Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC. + Installiere pipewire-pulse (oder pulseaudio-utils) für den Ton bei Anrufen und beim Suchen deines PCs. + + + + Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera. + Installiere v4l2loopback-dkms und starte den PC neu, damit Apps dein Gerät als Kamera verwenden können. + + + + It hasn't connected yet. Es war noch nicht verbunden. - + Keep %1 reachable in the background (%2) %1 im Hintergrund erreichbar halten (%2) - + Last seen %1. Zuletzt gesehen %1. - - NectarLink isn't allowed through Windows Firewall + + NectarLink can't show its icon in the top bar + NectarLink kann sein Symbol nicht in der oberen Leiste anzeigen + + + + NectarLink is in the top bar + NectarLink ist in der oberen Leiste + + + + NectarLink isn't allowed through Windows Firewall NectarLink ist in der Windows-Firewall nicht zugelassen - + + NectarLink isn't allowed through the firewall + NectarLink ist in der Firewall nicht zugelassen + + + On Huawei / Honor: open Settings → Battery → App launch, turn off Manage automatically for NectarLink, and keep Auto-launch, Secondary launch, and Run in background on. Auf Huawei / Honor: Öffne Einstellungen → Akku → App-Start, deaktiviere „Automatisch verwalten“ für NectarLink und lasse „Auto-Start“, „Sekundärer Start“ und „Im Hintergrund ausführen“ aktiviert. - + On OnePlus / OxygenOS: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. Auf OnePlus / OxygenOS: Öffne Einstellungen → Apps → App-Verwaltung → NectarLink → Akkunutzung und aktiviere „Hintergrundaktivität zulassen“ sowie „Automatischen Start zulassen“. - + On Oppo / Realme: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. Auf Oppo / Realme: Öffne Einstellungen → Apps → App-Verwaltung → NectarLink → Akkunutzung und aktiviere „Hintergrundaktivität zulassen“ sowie „Automatischen Start zulassen“. - + On Pixel: open Settings → Apps → See all apps → NectarLink → App battery usage, tap Allow background usage, and choose Unrestricted. Auf Pixel: Öffne Einstellungen → Apps → Alle Apps ansehen → NectarLink → Akkunutzung der App, tippe auf „Hintergrundnutzung zulassen“ und wähle „Uneingeschränkt“. - + On Samsung: open Settings → Apps → NectarLink → Battery and choose Unrestricted, then in Device care → Battery → Background usage limits make sure NectarLink is never put to sleep. Auf Samsung: Öffne Einstellungen → Apps → NectarLink → Akku und wähle „Nicht eingeschränkt“. Stelle dann unter Gerätewartung → Akku → Grenzen der Hintergrundnutzung sicher, dass NectarLink nie in den Standby versetzt wird. - + On USB Über USB - + On Vivo / OriginOS / Funtouch: open Settings → Battery → Background power consumption management → NectarLink and choose Unrestricted, and allow Autostart. Auf Vivo / OriginOS / Funtouch: Öffne Einstellungen → Akku → Hintergrund-Stromverbrauchsverwaltung → NectarLink, wähle „Uneingeschränkt“ und erlaube „Autostart“. - + On Xiaomi / HyperOS / MIUI: open Settings → Apps → Manage apps → NectarLink, turn on Autostart, and set Battery saver to No restrictions. Auf Xiaomi / HyperOS / MIUI: Öffne Einstellungen → Apps → Apps verwalten → NectarLink, aktiviere „Autostart“ und stelle das Energiesparen auf „Keine Beschränkungen“. - - On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private. + + On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private. In öffentlichen Netzwerken verhindert Windows, dass andere Geräte diesen PC finden. Wenn es dein Heim- oder Arbeitsnetzwerk ist, stelle es auf „Privat“. - + + On public networks the firewall keeps other devices from finding this PC. If it's your home or work network, open network settings and set its firewall zone to Home or Trusted. + In öffentlichen Netzwerken verhindert die Firewall, dass andere Geräte diesen PC finden. Wenn es dein Heim- oder Arbeitsnetzwerk ist, öffne die Netzwerkeinstellungen und setze seine Firewall-Zone auf „Home“ oder „Trusted“. + + + On this network In diesem Netzwerk - + + Phone as webcam is ready + „Gerät als Webcam“ ist bereit + + + + Phone as webcam needs one more step + Für „Gerät als Webcam“ fehlt noch ein Schritt + + + + Phone storage can open in your file manager + Der Gerätespeicher kann im Dateimanager geöffnet werden + + + + Phone storage needs one more step + Für den Gerätespeicher fehlt noch ein Schritt + + + + Showing this PC's screen needs one more step + Für die Anzeige des PC-Bildschirms fehlt noch ein Schritt + + + Someone chose not to allow it when Windows asked. Allow it, and your phone can connect. Bei der Nachfrage von Windows wurde der Zugriff nicht zugelassen. Erlaube ihn, damit sich dein Gerät verbinden kann. - - This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable. + + Sound is ready + Ton ist bereit + + + + The firewall blocks NectarLink + Die Firewall blockiert NectarLink + + + + The firewall is off for this network + Die Firewall ist für dieses Netzwerk deaktiviert + + + + The firewall lets your phone connect + Die Firewall lässt Verbindungen deines Geräts zu + + + + The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput. + Touchpad und Tastatur auf deinem Gerät brauchen das Desktop-Portal (xdg-desktop-portal) oder Zugriff auf /dev/uinput. + + + + This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable. Dieser PC erkennt %1 im Netzwerk, aber der Router blockiert die direkte Kommunikation zwischen Geräten (oft „AP-Isolation“, „Client-Isolation“ oder „Gast-WLAN“ genannt). Verbinde beide Geräte mit dem normalen WLAN statt eines Gastnetzwerks, deaktiviere die AP-/Client-Isolation in den WLAN-Einstellungen deines Routers oder nutze ein USB-Kabel. - - This PC isn't on a network + + This PC isn't on a network Dieser PC ist mit keinem Netzwerk verbunden - + This network Dieses Netzwerk - + This usually takes a few seconds. Das dauert normalerweise nur ein paar Sekunden. - - VPNs often keep devices on your network from reaching this PC. If your phone can't connect, try turning it off. + + VPNs often keep devices on your network from reaching this PC. If your phone can't connect, try turning it off. VPNs verhindern oft, dass Geräte im lokalen Netzwerk diesen PC erreichen. Wenn sich dein Gerät nicht verbinden kann, schalte das VPN testweise aus. - + While away Unterwegs - + Windows Firewall blocks NectarLink Die Windows-Firewall blockiert NectarLink - + Windows Firewall is off for this network Die Windows-Firewall ist für dieses Netzwerk deaktiviert - + Windows Firewall lets your phone connect Die Windows-Firewall lässt Verbindungen deines Geräts zu - + + Your phone can control this PC + Dein Gerät kann diesen PC steuern + + + + Your phone can show this PC's screen + Dein Gerät kann den Bildschirm dieses PCs anzeigen + + + + Your phone can't control this PC yet + Dein Gerät kann diesen PC noch nicht steuern + + + Your phone may not be able to connect. Allow it once here. Dein Gerät kann sich möglicherweise nicht verbinden. Erlaube den Zugriff hier einmalig. - + a minute ago vor einer Minute - + an hour ago vor einer Stunde - + just now gerade eben @@ -2053,52 +2198,57 @@ DoctorSheet - + Allow Zulassen - + Open settings Einstellungen öffnen - + Reconnect Neu verbinden - + Connection Doctor Verbindungs-Doktor - + Checking network and firewall… Netzwerk und Firewall werden geprüft… - + Everything looks right. Alles sieht gut aus. - + + Your phone can reach this PC. A few features need a step from you: + Dein Gerät erreicht diesen PC. Für einige Funktionen ist noch ein Schritt von dir nötig: + + + Issues blocking your phone from reaching this PC: Probleme, die verhindern, dass dein Gerät diesen PC erreicht: - + Firewall settings Firewall-Einstellungen - + Check again Erneut prüfen - + Done Fertig @@ -2106,233 +2256,233 @@ Errors - + Something went wrong: %1 Etwas ist schiefgelaufen: %1 - - That device isn't paired anymore. + + That device isn't paired anymore. Dieses Gerät ist nicht mehr gekoppelt. - + The device declined. Das Gerät hat abgelehnt. - - The device didn't answer in time. + + The device didn't answer in time. Das Gerät hat nicht rechtzeitig geantwortet. - - The device isn't connected right now. + + The device isn't connected right now. Das Gerät ist momentan nicht verbunden. - - The device's app doesn't support that yet. + + The device's app doesn't support that yet. Die App auf dem Gerät unterstützt das noch nicht. FeaturesGuidePage - + Overview Übersicht - + Setup Einrichtung - + Permissions Berechtigungen - + Power levels Funktionsmodi - + Troubleshooting Fehlerbehebung - + FAQ FAQ - + All Alle - + Notifications & Calls Benachrichtigungen & Anrufe - + Messages Nachrichten - + Clipboard & Files Zwischenablage & Dateien - + Mirroring & Apps Spiegelung & Apps - + Camera & Audio Kamera & Audio - + Input & Control Eingabe & Steuerung - + Desktop Surfaces Desktop-Oberflächen - + Automation & Network Automatisierung & Netzwerk - - - - + + + + Ready on PC Bereit auf dem PC - - - + + + Off in Settings In Einstellungen deaktiviert - + Opt-in on Home Auf der Startseite aktivierbar - - + + Off on Home Auf der Startseite deaktiviert - - + + Ready Bereit - + Basic mode Basismodus - - + + Needs phone permission Geräteberechtigung erforderlich - + Needs Elevated Benötigt Erweitert - + Needs Assist Benötigt Assistenz - + Every feature in NectarLink, what it needs, and where to find it. Jede Funktion in NectarLink, was sie benötigt und wo du sie findest. - + Read the full guide › Vollständiges Handbuch lesen › - + Setup walkthrough Einrichtungsassistent - + Connect your phone to see which features are ready Verbinde dein Gerät, um zu sehen, welche Funktionen bereit sind - + Search features by name, location, or permission... Funktionen nach Name, Ort oder Berechtigung durchsuchen… - + Clear search Suche löschen - + Show me Zeig es mir - + Learn more › Mehr erfahren › - + Where: Wo: - + Needs: Benötigt: - + No matching features Keine passenden Funktionen - + Try another search term or select All above. Versuche einen anderen Suchbegriff oder wähle oben „Alle“. - + Close guide Handbuch schließen - + Close Schließen @@ -2340,12 +2490,12 @@ FolderSync - + Folders are up to date. Ordner sind auf dem neuesten Stand. - + Synced %1 file(s). %1 Datei(en) synchronisiert. @@ -2353,85 +2503,85 @@ Guide - - 1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer). + + 1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer). 2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable. 3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail. 4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens. 5. Choose which features to share for that phone and grant only the Android permissions you want to use. - 1. Installiere NectarLink auf deinem Windows-11-PC und deinem Android-Gerät (Android 8.0 oder neuer). + 1. Installiere NectarLink auf deinem PC (Windows 11 oder Linux) und deinem Android-Gerät (Android 8.0 oder neuer). 2. Verbinde beide Geräte mit demselben WLAN oder schließe dein Gerät per USB-Kabel an deinen PC an. 3. Öffne den Kopplungsschritt im Einrichtungsassistenten oder klicke unten in der linken Seitenleiste auf „+“ (Neues Gerät koppeln). 4. Scanne den QR-Code in der NectarLink-Android-App oder wähle dein Gerät aus der Liste der Geräte in der Nähe aus und bestätige, dass der 6-stellige Code auf beiden Bildschirmen übereinstimmt. 5. Wähle aus, welche Funktionen für dieses Gerät freigegeben werden sollen, und erteile nur die Android-Berechtigungen, die du nutzen möchtest. - + Android permissions and why each is optional Android-Berechtigungen und warum jede optional ist - + Automations Automatisierungen - + Calls Anrufe - + Clipboard Zwischenablage - + Command palette Befehlspalette - + Connection Doctor Verbindungs-Doktor - + Continuity camera Übergabekamera - + Deck Deck - + Drop shelf Shelf - + Elevated and Assist modes Erweiterter und Assistenz-Modus - + Features guide Funktionsübersicht - + Features reference Funktionsreferenz - + Handoff Übergabe (Handoff) - + If your phone and PC do not see each other: 1. Open Connection Doctor from Settings › Connection & About or the Command Palette. @@ -2446,17 +2596,17 @@ 4. Du kannst die Verbindung auch über ein USB-Kabel bei aktiviertem USB-Debugging herstellen; NectarLink richtet dann automatisch einen lokalen Tunnel ein. - + Install and first-time setup Installation und Ersteinrichtung - + Local HTTP API Lokale HTTP-API - + Local by default Your phone and PC talk directly over your local network or USB cable. When Connect over mobile data is on, encrypted traffic can use a relay when away from home. Automatic update checks contact GitHub Releases and can be turned off in Settings › Connection & About › Check for updates. @@ -2475,17 +2625,17 @@ Freigabeeinstellungen pro Gerät Sensible Funktionen wie „PC-Ordner freigeben“, „Ferneingabe“ und die umgekehrte PC-Bildschirmspiegelung sind für jedes gekoppelte Gerät standardmäßig deaktiviert, bis du sie einschaltest. - + LocalSend LocalSend - + Messages Nachrichten - + Most NectarLink features work in Standard mode with regular Android permissions. Two optional modes unlock deeper system integration: Elevated mode (Wireless debugging or root) @@ -2508,12 +2658,12 @@ Assistenz-Modus (Android-Bedienungshilfen) - NectarLink liest über die Bedienungshilfen niemals Bildschirmtexte aus und zeichnet keine Tastenanschläge auf. - + Nectar Island Nectar Island - + NectarLink links your Android phone and your PC directly on your local network or over a USB cable. How it works @@ -2538,7 +2688,7 @@ Funktionsstufen unter Android - Assistenz-Modus (optional): Nutzt die Android-Bedienungshilfen nur dann, wenn du die Ferneingabe aktivierst, damit Maus und Tastatur deines PCs das Gerät steuern können. - + NectarLink never asks for every permission up front. Each feature works independently: - Notification Access: Required only if you want phone notifications, inline replies, and notification-backed chat threads on your PC. @@ -2555,97 +2705,97 @@ Funktionsstufen unter Android - Kamera und Mikrofon: Nur erforderlich, während du dein Gerät als Webcam nutzt, ein Foto per Continuity-Kamera aufnimmst oder eine Sprachnotiz aufzeichnest. - + NectarLink overview NectarLink-Übersicht - + Notifications Benachrichtigungen - + Phone as speaker Gerät als Lautsprecher - + Phone as webcam Gerät als Webcam - + Phone controls Gerätesteuerung - + Photos Fotos - + Privacy and security Datenschutz und Sicherheit - + Remote input Ferneingabe - + Screen mirroring Bildschirmspiegelung - + Select any feature card in the Features guide to inspect its live status, jump directly to its control with Show me, or read how it works and what permissions it uses. Wähle eine beliebige Funktionskarte in der Funktionsübersicht aus, um ihren Live-Status zu prüfen, mit „Zeig es mir“ direkt zur Steuerung zu springen oder nachzulesen, wie sie funktioniert und welche Berechtigungen sie nutzt. - + Send files Dateien senden - + Share PC folders PC-Ordner freigeben - + Share PC screen PC-Bildschirm teilen - + Timeline Zeitverlauf - + Troubleshooting and Connection Doctor Fehlerbehebung und Verbindungs-Doktor - + Type on phone Auf dem Gerät tippen - + Voice recorder Sprachrekorder - + Wake PC PC aufwecken - + What it does Browses recent photos, camera shots, and screenshots on your phone without copying your entire library first. You can view full-resolution images, copy them to the clipboard, save them to disk, or drag them directly into other Windows apps. @@ -2666,9 +2816,9 @@ Voraussetzungen - Erteilte Foto- und Medienberechtigung auf deinem Android-Gerät. - + What it does -Controls your phone's Do Not Disturb mode, ringer mode (Sound, Vibrate, Silent), flashlight, media volume, screen brightness, Wi-Fi, Bluetooth, and Find My Phone ring from your PC. +Controls your phone's Do Not Disturb mode, ringer mode (Sound, Vibrate, Silent), flashlight, media volume, screen brightness, Wi-Fi, Bluetooth, and Find My Phone ring from your PC. Where to find it - Home › Phone controls and Find phone. @@ -2687,7 +2837,7 @@ Voraussetzungen - Lautstärke, Taschenlampe und „Mein Gerät finden“ funktionieren im Standardmodus; „Nicht stören“ erfordert Zugriff auf die „Nicht stören“-Einstellungen; WLAN- und Bluetooth-Schalter nutzen unter neueren Android-Versionen den erweiterten Modus. - + What it does Discovers and exchanges files with nearby devices running LocalSend v2 on your local network without requiring pairing. @@ -2708,7 +2858,7 @@ Voraussetzungen - „LocalSend im lokalen Netzwerk“ unter Einstellungen › Dateien & Sync aktiviert sowie beide Geräte im selben lokalen Netzwerk. - + What it does Displays a compact pill at the top center of your primary monitor for incoming calls, live file transfer progress, media playback, and optional notification previews. @@ -2727,7 +2877,7 @@ Voraussetzungen - Aktiviere Nectar Island unter Einstellungen › Allgemein und wähle aus, welche Ereignisse angezeigt werden sollen. - + What it does Exposes an opt-in localhost HTTP/JSON and Server-Sent Events API on 127.0.0.1 (default port 53320) protected by a bearer token so scripts, CLI tools, and Stream Deck plugins can trigger NectarLink actions. @@ -2746,7 +2896,7 @@ Voraussetzungen - Aktiviere „Lokale Automatisierungs-API“ unter Einstellungen › Verbindung & Info und lies das Bearer-Token aus api-token.json aus. - + What it does Forwards keystrokes and pasted text from your PC keyboard directly to your phone without needing to mirror the phone screen. @@ -2767,7 +2917,7 @@ Voraussetzungen - Assistenz-Modus (Bedienungshilfen), die NectarLink-Eingabemethode oder den erweiterten Modus auf deinem Gerät. - + What it does Inspects Windows Firewall rules, network profile type (Private vs Public), UDP discovery sockets, and paired phone reachability, and offers one-click fixes. @@ -2788,7 +2938,7 @@ Voraussetzungen - Jederzeit auf deinem PC verfügbar. - + What it does Keeps selected folders on your phone (such as DCIM/Camera or Download) and folders on your PC in sync in one-way or two-way mode, and lets your phone browse shared PC folders. @@ -2809,7 +2959,7 @@ Voraussetzungen - Datei- und Medienzugriff auf deinem Gerät sowie mindestens eine eingerichtete Ordnersynchronisierungsregel in den Einstellungen. - + What it does Lets you read and send SMS and MMS conversations, plus reply to notification-backed chat threads from messaging apps such as WhatsApp, Signal, and Telegram. @@ -2832,7 +2982,7 @@ Voraussetzungen - Benachrichtigungszugriff auf deinem Gerät für Unterhaltungen aus Messenger-Apps. - + What it does Lets your paired phone wake this PC from sleep or soft power-off over your local network using a Wake-on-LAN packet. @@ -2853,7 +3003,7 @@ Voraussetzungen - Ein Netzwerkadapter mit aktiviertem Wake-on-LAN im Windows-Geräte-Manager sowie im BIOS/UEFI (kabelgebundenes Ethernet ist am zuverlässigsten). - + What it does Lets your phone act as a wireless trackpad, keyboard, presentation clicker, and on-screen laser pointer for your PC. @@ -2874,7 +3024,7 @@ Voraussetzungen - Der gerätespezifische Schalter „Fernsteuerung per Maus, Tastatur & Deck“ auf deinem PC (oder eine einmalige Bestätigung, wenn dein Gerät die Steuerung anfordert). - + What it does Mirrors your phone notifications to the Home feed and Windows notification pop-ups. You can reply inline to messages, click action buttons, dismiss individual notifications or groups, and undo a recent dismissal. @@ -2899,7 +3049,7 @@ Voraussetzungen - Aktivierter Schalter „Benachrichtigungen“ für dieses Gerät auf deinem PC. - + What it does Opens a keyboard-first search bar over actions, contacts, message threads, mirrorable apps, phone quick toggles, clipboard history, and navigation pages. @@ -2920,9 +3070,9 @@ Voraussetzungen - Jederzeit einsatzbereit, ohne zusätzlichen Speicherbedarf im Hintergrund, wenn sie geschlossen ist. - + What it does -Opens a lightweight slide-out panel at the edge of your monitor showing your phone's latest photo, latest screenshot, recent clipboard items, and recent received files. Drag items out into any app, or drop files onto the Shelf to send them to your phone. +Opens a lightweight slide-out panel at the edge of your monitor showing your phone's latest photo, latest screenshot, recent clipboard items, and recent received files. Drag items out into any app, or drop files onto the Shelf to send them to your phone. Where to find it - Global hotkey Ctrl+Alt+S. @@ -2943,7 +3093,7 @@ Voraussetzungen - Gekoppeltes Gerät; Foto-Berechtigung auf deinem Gerät für die Kacheln „Neuestes Foto“ und „Neuester Screenshot“. - + What it does Provides a unified, searchable chronological feed of files, photos, clipboard items, calls, messages, and voice recordings shared between your phone and PC. @@ -2964,7 +3114,7 @@ Voraussetzungen - Funktioniert automatisch für alle gekoppelten Geräte; die Aufbewahrungsdauer kann unter Einstellungen › Speicher & Datenschutz angepasst werden. - + What it does Receives voice notes recorded on your phone and saves them to your PC in your chosen audio format (M4A, MP3, WAV, or FLAC). @@ -2985,7 +3135,7 @@ Voraussetzungen - Mikrofonberechtigung auf deinem Gerät beim Aufnehmen einer Sprachnotiz. - + What it does Runs automatic trigger-action rules such as locking your PC when your phone disconnects, pausing media when a call arrives, or running a command when your phone connects. @@ -3004,7 +3154,7 @@ Voraussetzungen - Füge dort eine oder mehrere Regeln hinzu und aktiviere sie. - + What it does Sends files, folders, links, and text between your phone and PC at local network speed. If your phone is offline when you send, NectarLink places the item in the Waiting to send queue and delivers it automatically as soon as your phone reconnects. @@ -3025,7 +3175,7 @@ Voraussetzungen - Gekoppeltes Gerät und aktivierter Schalter „Dateien“ für dieses Gerät. - + What it does Sends web links, video links with their current playback timestamp, map coordinates or street addresses, and documents to your phone so they open immediately in the right app. @@ -3044,7 +3194,7 @@ Voraussetzungen - Gekoppeltes Gerät; funktioniert online sofort oder reiht die Übergabe automatisch in die Warteschlange ein, wenn dein Gerät offline ist. - + What it does Shares copied text, rich HTML/RTF text, images, and small copied file sets between your PC and phone. Keeps an encrypted local history of up to 50 items with pinning and smart context chips for links, phone numbers, addresses, and tracking codes. @@ -3067,7 +3217,7 @@ Voraussetzungen - Die Hintergrundsynchronisierung vom Gerät zum PC nutzt ab Android 10 Benachrichtigungen, die NectarLink-Schnelleinstellungskachel oder den erweiterten Modus. - + What it does Shows incoming and active phone calls on your PC with Answer, Decline, and Reject with SMS actions, plus browsable call history, contacts, and a dialpad. @@ -3090,7 +3240,7 @@ Voraussetzungen - Das Gesprächsaudio bleibt auf deinem Gerät (oder einem gekoppelten Headset); der erweiterte Modus (Elevated) schaltet ab Android 12 Stummschalten, Freisprechen, Halten und das Tastenfeld frei. - + What it does Streams your PC system audio to your connected phone in real time over the encrypted connection so you can listen through your phone speakers or headphones. @@ -3111,9 +3261,9 @@ Voraussetzungen - Gekoppeltes Gerät, das über lokales WLAN oder USB verbunden ist. - + What it does -Streams your PC's screen to your phone so you can view your desktop from across the room and use your phone as a trackpad or laser pointer. +Streams your PC's screen to your phone so you can view your desktop from across the room and use your phone as a trackpad or laser pointer. Where to find it - Started from the NectarLink app on your phone. @@ -3132,9 +3282,9 @@ Voraussetzungen - Der gerätespezifische Schalter „PC-Bildschirm für Gerät freigeben“ auf deinem PC oder eine einmalige Bestätigung, wenn dein Gerät die Bildschirmansicht anfordert. - + What it does -Streams your phone camera into the NectarLink Virtual Camera on Windows so Zoom, Teams, Meet, Discord, and OBS can use your phone's rear or front camera at up to 4K. +Streams your phone camera into the NectarLink Virtual Camera on Windows so Zoom, Teams, Meet, Discord, and OBS can use your phone's rear or front camera at up to 4K. Where to find it - Home › Phone as webcam. @@ -3155,7 +3305,7 @@ Voraussetzungen - Damit andere Apps sie erkennen: „Virtuelle Kamera (Add-on)“ unter Einstellungen › Kamera & Audio. - + What it does Streams your phone screen into a low-latency desktop window with hardware-accelerated decoding, or opens individual Android apps in their own windows on your PC. @@ -3164,7 +3314,7 @@ Where to find it - Home › Mirror screen quick action. What it needs -- Standard mode mirrors the full phone screen after you tap Start now on Android's screen capture prompt. +- Standard mode mirrors the full phone screen after you tap Start now on Android's screen capture prompt. - Elevated mode (Wireless debugging or root) skips the prompt and unlocks individual app windows. - Assist mode or Elevated mode lets you click, scroll, and type inside the mirrored window. Funktion @@ -3180,7 +3330,7 @@ Voraussetzungen - Mit dem Assistenz-Modus oder dem erweiterten Modus kannst du im gespiegelten Fenster klicken, scrollen und tippen. - + What it does Syncs media playback between your phone and PC, switches Windows audio output and microphone devices, and turns your phone into a customizable grid of shortcut tiles, macros, and volume sliders. @@ -3199,7 +3349,7 @@ Voraussetzungen - Gekoppeltes Gerät über eine beliebige aktive Verbindung. - + What it does Triggers your phone camera from any Windows app to snap a photo or scan a document, then copies the result to the Windows clipboard and pastes it into the active window. @@ -3223,12 +3373,12 @@ Voraussetzungen HistoryItem - + Open %1 in an app window %1 in einem App-Fenster öffnen - + Yesterday %1 Gestern %1 @@ -3236,47 +3386,47 @@ Voraussetzungen HomePage - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Send files via LocalSend Dateien über LocalSend senden - + never nie - + just now gerade eben - + %n min ago vor %n Min. @@ -3284,7 +3434,7 @@ Voraussetzungen - + %n h ago vor %n Std. @@ -3292,812 +3442,822 @@ Voraussetzungen - + ~%1 min to full ~%1 Min. bis voll - + ~%1h %2m to full ~%1 Std. %2 Min. bis voll - + ~%1h to full ~%1 Std. bis voll - + New: Features guide Neu: Funktionsübersicht - + See what each feature needs and where to find it. Erfahre, was jede Funktion benötigt und wo du sie findest. - - - + + + Dismiss Schließen - + Open features guide Funktionsübersicht öffnen - + Try Hive, the new theme Probiere „Wabe“, das neue Design - + Honey on charcoal or warm wax, with a living honeycomb behind the app. Change it any time in Settings › Appearance. Honig auf Anthrazit oder warmem Wachs, mit einer lebendigen Bienenwabe hinter der App. Jederzeit unter Einstellungen › Darstellung änderbar. - + Not now Jetzt nicht - + Try Hive Wabe ausprobieren - + No phone paired yet Noch kein Gerät gekoppelt - + Pair your Android phone over Wi-Fi or USB. Kopple dein Android-Gerät über WLAN oder USB. - + Features guide Funktionsübersicht - + Setup walkthrough Einrichtungsassistent - + Pair a phone Gerät koppeln - - + + Transfers Übertragungen - - + + Clear finished Abgeschlossene löschen - + Drop to send to %1 Hier ablegen, um an %1 zu senden - + Hold Shift while dropping a document to open it on arrival Halte beim Ablegen eines Dokuments die Umschalttaste gedrückt, um es nach dem Empfang zu öffnen - - + + Charging Lädt - + Low Niedrig - + Android %1 Android %1 - + Away · %1 ms Abwesend · %1 ms - + Away Abwesend - + USB · %1 ms USB · %1 ms - + USB USB - - + + Wi‑Fi · %1 ms WLAN · %1 ms - - - - + + + + Wi‑Fi WLAN - + Cellular Mobilfunk - + · %1 ms · %1 ms - + Offline · %1 Offline · %1 - + Offline Offline - + Troubleshoot Problembehandlung - + Media player Mediaplayer - + Nothing playing Keine Wiedergabe - + Mirror screen Bildschirm spiegeln - + Open an app App öffnen - - + + Send files Dateien senden - + Send a folder Ordner senden - + Send clipboard Zwischenablage senden - - + + Clipboard history Zwischenablageverlauf - + Stop ringing Klingeln beenden - + Find phone Gerät klingeln lassen - + Send to %1 An %1 senden - + Hand off & open on %1 Übergeben & auf %1 öffnen - + Send a folder to %1 Ordner an %1 senden - + Waiting to send · %1 Wartet auf Senden · %1 - + Waiting to send Wartet auf Senden - + Clear Leeren - + Cancel all Alle abbrechen - + Sending to %1 · %2% Wird an %1 gesendet · %2% - + Sending to %1… Wird an %1 gesendet… - + Failed · %1 Fehlgeschlagen · %1 - + Failed Fehlgeschlagen - + Expired Abgelaufen - + Waiting for %1 · %2 Wartet auf %1 · %2 - + Waiting for %1 Wartet auf %1 - + Retry Erneut versuchen - - - + + + Cancel Abbrechen - - + + Timeline Zeitverlauf - + Notifications · %1 Benachrichtigungen · %1 - + Notifications Benachrichtigungen - + History Verlauf - + Notification history (last 24 hours) Benachrichtigungsverlauf (letzte 24 Stunden) - + Clear all Alle löschen - + Undo Rückgängig - + No notifications Keine Benachrichtigungen - + Notifications unavailable Benachrichtigungen nicht verfügbar - + Connect %1 to view notifications Verbinde %1, um Benachrichtigungen anzuzeigen - + + Desktop notifications are off. Turn them on in your system's notification settings. + Desktop-Benachrichtigungen sind deaktiviert. Schalte sie in den Benachrichtigungseinstellungen deines Systems ein. + + + Windows notifications are off. Windows-Benachrichtigungen sind deaktiviert. - + Turn on Aktivieren - + Webcam Webcam - + Streaming from %1 Streamt von %1 - - + + Stop Stoppen - + Back Hinten - + Front Vorne - + Mirror Spiegeln - + Phone controls Gerätesteuerung - + Less Weniger - + Sound & display Ton & Anzeige - + DND Nicht stören - + Do Not Disturb Nicht stören - + Torch Licht - + Flashlight Taschenlampe - + BT BT - + Bluetooth Bluetooth - + Hotspot Hotspot - + Instant Hotspot Instant Hotspot - + Phone as speaker Gerät als Lautsprecher - + Streaming PC audio PC-Audio wird gestreamt - + Play on phone Auf dem Gerät abspielen - + Watching on %1 Wiedergabe auf %1 - + Tap the notification on %1 Tippe auf die Benachrichtigung auf %1 - + Play a video on this PC first Spiele zuerst ein Video auf diesem PC ab - + Turning off Wi‑Fi may disconnect %1. Das Ausschalten von WLAN kann die Verbindung zu %1 trennen. - + Turn off Ausschalten - + Ring Klingeln - + Vibrate Vibrieren - + Silent Lautlos - + Volume Lautstärke - + Brightness Helligkeit - + Continuity Camera Übergabekamera - + Scan document Dokument scannen - + Phone as webcam Gerät als Webcam - + Streaming Streamt - + Type on phone Auf dem Gerät tippen - + Sent: %1 Gesendet: %1 - + Listening for keys Tastatureingabe aktiv - - + + When connected Wenn verbunden - + Start Starten - - + + Clipboard Zwischenablage - + Type here: keys go to %1 Hier tippen: Eingaben gehen an %1 - + Click to focus keyboard Klicken, um Tastaturfokus zu setzen - + Ctrl+V pastes · Esc stops Strg+V fügt ein · Esc beendet - + Paste Einfügen - + View all Alle anzeigen - + No recent activity Keine kürzlichen Aktivitäten - + %1 notifications %1-Benachrichtigungen - + Show Anzeigen - + No pop-ups Keine Pop-ups - + Hide Ausblenden - + Hidden on this PC. Auf diesem PC ausgeblendet. - + Shown in feed without pop-ups. Wird im Feed ohne Pop-ups angezeigt. - + + Shown in feed and as desktop pop-ups. + Wird im Feed und als Desktop-Pop-up angezeigt. + + + Shown in feed and as Windows pop-ups. Wird im Feed und als Windows-Pop-up angezeigt. - - - - + + + + Done Fertig - + Notification history Benachrichtigungsverlauf - - + + Clear history Verlauf löschen - + Dismissed notifications from the last 24 hours. Verworfene Benachrichtigungen der letzten 24 Stunden. - + No recent notifications. Keine kürzlichen Benachrichtigungen. - - + + Search notification history Benachrichtigungsverlauf durchsuchen - - + + Nothing matches “%1”. Keine Treffer für „%1“. - + Last 50 shared clips. Die letzten 50 geteilten Einträge der Zwischenablage. - + No shared clips yet. Noch keine geteilten Einträge in der Zwischenablage. - - + + Search clipboard history Zwischenablageverlauf durchsuchen - + From %1 Von %1 - + From phone Vom Gerät - + Sent to %1 An %1 gesendet - + From this PC Von diesem PC - + Image Bild - + Pinned Angeheftet - + Open on phone Auf dem Gerät öffnen - + Send to phone An Gerät senden - + Copy Kopieren - + Unpin Lösen - + Pin Anheften - + Delete Löschen - - + + %1% %1% - + LocalSend nearby · %1 LocalSend in der Nähe · %1 - + LocalSend nearby LocalSend in der Nähe - + Refresh Aktualisieren - + Listening for LocalSend devices on your Wi‑Fi network. Sucht nach LocalSend-Geräten in deinem WLAN. - + LocalSend port 53317 is busy on this PC (another LocalSend app may be running). LocalSend-Port 53317 ist auf diesem PC belegt (möglicherweise läuft eine andere LocalSend-App). - + Nearby device Gerät in der Nähe - + Image preview Bildvorschau - + Close Schließen - + Open externally Extern öffnen @@ -4105,112 +4265,112 @@ Voraussetzungen Hotspot - - %1's hotspot didn't show up. Check that it's on, then pick %2 in Windows' Wi-Fi list. + + %1's hotspot didn't show up. Check that it's on, then pick %2 in Windows' Wi-Fi list. Der Hotspot von %1 wurde nicht gefunden. Prüfe, ob er eingeschaltet ist, und wähle dann %2 in der WLAN-Liste von Windows aus. - - Connected to %1's hotspot. + + Connected to %1's hotspot. Mit dem Hotspot von %1 verbunden. - - Couldn't join %1's hotspot. Pick %2 in Windows' Wi-Fi list. + + Couldn't join %1's hotspot. Pick %2 in Windows' Wi-Fi list. Verbindung zum Hotspot von %1 fehlgeschlagen. Wähle %2 in der WLAN-Liste von Windows aus. IslandWindow - + Nectar Island Nectar Island - + Phone call Telefonanruf - + Transfer complete Übertragung abgeschlossen - + Transferring %1 file(s)… %1 Datei(en) werden übertragen… - + Now playing Aktuelle Wiedergabe - + %1 phone notification(s) %1 Gerätebenachrichtigung(en) - - - + + + %1 waiting %1 ausstehend - + Nectar Island · %1 Nectar Island · %1 - + Nectar Island · Ready Nectar Island · Bereit - + Incoming call Eingehender Anruf - + On hold Gehalten - + Call in progress Laufender Anruf - + This PC Dieser PC - + Phone Gerät - + Click to view in NectarLink Klicken, um in NectarLink anzuzeigen - + Will send when %1 is back Wird gesendet, sobald %1 wieder da ist - + Will send when connected Wird gesendet, sobald verbunden - + Live calls, transfers & media Aktive Anrufe, Übertragungen & Medien @@ -4218,62 +4378,62 @@ Voraussetzungen Links - + %1: tap the notification on your phone to continue watching. %1: Tippe auf die Benachrichtigung auf deinem Gerät, um weiterzuschauen. - + %1: tap the notification on your phone to open in Maps. %1: Tippe auf die Benachrichtigung auf deinem Gerät, um sie in Maps zu öffnen. - + Copy a link or address first Kopiere zuerst einen Link oder eine Adresse - + Copy a web link (https://…), geo: URI, or street address, then choose this again. Kopiere einen Weblink (https://…), eine geo:-URI oder eine Adresse und wähle diese Option erneut aus. - + Copy a web link, video URL, or street address, then choose this again to open it on your phone. Kopiere einen Weblink, eine Video-URL oder eine Adresse und wähle diese Option erneut aus, um sie auf deinem Gerät zu öffnen. - - Couldn't send it + + Couldn't send it Senden fehlgeschlagen - + No phone to open it on Kein Gerät zum Öffnen vorhanden - + Pair a phone first, or enable opening links on your phone. Kopple zuerst ein Gerät oder aktiviere das Öffnen von Links auf deinem Gerät. - + Sent to %1 An %1 gesendet - + Tap the notification on your phone to open it. Tippe auf die Benachrichtigung auf deinem Gerät, um sie zu öffnen. - - That's not a link or address + + That's not a link or address Das ist kein Link und keine Adresse - + Update NectarLink on %1. Aktualisiere NectarLink auf %1. @@ -4281,107 +4441,107 @@ Voraussetzungen LockChip - + Elevated Erweitert - + Assist Assistenz - + Notification access Benachrichtigungszugriff - + DND access Nicht-stören-Zugriff - + System settings Systemeinstellungen - + Permission needed Berechtigung erforderlich - + Add-on Add-on - + Connect over mobile data Über mobile Daten verbinden - + Off Aus - + Update app App aktualisieren - + Unavailable Nicht verfügbar - + On your phone, open NectarLink › Settings › Control from your PC and turn on the Accessibility service. Öffne auf deinem Gerät NectarLink › Einstellungen › Vom PC aus steuern und aktiviere die Bedienungshilfe. - + Needs Elevated: set up Wireless debugging or connect over USB. Erfordert den erweiterten Modus: Richte Kabelloses Debugging ein oder verbinde das Gerät über USB. - + On your phone, set NectarLink as the default SMS/Caller ID app when prompted. Lege NectarLink auf deinem Gerät als Standard-App für SMS/Anrufer-ID fest, wenn du dazu aufgefordert wirst. - + On your phone, open NectarLink and allow Notification access. Öffne NectarLink auf deinem Gerät und erlaube den Benachrichtigungszugriff. - + On your phone, open NectarLink and allow Do Not Disturb access. Öffne NectarLink auf deinem Gerät und erlaube den Zugriff auf „Nicht stören“. - + On your phone, open NectarLink and allow Modify system settings. Öffne NectarLink auf deinem Gerät und erlaube das Ändern von Systemeinstellungen. - + Open NectarLink on your phone and grant this permission in Settings. Öffne NectarLink auf deinem Gerät und erteile diese Berechtigung in den Einstellungen. - + Open Settings › Camera & Audio to set up the NectarLink virtual camera add-on. Öffne Einstellungen › Kamera & Audio, um das Add-on für die virtuelle NectarLink-Kamera einzurichten. - + Open Settings › Connection & About and turn on Connect over mobile data. Öffne Einstellungen › Verbindung & Info und aktiviere „Über mobile Daten verbinden“. - + This feature is turned off for this phone in Settings › Devices. Diese Funktion ist für dieses Gerät unter Einstellungen › Geräte deaktiviert. @@ -4389,177 +4549,177 @@ Voraussetzungen MainWindow - - + + Home Start - - + + Messages Nachrichten - - + + Calls Anrufe - - + + Photos Fotos - - + + Apps Apps - - + + Deck Deck - + Guide Anleitung - - + + Settings Einstellungen - + Pair a new device Neues Gerät koppeln - + Back to Home Zurück zu Start - - + + Timeline Zeitverlauf - + Features guide Funktionsübersicht - + Sharing screen with %1 Bildschirm wird mit %1 geteilt - + Sharing screen Bildschirm wird geteilt - + Stop Stoppen - - Stop sharing this PC's screen + + Stop sharing this PC's screen Bildschirmfreigabe dieses PCs beenden - + Commands Befehle - + Command palette (%1) Befehlspalette (%1) - + Command palette (Ctrl+K) Befehlspalette (Strg+K) - + Files, clips, links, photos, recordings and sessions Dateien, Zwischenablage, Links, Fotos, Aufnahmen und Sitzungen - + Refresh Aktualisieren - + %1 is ringing this PC %1 lässt diesen PC klingeln - + Stop ringing Klingeln beenden - + Allow %1 to control this PC? Darf %1 diesen PC steuern? - - %1 wants to use this PC's mouse and keyboard. You can change this in Settings. + + %1 wants to use this PC's mouse and keyboard. You can change this in Settings. %1 möchte Maus und Tastatur dieses PCs verwenden. Du kannst dies in den Einstellungen ändern. - - + + Not now Jetzt nicht - - + + Allow Erlauben - - Allow %1 to view this PC's screen? + + Allow %1 to view this PC's screen? Darf %1 den Bildschirm dieses PCs sehen? - - %1 wants to view this PC's screen. Control needs Remote mouse, keyboard & Deck in Settings. + + %1 wants to view this PC's screen. Control needs Remote mouse, keyboard & Deck in Settings. %1 möchte den Bildschirm dieses PCs sehen. Für die Steuerung muss in den Einstellungen „Remote-Maus, -Tastatur & Deck“ aktiviert sein. - + Allow for this device Für dieses Gerät erlauben - + Photo ready on clipboard Foto in der Zwischenablage bereit - + Keep on clipboard In der Zwischenablage behalten - + Paste into active window In aktives Fenster einfügen @@ -4567,35 +4727,35 @@ Voraussetzungen Media - + Media is turned off for that device. Medien sind für dieses Gerät deaktiviert. - - That player can't do that. + + That player can't do that. Dieser Player unterstützt diese Aktion nicht. - - That player isn't playing anymore. + + That player isn't playing anymore. Dieser Player gibt nichts mehr wieder. Messages - + Attachment is %1 (MMS supports up to 900 KB) Anhang ist %1 groß (MMS unterstützt bis zu 900 KB) - - Group texts can't be sent from the PC yet. + + Group texts can't be sent from the PC yet. Gruppennachrichten können noch nicht vom PC aus gesendet werden. - + Only image or voice-note audio files can be attached. Es können nur Bilddateien oder Sprachnotiz-Audiodateien angehängt werden. @@ -4603,240 +4763,240 @@ Voraussetzungen MessagesPage - + Attach image or voice note Bild oder Sprachnotiz anhängen - + Media (*.png *.jpg *.jpeg *.webp *.gif *.bmp *.m4a *.mp3 *.aac *.ogg *.wav *.amr *.3gp) Medien (*.png *.jpg *.jpeg *.webp *.gif *.bmp *.m4a *.mp3 *.aac *.ogg *.wav *.amr *.3gp) - + Images (*.png *.jpg *.jpeg *.webp *.gif *.bmp) Bilder (*.png *.jpg *.jpeg *.webp *.gif *.bmp) - + Audio (*.m4a *.mp3 *.aac *.ogg *.wav *.amr *.3gp) Audio (*.m4a *.mp3 *.aac *.ogg *.wav *.amr *.3gp) - + Today Heute - - + + Yesterday Gestern - + Text from your PC Nachrichten vom PC schreiben - - %1 isn't connected + + %1 isn't connected %1 ist nicht verbunden - + Messages are off for %1 Nachrichten sind für %1 deaktiviert - + Allow texting on %1 Nachrichten auf %1 erlauben - - Couldn't load messages + + Couldn't load messages Nachrichten konnten nicht geladen werden - + Pair your phone to read and send texts. Kopple dein Gerät, um SMS zu lesen und zu senden. - + Connect %1 to view messages. Verbinde %1, um Nachrichten anzuzeigen. - + Enable Messages in Settings. Aktiviere „Nachrichten“ in den Einstellungen. - + Grant SMS permission on %1. Erteile die SMS-Berechtigung auf %1. - + Unable to load messages. Nachrichten können nicht geladen werden. - + Try again Erneut versuchen - + Conversations Unterhaltungen - + New message (Ctrl+N) Neue Nachricht (Strg+N) - + Refresh Aktualisieren - + Search conversations Unterhaltungen durchsuchen - + Search Suchen - + Clear search Suche löschen - + No conversations match. Keine passenden Unterhaltungen gefunden. - + No conversations yet. Noch keine Unterhaltungen. - + Drop image to attach (MMS) Bild zum Anhängen hier ablegen (MMS) - + PNG, JPEG, WebP, GIF, or BMP up to 900 KB PNG, JPEG, WebP, GIF oder BMP bis zu 900 KB - + Select a conversation Wähle eine Unterhaltung aus - + Back to conversations (Esc) Zurück zu den Unterhaltungen (Esc) - + Call %1 %1 anrufen - + Call Anrufen - + Remove conversation from PC Unterhaltung vom PC entfernen - + To An - - + + Name or phone number Name oder Telefonnummer - + No mirrored messages yet Noch keine gespiegelten Nachrichten - + No messages Keine Nachrichten - + %1 · Sent as MMS %1 · Als MMS gesendet - + Remove attachment Anhang entfernen - + Attach image (MMS) Bild anhängen (MMS) - + Reply in %1 In %1 antworten - - + + Text message SMS-Nachricht - + Replies are available while the notification is active on your phone Antworten sind möglich, solange die Benachrichtigung auf deinem Gerät aktiv ist - + Reply in %1… In %1 antworten… - - Group texts can't be sent from the PC yet + + Group texts can't be sent from the PC yet Gruppennachrichten können noch nicht vom PC aus gesendet werden - + Add a caption (optional)… Bildunterschrift hinzufügen (optional)… - + Send Senden - + %1 characters · %n text(s) %1 Zeichen · %n SMS @@ -4844,72 +5004,72 @@ Voraussetzungen - + Open image attachment Bildanhang öffnen - + Voice note Sprachnotiz - + · Click to play · Klicken zum Abspielen - + · Loading… · Lädt… - + Attachment (open it on the phone) Anhang (auf dem Gerät öffnen) - + Not sent Nicht gesendet - + Sending… Wird gesendet… - + Copy text Text kopieren - + Copied Kopiert - + Copy code %1 Code %1 kopieren - + Image attachment Bildanhang - + Close Schließen - + Open externally Extern öffnen - + Done Fertig @@ -4917,102 +5077,117 @@ Voraussetzungen Mirror - - Couldn't start recording. + + Couldn't start recording. Aufnahme konnte nicht gestartet werden. - + Mirror recording Aufnahme der Spiegelung - + Mirror screenshot Screenshot der Spiegelung - + Mirroring is turned off for this phone. Die Bildschirmspiegelung ist für dieses Gerät deaktiviert. - + + Recording from %1, saved in Downloads/Nectarlink + Aufnahme von %1, gespeichert unter Downloads/Nectarlink + + + Recording from %1, saved in Downloads\Nectarlink Aufnahme von %1, gespeichert unter Downloads\Nectarlink - + Saved %1 %1 gespeichert - + + Saved %1 to Downloads/Nectarlink. + %1 unter Downloads/Nectarlink gespeichert. + + + Saved %1 to Downloads\Nectarlink. %1 unter Downloads\Nectarlink gespeichert. - + Screenshot copied to clipboard. Screenshot in die Zwischenablage kopiert. - + + Screenshot from %1, saved in Downloads/Nectarlink + Screenshot von %1, gespeichert unter Downloads/Nectarlink + + + Screenshot from %1, saved in Downloads\Nectarlink Screenshot von %1, gespeichert unter Downloads\Nectarlink - + Sending %1 files… %1 Dateien werden gesendet… - + Sending %1… %1 wird gesendet… - + Set up Wireless debugging in NectarLink on the phone to open its apps here. Richte Kabelloses Debugging in NectarLink auf dem Gerät ein, um dessen Apps hier zu öffnen. - + Show in folder Im Ordner anzeigen - - The app didn't open on the phone. Try again, or open NectarLink on the phone to check Wireless debugging. + + The app didn't open on the phone. Try again, or open NectarLink on the phone to check Wireless debugging. Die App wurde auf dem Gerät nicht geöffnet. Versuche es erneut oder öffne NectarLink auf dem Gerät, um das Kabellose Debugging zu prüfen. - - The phone isn't connected. + + The phone isn't connected. Das Gerät ist nicht verbunden. - - The screenshot couldn't be copied to the clipboard. + + The screenshot couldn't be copied to the clipboard. Der Screenshot konnte nicht in die Zwischenablage kopiert werden. - - The screenshot couldn't be saved. + + The screenshot couldn't be saved. Der Screenshot konnte nicht gespeichert werden. - - This PC can't decode the phone's video. + + This PC can't decode the phone's video. Dieser PC kann das Video des Geräts nicht dekodieren. - - This phone's app doesn't share its screen yet. Update NectarLink on the phone. + + This phone's app doesn't share its screen yet. Update NectarLink on the phone. Die App auf diesem Gerät unterstützt noch keine Bildschirmfreigabe. Aktualisiere NectarLink auf dem Gerät. - + Wait for the screen to appear first. Warte zuerst, bis der Bildschirm angezeigt wird. @@ -5020,143 +5195,143 @@ Voraussetzungen MirrorWindow - + %1 · %2 %1 · %2 - + %1 · NectarLink %1 · NectarLink - + Phone screen · NectarLink Gerätebildschirm · NectarLink - + Copy screenshot (Ctrl+Shift+C) Screenshot kopieren (Strg+Umschalt+C) - + Save screenshot (Ctrl+S) Screenshot speichern (Strg+S) - + Stop recording (Ctrl+R) Aufnahme beenden (Strg+R) - + Record video (Ctrl+R) Video aufnehmen (Strg+R) - + Phone stays awake while mirroring (turn off) Gerät bleibt beim Spiegeln wach (ausschalten) - + Keep phone awake while mirroring Gerät beim Spiegeln wach halten - + Turn phone screen back on Gerätebildschirm wieder einschalten - + Turn phone screen off while mirroring Gerätebildschirm beim Spiegeln ausschalten - - - Play the phone's sound here + + + Play the phone's sound here Ton des Geräts hier abspielen - - Mute the phone's sound here + + Mute the phone's sound here Ton des Geräts hier stummschalten - + Release to send files to %1 Loslassen, um Dateien an %1 zu senden - + phone Gerät - - To control the phone, turn on Control from your PC in NectarLink's settings on the phone. + + To control the phone, turn on Control from your PC in NectarLink's settings on the phone. Um das Gerät zu steuern, aktiviere „Vom PC aus steuern“ in den NectarLink-Einstellungen auf dem Gerät. - + Notify phone Hinweis auf Gerät senden - + Hide Ausblenden - + Opening %1 %1 wird geöffnet - + Allow it on your phone Auf deinem Gerät erlauben - + %1 closed %1 geschlossen - + Mirroring stopped Spiegelung beendet - + Starting… Wird gestartet… - + Tap the notification on %1, then choose to share the entire screen. Tippe auf die Benachrichtigung auf %1 und wähle dann die Freigabe des gesamten Bildschirms aus. - + The app closed on %1. Die App wurde auf %1 geschlossen. - + The phone stopped sharing its screen. Das Gerät hat die Bildschirmfreigabe beendet. - + Open again Erneut öffnen - + Try again Erneut versuchen @@ -5164,7 +5339,7 @@ Voraussetzungen Mp4 - + No video frames were recorded yet. Es wurden noch keine Videobilder aufgenommen. @@ -5172,22 +5347,22 @@ Voraussetzungen NotificationItem - + LIVE LIVE - + Open %1 in an app window %1 in einem App-Fenster öffnen - + now jetzt - + %n min %n Min. @@ -5195,63 +5370,63 @@ Voraussetzungen - + Sending: %1 Wird gesendet: %1 - + You: %1 Du: %1 - + Copy code Code kopieren - + Reply Antworten - - + + Reply to %1 An %1 antworten - + Send Senden - + Show less Weniger anzeigen - + +%1 more +%1 weitere - + Clear %1 %1 löschen - + Options for %1 Optionen für %1 - + Dismiss %1 notifications from %2 %1 Benachrichtigungen von %2 verwerfen - + Dismiss Verwerfen @@ -5259,37 +5434,47 @@ Voraussetzungen Notifications - + Code copied Code kopiert - + Copy code Code kopieren - + Dismissed %1 from %2 %1 von %2 verworfen - + Dismissed %1 notifications %1 Benachrichtigungen verworfen - + Dismissed from %1 Von %1 verworfen - + Notification dismissed Benachrichtigung verworfen - + + Open + Öffnen + + + + Reply + Antworten + + + That notification is gone from the phone. Diese Benachrichtigung ist nicht mehr auf dem Gerät vorhanden. @@ -5297,107 +5482,107 @@ Voraussetzungen NowPlaying - + This PC Dieser PC - - + + Phone Gerät - + PC PC - + Now playing Aktuelle Wiedergabe - + Play on %1 Auf %1 abspielen - + Artwork color sync is on (click to turn off) Farbanpassung an das Albumcover ist aktiv (klicken zum Ausschalten) - + Sync app colors & blur to playing artwork App-Farben & Unschärfe an aktuelles Albumcover anpassen - + Compact player Kompakter Player - + More controls Weitere Steuerelemente - - + + Previous Vorheriger Titel - - + + Pause Pause - - + + Play Wiedergabe - - + + Next Nächster Titel - + Playback position Wiedergabeposition - + Rewind 10 seconds 10 Sekunden zurückspulen - + Forward 10 seconds 10 Sekunden vorspulen - + Unmute Stummschaltung aufheben - + Mute Stummschalten - + Phone speaker Gerätelautsprecher - + PC speaker PC-Lautsprecher @@ -5405,180 +5590,180 @@ Voraussetzungen Obs - - OBS isn't reachable on port %1. Turn on its WebSocket server. + + OBS isn't reachable on port %1. Turn on its WebSocket server. OBS ist auf Port %1 nicht erreichbar. Aktiviere den WebSocket-Server in OBS. - + OBS: %1 OBS: %1 - - The service on that port isn't OBS. + + The service on that port isn't OBS. Der Dienst auf diesem Port ist nicht OBS. PairingPanel - + Check the code Code überprüfen - + Paired Gekoppelt - - Pairing didn't work + + Pairing didn't work Kopplung fehlgeschlagen - + Pair nearby In der Nähe koppeln - - + + Scan with your phone Mit deinem Gerät scannen - - + + Pairing QR code QR-Code zur Kopplung - + Install NectarLink NectarLink installieren - - + + Tap “Pair with PC” Auf „Mit PC koppeln“ tippen - + Scan this code Diesen Code scannen - - + + Code refreshes in %1:%2 · Works on this network Code wird in %1:%2 erneuert · Funktioniert in diesem Netzwerk - - + + Pair nearby instead Stattdessen in der Nähe koppeln - + Open NectarLink on your phone Öffne NectarLink auf deinem Gerät - + Scan this QR code Diesen QR-Code scannen - + On your phone, open NectarLink and choose “Pair nearby”. It shows up here. Öffne NectarLink auf deinem Gerät und wähle „In der Nähe koppeln“. Es erscheint dann hier. - + Pair Koppeln - + Looking for devices… Suche nach Geräten… - + Show the QR code QR-Code anzeigen - + Connecting to %1… %1 wird verbunden… - + Waiting for %1… Warte auf %1… - + Make sure %1 shows the same code. This keeps anyone else from pairing in between. Stelle sicher, dass %1 denselben Code anzeigt. Das verhindert, dass sich jemand anderes dazwischenschaltet. - + your phone dein Gerät - - They don't match + + They don't match Stimmen nicht überein - + Codes match Codes stimmen überein - + %1 is paired with this PC. %1 ist mit diesem PC gekoppelt. - - Pairing was cancelled or the codes didn't match, so nothing was paired. Try again. + + Pairing was cancelled or the codes didn't match, so nothing was paired. Try again. Die Kopplung wurde abgebrochen oder die Codes stimmten nicht überein – es wurde kein Gerät gekoppelt. Versuche es erneut. - + Pairing was declined on one of the devices. Die Kopplung wurde auf einem der Geräte abgelehnt. - - Couldn't reach the phone. Make sure both devices are on the same network. + + Couldn't reach the phone. Make sure both devices are on the same network. Das Gerät konnte nicht erreicht werden. Stelle sicher, dass sich beide Geräte im selben Netzwerk befinden. - + The code expired. Der Code ist abgelaufen. - + Something went wrong. Try again. Etwas ist schiefgelaufen. Versuche es erneut. - + Try again Erneut versuchen - + Cancel Abbrechen @@ -5586,25 +5771,25 @@ Voraussetzungen PcMirror - - Can't find the player's window. Play the video on this PC again, then turn on Play on phone. + + Can't find the player's window. Play the video on this PC again, then turn on Play on phone. Das Player-Fenster wurde nicht gefunden. Spiele das Video erneut auf diesem PC ab und aktiviere dann „Auf dem Gerät abspielen“. PhoneMic - + Tap Start on %1 to use its microphone. Tippe auf %1 auf „Starten“, um das Mikrofon zu verwenden. - + The microphone is turned off for this PC on %1. Das Mikrofon ist auf %1 für diesen PC deaktiviert. - + Update NectarLink on %1 to use its microphone. Aktualisiere NectarLink auf %1, um das Mikrofon zu verwenden. @@ -5612,57 +5797,57 @@ Voraussetzungen PhotoBackup - - Can't write to the backup folder. + + Can't write to the backup folder. In den Sicherungsordner kann nicht geschrieben werden. - + Copying, %1 left Wird kopiert, noch %1 - + Looking for new photos… Suche nach neuen Fotos… - + Off Aus - + Photos are turned off for this PC on %1. Fotos sind auf %1 für diesen PC deaktiviert. - - The backup folder's drive is full. + + The backup folder's drive is full. Das Laufwerk des Sicherungsordners ist voll. - + The backup stopped. It tries again in a few minutes. Die Sicherung wurde unterbrochen. In wenigen Minuten wird es erneut versucht. - + Up to date Auf dem neuesten Stand - + Up to date, %1 backed up Auf dem neuesten Stand, %1 gesichert - + Update NectarLink on %1 to back up its photos. Aktualisiere NectarLink auf %1, um dessen Fotos zu sichern. - + Waiting for %1 Wartet auf %1 @@ -5670,339 +5855,339 @@ Voraussetzungen Photos - + Copied to clipboard. In die Zwischenablage kopiert. - + From %1 Von %1 - + New photo Neues Foto - + New screenshot Neuer Screenshot - + No recent photos found on the phone. Keine aktuellen Fotos auf dem Gerät gefunden. - + Opening latest photo… Neuestes Foto wird geöffnet… - + Saved %1 items to %2. %1 Elemente unter %2 gespeichert. - + Saved %1 to %2. %1 unter %2 gespeichert. - - That photo isn't on the phone anymore. + + That photo isn't on the phone anymore. Dieses Foto befindet sich nicht mehr auf dem Gerät. - - The phone doesn't share photos or files with this PC. + + The phone doesn't share photos or files with this PC. Das Gerät teilt keine Fotos oder Dateien mit diesem PC. - - The phone isn't connected right now. + + The phone isn't connected right now. Das Gerät ist momentan nicht verbunden. - - The photo couldn't be copied to the clipboard. + + The photo couldn't be copied to the clipboard. Das Foto konnte nicht in die Zwischenablage kopiert werden. - - The photo couldn't be copied. It's in Downloads\Nectarlink. + + The photo couldn't be copied. It's in Downloads\Nectarlink. Das Foto konnte nicht kopiert werden. Es liegt unter Downloads\Nectarlink. PhotosPage - - + + All photos Alle Fotos - + Album Album - - + + Today Heute - - + + Yesterday Gestern - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Choose default folder for saved photos Standardordner für gespeicherte Fotos auswählen - + Save to folder In Ordner speichern - + Photos Fotos - - - %1 isn't connected + + + %1 isn't connected %1 ist nicht verbunden - + Pair your phone to browse photos and albums. Kopple dein Gerät, um Fotos und Alben zu durchsuchen. - - + + Connect %1 to view photos. Verbinde %1, um Fotos anzuzeigen. - + Albums Alben - + Refresh Aktualisieren - + %1+ items %1+ Elemente - + 1 item 1 Element - + %1 items %1 Elemente - + Cancel Abbrechen - + Photo Foto - + Take photo Foto aufnehmen - + Scan Scannen - + Scan document Dokument scannen - + Select Auswählen - + Cancel selection Auswahl aufheben - + %1 selected %1 ausgewählt - + Select items Elemente auswählen - + Click items to select Klicke auf Elemente, um sie auszuwählen - + Select all Alle auswählen - - + + Copy Kopieren - + Open Öffnen - + Save (%1) Speichern (%1) - - + + Save Speichern - - + + Save to folder… In Ordner speichern… - + Photos are off for %1 Fotos sind für %1 deaktiviert - + Allow photos on %1 Fotos auf %1 erlauben - - Couldn't load photos + + Couldn't load photos Fotos konnten nicht geladen werden - + Enable Photos in Settings. Aktiviere „Fotos“ in den Einstellungen. - + Grant photos permission on %1. Erteile die Foto-Berechtigung auf %1. - + Unable to load photos. Fotos können nicht geladen werden. - + Try again Erneut versuchen - + No photos or videos Keine Fotos oder Videos - + Back to gallery Zurück zur Galerie - - + + Play video Video abspielen - + Open in Photos In Fotos öffnen - + Loading full resolution… Volle Auflösung wird geladen… - + Previous photo Vorheriges Foto - + Next photo Nächstes Foto - + %1 %1 - + Select photo Foto auswählen @@ -6010,32 +6195,32 @@ Voraussetzungen PlayOnPhone - + Connect your phone to play on it. Verbinde dein Gerät, um darauf abzuspielen. - + Media is turned off for %1. Medien sind für %1 deaktiviert. - + Opening in %1 on %2. Tap the notification there. Wird in %1 auf %2 geöffnet. Tippe dort auf die Benachrichtigung. - + Sent to %1. Tap the notification there. An %1 gesendet. Tippe dort auf die Benachrichtigung. - + Tap the notification on %1 to watch. Tippe auf die Benachrichtigung auf %1, um zuzuschauen. - + Update NectarLink on %1 to play on it. Aktualisiere NectarLink auf %1, um darauf abzuspielen. @@ -6043,7 +6228,7 @@ Voraussetzungen Prefs - + Connected to OBS. Mit OBS verbunden. @@ -6051,7 +6236,7 @@ Voraussetzungen Recordings - + Recording from %1 saved Aufnahme von %1 gespeichert @@ -6059,7 +6244,7 @@ Voraussetzungen SadBee - + A sad bee Eine traurige Biene @@ -6067,57 +6252,57 @@ Voraussetzungen SendTo - - Copied to your phone's clipboard. + + Copied to your phone's clipboard. In die Zwischenablage deines Geräts kopiert. - - Couldn't reach %1. + + Couldn't reach %1. %1 konnte nicht erreicht werden. - - Couldn't send %1 + + Couldn't send %1 %1 konnte nicht gesendet werden - - Couldn't send to %1 + + Couldn't send to %1 Senden an %1 fehlgeschlagen - - Couldn't share with %1 + + Couldn't share with %1 Teilen mit %1 fehlgeschlagen - + File failed integrity check; partial file kept to resume. Integritätsprüfung der Datei fehlgeschlagen; Teildatei zum Fortsetzen behalten. - + Send to %1 with NectarLink Mit NectarLink an %1 senden - + Sending to %1… Wird an %1 gesendet… - + Sent to %1 An %1 gesendet - - The transfer didn't go through. + + The transfer didn't go through. Die Übertragung konnte nicht abgeschlossen werden. - + Waiting for %1… Wartet auf %1… @@ -6125,1448 +6310,1495 @@ Voraussetzungen SettingsPage - + Appearance Darstellung - + Theme, mode & colors Design, Modus & Farben - + General Allgemein - + Startup, clipboard & desktop Autostart, Zwischenablage & Desktop - + Devices Geräte - + Paired phones & permissions Gekoppelte Geräte & Berechtigungen - + Camera & Audio Kamera & Audio - + Webcam, speaker & calls Webcam, Lautsprecher & Anrufe - - + + Notifications Benachrichtigungen - + Do not disturb & app rules Nicht stören & App-Regeln - + Files & Sync Dateien & Synchronisierung - + Folder sync, storage & recordings Ordnersynchronisierung, Speicher & Aufnahmen - + Shortcuts & Rules Tastenkürzel & Regeln - + Hotkeys & automations Hotkeys & Automatisierungen - + Storage & Privacy Speicher & Datenschutz - + History, caches & retention Verlauf, Caches & Aufbewahrung - + Connection & About Verbindung & Info - + Doctor, Wake-on-LAN, API & version Verbindungs-Doktor, Wake-on-LAN, API & Version - + When phone connects Wenn Gerät verbunden wird - + When phone disconnects Wenn Gerät getrennt wird - + When phone rings Wenn Gerät klingelt - + When call ends Wenn Anruf endet - + When phone battery is low Wenn Geräteakku schwach ist - + When phone is charged Wenn Gerät geladen ist - + Lock this PC Diesen PC sperren - + Sleep this PC Diesen PC in den Energiesparmodus versetzen - + Pause PC media PC-Medien pausieren - + Resume PC media PC-Medien fortsetzen - + Mute PC audio PC-Audio stummschalten - + Unmute PC audio Stummschaltung für PC-Audio aufheben - + Run command Befehl ausführen - + Show notification Benachrichtigung anzeigen - - + + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Messages Nachrichten - - + + Calls Anrufe - + Contacts Kontakte - + Clipboard Zwischenablage - + Files Dateien - - + + Voice recordings Sprachaufnahmen - + Media controls Mediensteuerung - + Photos Fotos - + Phone controls Gerätesteuerung - + Lock, sleep & wake PC PC sperren, in den Energiesparmodus versetzen & aufwecken - + Screen mirroring Bildschirmspiegelung - + Share PC screen to phone PC-Bildschirm für Gerät freigeben - + Webcam Webcam - + File Explorer integration Integration in den Datei-Explorer - + + File manager integration + Integration in den Dateimanager + + + Remote mouse, keyboard & Deck Remote-Maus, -Tastatur & Deck - + Run Deck shell commands Deck-Shell-Befehle ausführen - + Remote PC file access Remote-Dateizugriff auf den PC - - Allow this phone to view this PC's screen. + + Allow this phone to view this PC's screen. Diesem Gerät erlauben, den Bildschirm dieses PCs zu sehen. - + Only enable for a phone you trust. Nur für ein Gerät aktivieren, dem du vertraust. - + Calls, messages & notifications Anrufe, Nachrichten & Benachrichtigungen - + Files, photos & clipboard Dateien, Fotos & Zwischenablage - + Screen, camera & control Bildschirm, Kamera & Steuerung - + Back up photos to Fotos sichern unter - + Save recordings in Aufnahmen speichern unter - + Pair a new device Neues Gerät koppeln - + Theme Design - + Hive Wabe - + Bloom Blüte - + Glass Glass - + Graphite Graphit - + Mode Modus - - + + System System - + Light Hell - + Dark Dunkel - + Color Farbe - + Wallpaper Hintergrundbild - + AMOLED AMOLED - - - + + + Pure black (AMOLED) Reines Schwarz (AMOLED) - - + + Media artwork Albumcover - - + + Animated comb Animierte Wabe - + + Still while animations are off in system settings. + Statisch, solange Animationen in den Systemeinstellungen deaktiviert sind. + + + Still while animations are off in Windows. Statisch, solange Animationen in Windows deaktiviert sind. - + Still while energy saver is on. Statisch, solange der Energiesparmodus aktiv ist. - + The honeycomb breathes and pulses. Die Bienenwabe atmet und pulsiert. - + Pitch-black #000000 surfaces. Tiefschwarze #000000-Oberflächen. - - + + Mica backdrop Mica-Hintergrund - + Tint window with desktop wallpaper. Fenster mit dem Desktop-Hintergrund tönen. - - + + Language Sprache - + + NectarLink follows the system unless you pick one. + NectarLink übernimmt die Systemsprache, sofern du keine auswählst. + + + NectarLink follows Windows unless you pick one. NectarLink übernimmt die Windows-Sprache, sofern du keine auswählst. - + More Mehr - - + + Start with Windows Mit Windows starten - + + + Start at login + Bei der Anmeldung starten + + + Launch in the tray when you sign in. Beim Anmelden im Infobereich starten. - - + + Stay in system tray Im Infobereich bleiben - + Keep running when the window is closed. Beim Schließen des Fensters im Hintergrund weiterlaufen. - - + + Nectar Island Nectar Island - + Top-screen pill for calls, transfers & media. Leiste am oberen Bildschirmrand für Anrufe, Übertragungen & Medien. - - + + Battery alerts Akku-Warnungen - + Notify when phone battery is low or full. Benachrichtigen, wenn der Geräteakku fast leer oder voll geladen ist. - + Island triggers Island-Auslöser - + Choose when the top pill appears. Wähle aus, wann die obere Leiste eingeblendet wird. - + Transfers Übertragungen - + Media Medien - + Alerts Hinweise - - + + Sync clipboard to phone Zwischenablage mit Gerät synchronisieren - + Share copied text and images with your phone. Kopierten Text und Bilder mit deinem Gerät teilen. - - + + Clipboard follows control Zwischenablage folgt Steuerung - + Send your clipboard to the phone when you start controlling it, or when the mouse moves into its window. Zwischenablage an das Gerät senden, sobald du es steuerst oder den Mauszeiger in sein Fenster bewegst. - - + + Copy one-time codes Einmalcodes kopieren - + Auto-copy verification codes from texts. Bestätigungscodes aus SMS automatisch kopieren. - - + + Copy received files Empfangene Dateien kopieren - + Ready to Ctrl+V incoming files anywhere. Eingehende Dateien überall direkt mit Strg+V einfügen. - - + + Suggest clipboard actions Aktionen für die Zwischenablage vorschlagen - + Open links, maps, numbers & emails from phone clips. Links, Karten, Telefonnummern & E-Mails aus der Geräte-Zwischenablage öffnen. - + No phones paired yet Noch kein Gerät gekoppelt - + Pair your phone to get started. Kopple dein Gerät, um loszulegen. - + Pair a phone Gerät koppeln - + Connected Verbunden - + Not connected Nicht verbunden - - + + Unpair Entkoppeln - - Join this phone's hotspot + + Join this phone's hotspot Mit dem Hotspot dieses Geräts verbinden - + When Instant Hotspot turns it on, this PC joins %1. Wenn Instant Hotspot ihn aktiviert, verbindet sich dieser PC mit %1. - - Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here. + + Join the phone's hotspot once from your system Wi-Fi list, then choose it here. + Verbinde dich einmal über die WLAN-Liste deines Systems mit dem Hotspot des Geräts und wähle ihn dann hier aus. + + + + Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here. Verbinde dich einmal über die WLAN-Liste von Windows mit dem Hotspot des Geräts und wähle ihn dann hier aus. - + Choose network Netzwerk auswählen - - - - - - + + + + + + Open Öffnen - - + + Stream PC audio to phone PC-Audio auf Gerät streamen - + Listen to PC audio through your phone. Höre den PC-Ton über dein Gerät. - - + + Pause media during calls Medien während Anrufen pausieren - + Auto-pause PC media when phone rings. PC-Medien automatisch pausieren, wenn das Gerät klingelt. - - + + Mute this PC while a phone watches Diesen PC stummschalten, während ein Gerät zuschaut - + With Play on phone, the phone still gets the sound. Bei „Auf dem Gerät abspielen“ wird der Ton weiterhin auf dem Gerät wiedergegeben. - + Phone as microphone Gerät als Mikrofon - + Live from %1, playing into %2. Live von %1, Wiedergabe über %2. - + Tap Start on %1. Tippe auf %1 auf „Starten“. - + Plays into %1, so apps can pick its other end as a microphone. Wird über %1 ausgegeben, sodass Apps die Gegenseite als Mikrofon auswählen können. - + Plays into %1. To use it as a microphone in other apps, install a virtual audio cable such as VB-CABLE. Wird über %1 ausgegeben. Um es in anderen Apps als Mikrofon zu nutzen, installiere ein virtuelles Audiokabel wie VB-CABLE. - + Output Ausgabe - + Start Starten - + Stop Stoppen - - + + Microphone volume Mikrofonlautstärke - - + + %1% %1% - + Virtual camera add-on Add-on für virtuelle Kamera - + Registered for Zoom, Teams & Meet. Registriert für Zoom, Teams & Meet. - + One-time setup (admin required) for Zoom, Teams & Meet. Einmalige Einrichtung (Administratorrechte erforderlich) für Zoom, Teams & Meet. - - + + Ready Bereit - - - + + + Remove Entfernen - + Set up Einrichten - + Phone Gerät - + Camera Kamera - + Back Hinten - + Front Vorne - - + + Mirror image Bild spiegeln - + Resolution Auflösung - + Preview · %1 Vorschau · %1 - + Pair a phone to use its camera as a webcam Kopple ein Gerät, um dessen Kamera als Webcam zu nutzen - + Stop webcam Webcam stoppen - + Start webcam Webcam starten - - + + Follow phone Do not disturb „Nicht stören“ des Geräts übernehmen - + Mute pop-ups while phone is on DND. Pop-ups stummschalten, solange auf dem Gerät „Nicht stören“ aktiv ist. - - + + Forward PC notifications PC-Benachrichtigungen weiterleiten - + Send PC alerts to phone while away. PC-Hinweise bei Abwesenheit an das Gerät senden. - + Quiet hours Ruhezeiten - + Configure automatic DND schedule in Windows. Automatischen Zeitplan für „Nicht stören“ in Windows einrichten. - + Set a schedule Zeitplan festlegen - + Show Anzeigen - + No pop-ups Keine Pop-ups - + Hide Ausblenden - - + + File Explorer Send to „Senden an“ im Datei-Explorer - + + + File manager Send to + „Senden an“ im Dateimanager + + + Send files from right-click menu. Dateien über das Rechtsklick-Menü senden. - - + + Your PC in your pocket Dein PC in deiner Tasche - + Share Downloads, Documents, Desktop & Pictures. Downloads, Dokumente, Desktop & Bilder freigeben. - - + + LocalSend on local network LocalSend im lokalen Netzwerk - + Share with nearby LocalSend devices without pairing. Ohne Kopplung mit LocalSend-Geräten in der Nähe teilen. - - + + Open files shared from the phone Vom Gerät geteilte Dateien öffnen - + Opens a shared photo or document as soon as it arrives. Öffnet ein geteiltes Foto oder Dokument automatisch nach dem Empfang. - - - - - + + + + + Reset Zurücksetzen - - + + Choose folder Ordner auswählen - + Photo backup Fotosicherung - - Copies new photos from every album into a folder on this PC, by year and month. Nothing is deleted, here or on the phone. Needs only the phone's photos permission. + + Copies new photos from every album into a folder on this PC, by year and month. Nothing is deleted, here or on the phone. Needs only the phone's photos permission. Kopiert neue Fotos aus jedem Album nach Jahr und Monat sortiert in einen Ordner auf diesem PC. Weder hier noch auf dem Gerät wird etwas gelöscht. Benötigt nur die Foto-Berechtigung auf dem Gerät. - + Back up photos from %1 Fotos von %1 sichern - + Backup folder Sicherungsordner - - + + Include videos Videos einschließen - + Videos can be large. Copied one at a time, so a lost connection costs at most one file. Videos können groß sein. Sie werden einzeln kopiert, sodass bei einem Verbindungsabbruch höchstens eine Datei betroffen ist. - + Back up now Jetzt sichern - + Synced folders Synchronisierte Ordner - + One-way or two-way folder sync between phone and PC. Einseitige oder zweiseitige Ordnersynchronisierung zwischen Gerät und PC. - + Sync now Jetzt synchronisieren - + Add DCIM → Pictures DCIM → Bilder hinzufügen - + Two-way sync · conflict copies kept Zwei-Wege-Synchronisierung · Konfliktkopien werden behalten - + One-way · phone to PC Einseitig · Gerät zu PC - + Command palette Befehlspalette - + Open from any app. Aus jeder App heraus öffnen. - + Command palette shortcut Tastenkürzel für Befehlspalette - + File shelf Datei-Shelf - + Toggle screen-edge shelf. Shelf am Bildschirmrand ein-/ausblenden. - + Shelf shortcut Tastenkürzel für Shelf - + Take photo Foto aufnehmen - + Capture & paste photo. Foto aufnehmen & einfügen. - + Take photo shortcut Tastenkürzel für Fotoaufnahme - + Scan document Dokument scannen - + Scan & paste document. Dokument scannen & einfügen. - + Scan document shortcut Tastenkürzel für Dokumentenscan - + Trigger-action rules Auslöser-Aktions-Regeln - + Run actions when your phone connects, disconnects, or rings. Aktionen ausführen, wenn dein Gerät verbunden oder getrennt wird oder klingelt. - + Lock PC on disconnect PC beim Trennen sperren - + Pause media on ring Medien beim Klingeln pausieren - + OBS Studio OBS Studio - - For Deck tiles that run obs: commands. Turn on OBS's WebSocket server in Tools › WebSocket Server Settings. + + For Deck tiles that run obs: commands. Turn on OBS's WebSocket server in Tools › WebSocket Server Settings. Für Deck-Kacheln, die obs:-Befehle ausführen. Aktiviere den WebSocket-Server in OBS unter Werkzeuge › WebSocket-Server-Einstellungen. - + Port Port - + OBS WebSocket port OBS-WebSocket-Port - + Password Passwort - + OBS WebSocket password OBS-WebSocket-Passwort - + Saved Gespeichert - + None Keines - + Save Speichern - + Test connection Verbindung testen - + Remove password Passwort entfernen - - + + Clipboard history Zwischenablageverlauf - + %1 clips · %2 · kept 7 days %1 Einträge · %2 · 7 Tage aufbewahrt - - - - - - + + + + + + Clear Leeren - - + + Notification history Benachrichtigungsverlauf - + %1 alerts · %2 · kept 24h %1 Hinweise · %2 · 24 Std. aufbewahrt - + Cross-device timeline Geräteübergreifender Zeitverlauf - + 1 item stored (max 5,000). 1 Eintrag gespeichert (max. 5.000). - + %1 items stored (max 5,000). %1 Einträge gespeichert (max. 5.000). - + 30d 30 T. - + 90d 90 T. - + 1y 1 J. - + All Alle - + Cached messages Zwischengespeicherte Nachrichten - + 1 chat 1 Chat - + %1 chats %1 Chats - + 1 message 1 Nachricht - + %1 messages %1 Nachrichten - + %1 · %2 · %3 · kept 90 days %1 · %2 · %3 · 90 Tage aufbewahrt - + Photo thumbnails Foto-Vorschaubilder - + %1 cached · %2 · kept 30 days %1 im Cache · %2 · 30 Tage aufbewahrt - + Transfer history Übertragungsverlauf - + %1 records · kept with timeline %1 Einträge · mit Zeitverlauf aufbewahrt - + Clear all local history & caches Gesamten lokalen Verlauf & Caches löschen - + Wipes local history and caches; keeps paired devices and settings. Löscht den lokalen Verlauf und die Caches; gekoppelte Geräte und Einstellungen bleiben erhalten. - + Clear everything… Alles löschen… - + Connection Doctor Verbindungs-Doktor - + Diagnose and fix firewall, network, or VPN issues. Firewall-, Netzwerk- oder VPN-Probleme diagnostizieren und beheben. - + Check Prüfen - + Connect over mobile data Über mobile Daten verbinden - + Stay connected over LTE/5G or relay when away from Wi-Fi. Über LTE/5G oder Relay verbunden bleiben, wenn kein WLAN verfügbar ist. - + Wake from your phone Vom Gerät aus aufwecken - + No active network adapter found. Kein aktiver Netzwerkadapter gefunden. - + %1 · Wake on Magic Packet ready. %1 · Wake on Magic Packet bereit. - + %1 · Enable Wake on Magic Packet in Device Manager. %1 · Aktiviere „Wake on Magic Packet“ im Geräte-Manager. - + Off in Windows In Windows deaktiviert - + + Off in the adapter's settings + In den Adaptereinstellungen deaktiviert + + + Check settings Einstellungen prüfen - + No adapter Kein Adapter - - + + Local automation API Lokale Automatisierungs-API - + Listening on 127.0.0.1:%1 · Token in %2 Lauscht auf 127.0.0.1:%1 · Token in %2 - + Local HTTP/SSE server (127.0.0.1:%1) for scripts and tools. Lokaler HTTP/SSE-Server (127.0.0.1:%1) für Skripte und Tools. - + NectarLink %1 NectarLink %1 - + This PC: %1 Dieser PC: %1 - + Update download Update-Download - + Update to %1 Auf %1 aktualisieren - - - + + + Check for updates Nach Updates suchen - + Open logs Protokolle öffnen - + Looks for a new version on GitHub once a day. Sucht einmal täglich auf GitHub nach einer neuen Version. - - + + Install updates automatically Updates automatisch installieren - + When nobody is using the PC and nothing is in progress. Wenn der PC gerade nicht genutzt wird und keine Vorgänge laufen. - + Setup walkthrough Einrichtungsassistent - + Re-open the setup guide. Einrichtungsassistenten erneut öffnen. - + Features guide Funktionsübersicht - + What each feature needs and where to find it. Was jede Funktion benötigt und wo du sie findest. - - Phone's hotspot network + + Phone's hotspot network Hotspot-Netzwerk des Geräts - + + Wi-Fi networks this PC has joined before. Your system keeps their passwords; NectarLink never sees them. + WLAN-Netzwerke, mit denen dieser PC bereits verbunden war. Dein System speichert deren Passwörter; NectarLink sieht sie nie. + + + + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from your system Wi-Fi list first. + Dieser PC hat noch keine WLAN-Netzwerke gespeichert. Verbinde dich zuerst einmal über die WLAN-Liste deines Systems mit dem Hotspot des Geräts. + + + Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them. WLAN-Netzwerke, mit denen dieser PC bereits verbunden war. Windows speichert deren Passwörter; NectarLink sieht sie nie. - - This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first. + + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first. Dieser PC hat noch keine WLAN-Netzwerke gespeichert. Verbinde dich zuerst einmal über die WLAN-Liste von Windows mit dem Hotspot des Geräts. - - Don't join + + Don't join Nicht verbinden - - - + + + Close Schließen - - Play the phone's microphone into + + Play the phone's microphone into Gerätemikrofon ausgeben über - + Unpair %1? %1 entkoppeln? - + Both devices forget each other. You can pair them again any time. Beide Geräte vergessen einander. Du kannst sie jederzeit erneut koppeln. - - + + Cancel Abbrechen - + Clear all local history & caches? Gesamten lokalen Verlauf & Caches löschen? - + Deletes clipboard history, Timeline, notification history, cached messages, photo thumbnails and transfer history on this PC. Paired devices, settings and downloaded files are kept. Löscht Zwischenablageverlauf, Zeitverlauf, Benachrichtigungsverlauf, zwischengespeicherte Nachrichten, Foto-Vorschaubilder und Übertragungsverlauf auf diesem PC. Gekoppelte Geräte, Einstellungen und heruntergeladene Dateien bleiben erhalten. - + Clear everything Alles löschen @@ -7574,209 +7806,219 @@ Voraussetzungen Setup - - Couldn't start setup: %1 + + Couldn't start setup: %1 Installation konnte nicht gestartet werden: %1 - + + Couldn't uninstall NectarLink: %1 + NectarLink konnte nicht deinstalliert werden: %1 + + + Setup is taking too long. Try again after restarting the PC. Die Installation dauert zu lange. Starte den PC neu und versuche es erneut. - + Setup stopped with code %1. Installation mit Code %1 abgebrochen. - + The uninstaller is missing. Reinstall NectarLink, then uninstall it. Das Deinstallationsprogramm fehlt. Installiere NectarLink erneut und deinstalliere es anschließend. + + + Uninstall stopped with code %1. + Die Deinstallation wurde mit Code %1 beendet. + SetupWindow - + Uninstall NectarLink NectarLink deinstallieren - + NectarLink Setup NectarLink-Installation - - - - + + + + Close Schließen - + NectarLink NectarLink - - Couldn't uninstall NectarLink + + Couldn't uninstall NectarLink NectarLink konnte nicht deinstalliert werden - - Setup couldn't finish + + Setup couldn't finish Installation konnte nicht abgeschlossen werden - + Uninstalling NectarLink NectarLink wird deinstalliert - + Updating NectarLink NectarLink wird aktualisiert - + Installing NectarLink NectarLink wird installiert - + Uninstall NectarLink? NectarLink deinstallieren? - + Sorry to see you go. This uninstalls NectarLink and deletes its settings and pairings on this PC. Files you received stay in %1. Schade, dass du gehst. Dadurch wird NectarLink deinstalliert und alle Einstellungen sowie Kopplungen auf diesem PC werden gelöscht. Empfangene Dateien bleiben in %1 erhalten. - + Update from %1 to %2. Your settings and pairings stay as they are. Aktualisierung von %1 auf %2. Deine Einstellungen und Kopplungen bleiben erhalten. - + Version %1 is already installed. Install it again to repair it. Version %1 ist bereits installiert. Installiere sie erneut, um sie zu reparieren. - + This PC has a newer version (%1). Installing %2 replaces it. Auf diesem PC ist eine neuere Version (%1) installiert. Durch die Installation von %2 wird sie ersetzt. - + Your Android phone and your PC, working as one. Dein Android-Gerät und dein PC – perfekt verbunden. - + Try again Erneut versuchen - + Uninstall Deinstallieren - + Update Aktualisieren - + Reinstall Erneut installieren - + Install Installieren - + Keep NectarLink NectarLink behalten - - + + What this installs Was installiert wird - + NectarLink is uninstalled NectarLink wurde deinstalliert - + NectarLink is ready NectarLink ist bereit - + Your received files are still in %1. Thanks for giving it a try. Deine empfangenen Dateien befinden sich weiterhin in %1. Danke fürs Ausprobieren! - - Next, get the Android app. Scan this code with your phone's camera to open its download page. + + Next, get the Android app. Scan this code with your phone's camera to open its download page. Hole dir als Nächstes die Android-App. Scanne diesen Code mit der Kamera deines Geräts, um die Download-Seite zu öffnen. - + QR code for the Android app download page QR-Code für die Download-Seite der Android-App - + Open the Android app download page Download-Seite der Android-App öffnen - + Open NectarLink NectarLink öffnen - + NectarLink goes into Program Files for everyone on this PC. Setup also: NectarLink wird unter „Programme“ für alle Benutzer dieses PCs installiert. Außerdem führt die Installation Folgendes aus: - + allows it through Windows Firewall on private networks, so your phone can connect lässt NectarLink in privaten Netzwerken durch die Windows-Firewall zu, damit sich dein Gerät verbinden kann - + adds a task that installs updates without an administrator prompt richtet eine Aufgabe ein, die Updates ohne Administratorabfrage installiert - + adds Start menu and desktop shortcuts, and starts it with Windows (turn this off in Settings) erstellt Verknüpfungen im Startmenü und auf dem Desktop und startet die App mit Windows (in den Einstellungen deaktivierbar) - + adds it to the Windows Share sheet fügt NectarLink zum Teilen-Menü von Windows hinzu - + It checks GitHub for updates once a day, which you can turn off in Settings. Otherwise your phone and PC only talk to each other. Es sucht einmal täglich auf GitHub nach Updates, was du in den Einstellungen ausschalten kannst. Ansonsten kommunizieren dein Gerät und dein PC ausschließlich direkt miteinander. - + Privacy details Details zum Datenschutz @@ -7784,174 +8026,174 @@ Voraussetzungen ShelfWindow - + NectarLink Shelf NectarLink Shelf - + phone Gerät - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + Shelf Shelf - + No phone connected Kein Gerät verbunden - + Refresh Shelf Shelf aktualisieren - + Close Shelf Shelf schließen - + Release to send to %1 Loslassen, um an %1 zu senden - + Sent to %1 An %1 gesendet - + Send cancelled Senden abgebrochen - + Drop or flick items here to send Elemente zum Senden hier ablegen oder nach oben wischen - + Sending to %1… Wird an %1 gesendet… - + Undo Rückgängig - + Latest photo Neuestes Foto - + No recent photo Kein aktuelles Foto - + Latest screenshot Neuester Screenshot - + No recent screenshot Kein aktueller Screenshot - + Copy Kopieren - + Open Öffnen - + Current clip Aktuelle Zwischenablage - + Send clip to phone Zwischenablage an Gerät senden - - + + Clip Zwischenablage - + Copy clip Inhalt kopieren - - + + Clipboard is empty Zwischenablage ist leer - + Current clip: %1 Aktuelle Zwischenablage: %1 - + Recent received files Zuletzt empfangene Dateien - + No received files yet Noch keine empfangenen Dateien - + Send to phone An Gerät senden - + Open file Datei öffnen - + Show in folder Im Ordner anzeigen - + Flick any card up to send to phone, drag out to use, or drop files in Wische eine Karte nach oben, um sie ans Gerät zu senden, ziehe sie heraus oder lege Dateien hier ab @@ -7959,147 +8201,147 @@ Voraussetzungen Timeline - + App window #%1 App-Fenster #%1 - + Clip (removed from clipboard history) Eintrag (aus dem Zwischenablageverlauf entfernt) - + Copied image Kopiertes Bild - + Copied link to clipboard. Link in die Zwischenablage kopiert. - + Copied to clipboard. In die Zwischenablage kopiert. - + From %1 Von %1 - + In progress In Bearbeitung - + No phone is paired yet. Noch kein Gerät gekoppelt. - + Opened from %1 Von %1 geöffnet - + Opened on %1 Auf %1 geöffnet - + Phone microphone Gerätemikrofon - + Phone webcam Geräte-Webcam - + Recorded on %1 Auf %1 aufgenommen - + Saved from %1 Von %1 gespeichert - + Screen mirroring Bildschirmspiegelung - + Sending to %1… Wird an %1 gesendet… - + Sent clip to %1. Zwischenablage an %1 gesendet. - + Sent to %1 An %1 gesendet - + Session Sitzung - + That clip is no longer in clipboard history. Dieser Eintrag befindet sich nicht mehr im Zwischenablageverlauf. - - That entry couldn't be removed. Try again. + + That entry couldn't be removed. Try again. Dieser Eintrag konnte nicht entfernt werden. Versuche es erneut. - + That file was moved or deleted. Diese Datei wurde verschoben oder gelöscht. - - That isn't a web link. + + That isn't a web link. Das ist kein Weblink. - + That photo was moved or deleted. Dieses Foto wurde verschoben oder gelöscht. - - The Timeline couldn't be cleared. Try again. + + The Timeline couldn't be cleared. Try again. Der Zeitverlauf konnte nicht geleert werden. Versuche es erneut. - - The photo couldn't be copied to the clipboard. + + The photo couldn't be copied to the clipboard. Das Foto konnte nicht in die Zwischenablage kopiert werden. - + Voice recording Sprachaufnahme - + With %1 Mit %1 - + device Gerät @@ -8107,145 +8349,145 @@ Voraussetzungen TimelinePage - + All Alle - + Files Dateien - + Clips Zwischenablage - + Links Links - + Photos Fotos - + Recordings Aufnahmen - + Sessions Sitzungen - - - - + + + + All devices Alle Geräte - + Today Heute - + Yesterday Gestern - + Search timeline Zeitverlauf durchsuchen - + Search timeline… Zeitverlauf durchsuchen… - + Clear search Suche löschen - + Clear all Alle löschen - + Device filter: %1 Gerätefilter: %1 - + Clear timeline history? Saved files on disk are kept. Zeitverlauf löschen? Auf der Festplatte gespeicherte Dateien bleiben erhalten. - + Cancel Abbrechen - + Clear timeline Zeitverlauf löschen - + No matching items Keine passenden Einträge - + No recent activity Keine kürzlichen Aktivitäten - + Clear the search or filter. Setze die Suche oder den Filter zurück. - + Shared files, clips, links, and photos appear here. Geteilte Dateien, Zwischenablage-Einträge, Links und Fotos erscheinen hier. - + Open Öffnen - + Show in folder Im Ordner anzeigen - + Copy Kopieren - + Send again Erneut senden - + Remove from timeline Aus Zeitverlauf entfernen - + Load more Mehr laden @@ -8253,137 +8495,137 @@ Voraussetzungen TransferItem - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + %1 wants to send this · %2 %1 möchte Folgendes senden · %2 - + Nearby device Gerät in der Nähe - + Paused; continues when %1 is back Pausiert; wird fortgesetzt, sobald %1 wieder da ist - + Waiting for %1 Wartet auf %1 - + %1 of %2 · %3/s %1 von %2 · %3/s - + %1 of %2 %1 von %2 - + Paused · %1 of %2 Pausiert · %1 von %2 - + From %1 · %2 Von %1 · %2 - + Sent to %1 · %2 An %1 gesendet · %2 - + Cancelled Abgebrochen - - %1 doesn't accept files from this PC + + %1 doesn't accept files from this PC %1 nimmt keine Dateien von diesem PC an - - Couldn't reach %1 + + Couldn't reach %1 %1 konnte nicht erreicht werden - + Not enough space on %1 Nicht genügend Speicherplatz auf %1 - + Integrity check failed · partial file kept to resume Integritätsprüfung fehlgeschlagen · Teildatei zum Fortsetzen behalten - - Didn't go through + + Didn't go through Fehlgeschlagen - + Accept Annehmen - + Decline Ablehnen - + Open Öffnen - + Show in folder Im Ordner anzeigen - + Pause Pause - + Resume Fortsetzen - + Cancel Abbrechen @@ -8391,105 +8633,105 @@ Voraussetzungen Transfers - + Accept in NectarLink to save to Downloads\Nectarlink In NectarLink annehmen, um unter Downloads\Nectarlink zu speichern - + Files are turned off for that device. Dateien sind für dieses Gerät deaktiviert. - + From %1, opened from Downloads\Nectarlink Von %1, geöffnet aus Downloads\Nectarlink - + From %1, saved in Downloads\Nectarlink Von %1, gespeichert unter Downloads\Nectarlink - + Nearby device Gerät in der Nähe - - That's too many files to send at once. + + That's too many files to send at once. Das sind zu viele Dateien auf einmal. - - There's nothing to send in there. + + There's nothing to send in there. Dort gibt es nichts zu senden. Updater - - Couldn't check for updates. Check your internet connection. + + Couldn't check for updates. Check your internet connection. Suche nach Updates fehlgeschlagen. Prüfe deine Internetverbindung. - + NectarLink %1 is available NectarLink %1 ist verfügbar - + NectarLink %1 is available. NectarLink %1 ist verfügbar. - + NectarLink is still on the version you had. Try again from Settings. NectarLink befindet sich noch auf der bisherigen Version. Versuche es über die Einstellungen erneut. - + NectarLink is up to date. NectarLink ist auf dem neuesten Stand. - + NectarLink is updated to %1 NectarLink wurde auf %1 aktualisiert - - The download was damaged, so it wasn't installed. Try again. + + The download was damaged, so it wasn't installed. Try again. Der Download war beschädigt und wurde daher nicht installiert. Versuche es erneut. - - The update couldn't be downloaded. Check your internet connection and try again. + + The update couldn't be downloaded. Check your internet connection and try again. Das Update konnte nicht heruntergeladen werden. Prüfe deine Internetverbindung und versuche es erneut. - - The update couldn't be saved. Check that there's free space and try again. + + The update couldn't be saved. Check that there's free space and try again. Das Update konnte nicht gespeichert werden. Prüfe, ob genügend freier Speicherplatz vorhanden ist, und versuche es erneut. - - The update couldn't be started. Try again. + + The update couldn't be started. Try again. Das Update konnte nicht gestartet werden. Versuche es erneut. - - The update didn't install + + The update didn't install Das Update wurde nicht installiert - + Update now, or later from Settings. Jetzt oder später über die Einstellungen aktualisieren. - + Your phone reconnects on its own. Dein Gerät verbindet sich automatisch wieder. @@ -8497,191 +8739,202 @@ Voraussetzungen WalkthroughPage - + Skip setup Einrichtung überspringen - + Notifications and Calls Benachrichtigungen und Anrufe - + Clipboard and Send files Zwischenablage und Dateien senden - + Mirroring, Camera, and Audio Bildschirmspiegelung, Kamera und Audio - + Direct local QUIC link Direkte lokale QUIC-Verbindung - + End-to-end encrypted over QUIC Ende-zu-Ende-verschlüsselt über QUIC - + Hardware-backed device keys Hardwaregestützte Geräteschlüssel - + Local network by default Standardmäßig im lokalen Netzwerk - + Option 1: Download the Android APK (Android 8.0+) Option 1: Android-APK herunterladen (Android 8.0+) - + Open the latest GitHub Release on your phone or copy the release link. Öffne das neueste GitHub-Release auf deinem Gerät oder kopiere den Release-Link. - + Open release page Release-Seite öffnen - + Option 2: Install over USB with ADB Option 2: Über USB mit ADB installieren - + Paired with %1. You can continue to the next step. Kopplung für %1 abgeschlossen. Du kannst mit dem nächsten Schritt fortfahren. - - + + your phone dein Gerät - - + + On by default Standardmäßig aktiviert - - + + Off until you turn them on Deaktiviert, bis du sie einschaltest - + Change these per phone on Home after pairing. Du kannst diese nach dem Koppeln pro Gerät auf der Startseite anpassen. - + Sharing settings for %1 Freigabeeinstellungen für %1 - + Notifications Benachrichtigungen - + Clipboard Zwischenablage - + Send files Dateien senden - + Now playing Aktuelle Wiedergabe - + Messages Nachrichten - + Calls Anrufe - + Screen mirroring Bildschirmspiegelung - + Remote input Remote-Eingabe - + Share PC folders PC-Ordner freigeben - + Windows notifications Windows-Benachrichtigungen - + + Desktop notifications + Desktop-Benachrichtigungen + + + Windows Settings Windows-Einstellungen - + Recheck Erneut prüfen - - + + Start with Windows Mit Windows starten - + + + Start at login + Bei der Anmeldung starten + + + Keep NectarLink ready in the system tray when you sign in. NectarLink beim Anmelden im Infobereich bereithalten. - + Firewall and network check Firewall- und Netzwerkprüfung - + Command Palette (Ctrl+K) Befehlspalette (Strg+K) - + Features guide Funktionsübersicht - + Back Zurück - + Open features guide Funktionsübersicht öffnen @@ -8689,12 +8942,12 @@ Voraussetzungen Webcam - + Starting camera on %1… Kamera auf %1 wird gestartet… - + Streaming %1 × %2 · %3 fps from %4 Streamt %1 × %2 · %3 FPS von %4 @@ -8702,22 +8955,22 @@ Voraussetzungen WelcomePage - + Welcome to NectarLink Willkommen bei NectarLink - - NectarLink couldn't start. + + NectarLink couldn't start. NectarLink konnte nicht gestartet werden. - + Your phone and this PC, together. Pair once and they find each other from then on. Dein Gerät und dieser PC – vereint. Einmal koppeln, und sie finden sich von da an automatisch. - + Open the logs folder Protokollordner öffnen @@ -8725,17 +8978,17 @@ Voraussetzungen Widget - + No phone connected Kein Gerät verbunden - + No recent clipboard items Keine aktuellen Einträge in der Zwischenablage - + Not available Nicht verfügbar diff --git a/desktop/app/i18n/nectarlink_zh_CN.ts b/desktop/app/i18n/nectarlink_zh_CN.ts index 5b130adc..4f7559fd 100644 --- a/desktop/app/i18n/nectarlink_zh_CN.ts +++ b/desktop/app/i18n/nectarlink_zh_CN.ts @@ -177,57 +177,62 @@ 配对手机后,即可在本机的窗口中打开其应用。 - + + Opening apps in windows isn't supported on this phone. + 此手机不支持在窗口中打开应用。 + + + Set up Wireless debugging in NectarLink on the phone to open its apps here. 在手机上的 NectarLink 中设置无线调试,即可在此打开其应用。 - + Search apps 搜索应用 - + Search %1's apps 搜索 %1 的应用 - + Refresh 刷新 - + No apps match. 没有匹配的应用。 - + No apps to open. 没有可打开的应用。 - + Getting the phone's apps… 正在获取手机应用… - + Try again 重试 - + Recent 最近 - + All apps 全部应用 - + %1, open %1,打开 @@ -430,277 +435,277 @@ - + Yesterday 昨天 - + %1s %1秒 - + Call from your PC 从电脑拨打电话 - - - + + + %1 isn't connected %1未连接 - + Pair your phone to view calls and contacts. 配对手机以查看通话和联系人。 - + Connect %1 to view calls and contacts. 连接%1以查看通话和联系人。 - + Recent 最近 - - + + Contacts 联系人 - + Refresh 刷新 - - + + Search calls 搜索通话 - - + + Search contacts 搜索联系人 - + Clear search 清除搜索 - + Calls are off for %1 %1的通话功能已关闭 - + Allow call log on %1 在%1上允许通话记录 - + Couldn't load recent calls 无法加载最近通话 - + Enable Calls in Settings. 请在设置中启用通话。 - + Grant call log permission on %1. 请在%1上授予通话记录权限。 - + Connect %1 to view recent calls. 连接%1以查看最近通话。 - + Unable to load call log. 无法加载通话记录。 - - + + Try again 重试 - + No calls match. 没有匹配的通话。 - + No recent calls. 暂无最近通话。 - + Contacts are off for %1 %1的联系人功能已关闭 - + Allow contacts on %1 在%1上允许联系人 - + Couldn't load contacts 无法加载联系人 - + Enable Contacts in Settings. 请在设置中启用联系人。 - + Grant contacts permission on %1. 请在%1上授予联系人权限。 - + Connect %1 to view contacts. 连接%1以查看联系人。 - + Unable to load contacts. 无法加载联系人。 - + No contacts match. 没有匹配的联系人。 - + No contacts on this phone. 此手机上没有联系人。 - + Close contact 关闭联系人 - + Copy number 复制号码 - - - + + + Text %1 给%1发短信 - - - + + + Call %1 呼叫%1 - + Phone number to call 要拨打的电话号码 - + Enter a number 输入号码 - + Backspace 退格 - + Text 短信 - + Calling… 正在呼叫… - + Call 呼叫 - + Clear number 清除号码 - + PC call audio connected 电脑通话音频已连接 - + PC call audio ready 电脑通话音频已就绪 - + PC call audio (Bluetooth) 电脑通话音频(蓝牙) - + Connect PC audio 连接电脑音频 - + Open Bluetooth settings 打开蓝牙设置 - + Missed 未接 - + Outgoing 呼出 - + Declined 已拒接 - + Incoming 来电 - + Favorites 收藏 - + +%1 more 另有%1项 @@ -708,22 +713,22 @@ CaptionButtons - + Minimize 最小化 - + Restore 还原 - + Maximize 最大化 - + Close 关闭 @@ -1404,51 +1409,56 @@ + Enter an application path (/usr/bin/..., .desktop, or .AppImage). + 输入应用程序路径(/usr/bin/...、.desktop 或 .AppImage)。 + + + Enter an http:// or https:// web address. 请输入以 http:// 或 https:// 开头的网址。 - + Enter the text snippet to type. 请输入要键入的文本片段。 - + Invalid action configuration. 操作设置无效。 - + Invalid tile properties. 磁贴属性无效。 - + Media key 媒体键 - + Page not found. 未找到页面。 - + This page already has the maximum number of tiles (24). 此页面的磁贴已达上限(24 个)。 - + Tile not found. 未找到磁贴。 - + Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.). 请使用单个键(a-z、0-9、f1-f12、enter、tab、space、escape、方向键等)。 - + Web address must start with http:// or https://. 网址必须以 http:// 或 https:// 开头。 @@ -1525,6 +1535,11 @@ Show desktop (Win+D) 显示桌面 (Win+D) + + + Show desktop (Super+D) + 显示桌面 (Super+D) + Switch window (Alt+Tab) @@ -1535,6 +1550,11 @@ Screenshot (Win+Shift+S) 截图 (Win+Shift+S) + + + Screenshot + 截图 + Lock PC @@ -1728,6 +1748,11 @@ Win Win + + + Super + Super + Key (e.g. c, v, z, f5, tab, enter, space, escape, up, down): @@ -1840,212 +1865,332 @@ Doctor - + %1 Open NectarLink on the phone and keep both on the same network. If the phone asks to run in the background, allow it. %1 在手机上打开 NectarLink,并让两台设备处于同一网络。如果手机询问是否允许在后台运行,请允许。 - + %1 days ago %1 天前 - + %1 hours ago %1 小时前 - + %1 is connected %1 已连接 - + %1 is on this Wi-Fi, but the router blocks direct connections (AP isolation) %1 在此 Wi‑Fi 上,但路由器阻止了直接连接(AP 隔离) - + %1 is set as a public network %1 被设为公用网络 - + %1 isn't connected %1 未连接 - + %1 minutes ago %1 分钟前 - + %1 · %2 ms %1 · %2 毫秒 - + A VPN is on VPN 已开启 - + + A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect. + 一条防火墙规则阻止了传入的 NectarLink 连接。允许后,手机即可连接。 + + + + Calls and alerts can't play sound yet + 通话和提醒还无法播放声音 + + + Connect it to the same Wi-Fi or wired network as your phone. 请将它连接到与手机相同的 Wi‑Fi 或有线网络。 - + Connected to %1 已连接到 %1 - + Connected to a network 已连接到网络 - + Connecting to %1… 正在连接 %1… - + + GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions. + GNOME 只有安装 AppIndicator 扩展后才会在那里显示应用图标。请安装它(gnome-shell-extension-appindicator),并在“扩展”中启用。 + + + + Install fuse3 to browse your phone's files in your file manager. + 安装 fuse3,即可在文件管理器中浏览手机上的文件。 + + + + Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing. + 安装 gstreamer1.0-pipewire 和 gstreamer1.0-tools;如果缺少桌面门户(xdg-desktop-portal),也请一并安装。 + + + + Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC. + 安装 pipewire-pulse(或 pulseaudio-utils),用于通话声音和查找电脑的提示音。 + + + + Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera. + 安装 v4l2loopback-dkms 并重启电脑,应用即可把手机用作摄像头。 + + + It hasn't connected yet. 它还没有连接过。 - + Keep %1 reachable in the background (%2) 让 %1 在后台保持可连接(%2) - + Last seen %1. 上次在线:%1。 - + + NectarLink can't show its icon in the top bar + NectarLink 无法在顶栏中显示图标 + + + + NectarLink is in the top bar + NectarLink 已显示在顶栏中 + + + NectarLink isn't allowed through Windows Firewall Windows 防火墙未允许 NectarLink - + + NectarLink isn't allowed through the firewall + 防火墙未允许 NectarLink + + + On Huawei / Honor: open Settings → Battery → App launch, turn off Manage automatically for NectarLink, and keep Auto-launch, Secondary launch, and Run in background on. 华为 / 荣耀:打开 设置 → 电池 → 应用启动管理,为 NectarLink 关闭“自动管理”,并保持“允许自启动”“允许关联启动”和“允许后台活动”开启。 - + On OnePlus / OxygenOS: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. 一加 / OxygenOS:打开 设置 → 应用 → 应用管理 → NectarLink → 耗电管理,开启“允许后台活动”和“允许自启动”。 - + On Oppo / Realme: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. OPPO / 真我:打开 设置 → 应用 → 应用管理 → NectarLink → 耗电管理,开启“允许后台活动”和“允许自启动”。 - + On Pixel: open Settings → Apps → See all apps → NectarLink → App battery usage, tap Allow background usage, and choose Unrestricted. Pixel:打开 设置 → 应用 → 查看所有应用 → NectarLink → 应用的电池用量,点按“允许后台使用”,然后选择“无限制”。 - + On Samsung: open Settings → Apps → NectarLink → Battery and choose Unrestricted, then in Device care → Battery → Background usage limits make sure NectarLink is never put to sleep. 三星:打开 设置 → 应用程序 → NectarLink → 电池,选择“不受限制”,然后在 设备维护 → 电池 → 后台使用限制 中确保 NectarLink 不会被设为休眠。 - + On USB 通过 USB - + On Vivo / OriginOS / Funtouch: open Settings → Battery → Background power consumption management → NectarLink and choose Unrestricted, and allow Autostart. vivo / OriginOS / Funtouch:打开 设置 → 电池 → 后台耗电管理 → NectarLink,选择“允许后台高耗电”,并允许自启动。 - + On Xiaomi / HyperOS / MIUI: open Settings → Apps → Manage apps → NectarLink, turn on Autostart, and set Battery saver to No restrictions. 小米 / HyperOS / MIUI:打开 设置 → 应用设置 → 应用管理 → NectarLink,开启“自启动”,并将省电策略设为“无限制”。 - + On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private. 在公用网络上,Windows 会阻止其他设备发现此电脑。如果这是你的家庭或工作网络,请将其设为专用网络。 - + + On public networks the firewall keeps other devices from finding this PC. If it's your home or work network, open network settings and set its firewall zone to Home or Trusted. + 在公用网络上,防火墙会阻止其他设备发现此电脑。如果这是你的家庭或工作网络,请打开网络设置,将其防火墙区域设为“Home”或“Trusted”。 + + + On this network 在此网络上 - + + Phone as webcam is ready + “手机作为摄像头”已就绪 + + + + Phone as webcam needs one more step + “手机作为摄像头”还差一步 + + + + Phone storage can open in your file manager + 手机存储可以在文件管理器中打开 + + + + Phone storage needs one more step + 手机存储还差一步 + + + + Showing this PC's screen needs one more step + 显示此电脑的屏幕还差一步 + + + Someone chose not to allow it when Windows asked. Allow it, and your phone can connect. Windows 询问时有人选择了不允许。允许后,手机即可连接。 - + + Sound is ready + 声音已就绪 + + + + The firewall blocks NectarLink + 防火墙阻止了 NectarLink + + + + The firewall is off for this network + 此网络的防火墙已关闭 + + + + The firewall lets your phone connect + 防火墙允许手机连接 + + + + The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput. + 手机上的触控板和键盘需要桌面门户(xdg-desktop-portal)或 /dev/uinput 的访问权限。 + + + This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable. 此电脑能看到 %1 在网络上发出的广播,但路由器阻止了设备之间直接通信(通常称为 AP 隔离、客户端隔离或访客 Wi‑Fi)。请让两台设备离开访客 Wi‑Fi,在路由器的 Wi‑Fi 设置中关闭 AP / 客户端隔离,或改用 USB 数据线连接。 - + This PC isn't on a network 此电脑未连接网络 - + This network 此网络 - + This usually takes a few seconds. 通常只需几秒钟。 - + VPNs often keep devices on your network from reaching this PC. If your phone can't connect, try turning it off. VPN 常会阻止同一网络上的设备访问此电脑。如果手机无法连接,请尝试关闭 VPN。 - + While away 离开时 - + Windows Firewall blocks NectarLink Windows 防火墙阻止了 NectarLink - + Windows Firewall is off for this network 此网络的 Windows 防火墙已关闭 - + Windows Firewall lets your phone connect Windows 防火墙允许手机连接 - + + Your phone can control this PC + 手机可以控制此电脑 + + + + Your phone can show this PC's screen + 手机可以显示此电脑的屏幕 + + + + Your phone can't control this PC yet + 手机还无法控制此电脑 + + + Your phone may not be able to connect. Allow it once here. 手机可能无法连接。在这里允许一次即可。 - + a minute ago 1 分钟前 - + an hour ago 1 小时前 - + just now 刚刚 @@ -2053,52 +2198,57 @@ DoctorSheet - + Allow 允许 - + Open settings 打开设置 - + Reconnect 重新连接 - + Connection Doctor 连接诊断 - + Checking network and firewall… 正在检查网络和防火墙… - + Everything looks right. 一切正常。 - + + Your phone can reach this PC. A few features need a step from you: + 手机可以连接到此电脑。有几项功能还需要你操作一步: + + + Issues blocking your phone from reaching this PC: 阻止手机连接到本机的问题: - + Firewall settings 防火墙设置 - + Check again 再次检查 - + Done 完成 @@ -2106,32 +2256,32 @@ Errors - + Something went wrong: %1 出现问题:%1 - + That device isn't paired anymore. 该设备已不再配对。 - + The device declined. 设备已拒绝。 - + The device didn't answer in time. 设备未及时响应。 - + The device isn't connected right now. 设备当前未连接。 - + The device's app doesn't support that yet. 设备上的应用尚不支持此功能。 @@ -2340,12 +2490,12 @@ FolderSync - + Folders are up to date. 文件夹已是最新。 - + Synced %1 file(s). 已同步 %1 个文件。 @@ -2353,85 +2503,85 @@ Guide - - 1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer). + + 1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer). 2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable. 3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail. 4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens. 5. Choose which features to share for that phone and grant only the Android permissions you want to use. - 1. 在 Windows 11 电脑和 Android 手机(Android 8.0 或更高版本)上安装 NectarLink。 + 1. 在电脑(Windows 11 或 Linux)和 Android 手机(Android 8.0 或更高版本)上安装 NectarLink。 2. 让两台设备连接到同一个 Wi‑Fi 网络,或用 USB 数据线将手机连接到电脑。 3. 在设置向导中打开“配对”步骤,或点击左侧栏底部的 +(配对新设备)。 4. 用 NectarLink 安卓应用扫描二维码,或在附近设备列表中选择你的手机,并确认两边屏幕上的 6 位验证码一致。 5. 选择要为这部手机共享的功能,只授予你想使用的 Android 权限。 - + Android permissions and why each is optional Android 权限及为何每项都是可选的 - + Automations 自动化 - + Calls 通话 - + Clipboard 剪贴板 - + Command palette 命令面板 - + Connection Doctor 连接诊断 - + Continuity camera 接续相机 - + Deck 面板 - + Drop shelf 收纳架 - + Elevated and Assist modes 提升模式与辅助模式 - + Features guide 功能指南 - + Features reference 功能参考 - + Handoff 接力 - + If your phone and PC do not see each other: 1. Open Connection Doctor from Settings › Connection & About or the Command Palette. @@ -2446,17 +2596,17 @@ 4. 你也可以在开启 USB 调试后用 USB 数据线连接;NectarLink 会自动建立本地隧道。 - + Install and first-time setup 安装与首次设置 - + Local HTTP API 本地 HTTP API - + Local by default Your phone and PC talk directly over your local network or USB cable. When Connect over mobile data is on, encrypted traffic can use a relay when away from home. Automatic update checks contact GitHub Releases and can be turned off in Settings › Connection & About › Check for updates. @@ -2475,17 +2625,17 @@ Sensitive capabilities such as Share PC folders, Remote input, and Reverse PC sc “共享电脑文件夹”“远程输入”和“反向电脑屏幕镜像”等敏感功能,对每台已配对设备默认关闭,直到你打开它们。 - + LocalSend LocalSend - + Messages 消息 - + Most NectarLink features work in Standard mode with regular Android permissions. Two optional modes unlock deeper system integration: Elevated mode (Wireless debugging or root) @@ -2508,12 +2658,12 @@ Assist mode (Android Accessibility Service) - NectarLink 绝不会通过无障碍服务读取屏幕文字或记录按键。 - + Nectar Island Nectar 岛 - + NectarLink links your Android phone and your PC directly on your local network or over a USB cable. How it works @@ -2538,7 +2688,7 @@ Android 上的功能层级 - 辅助模式(可选):仅在你开启“远程输入”时使用 Android 无障碍服务,让电脑的鼠标和键盘能够控制手机。 - + NectarLink never asks for every permission up front. Each feature works independently: - Notification Access: Required only if you want phone notifications, inline replies, and notification-backed chat threads on your PC. @@ -2555,97 +2705,97 @@ Android 上的功能层级 - 相机和麦克风:仅在将手机用作摄像头、拍摄接续相机照片或录制语音备忘时才需要。 - + NectarLink overview NectarLink 概览 - + Notifications 通知 - + Phone as speaker 手机作为扬声器 - + Phone as webcam 手机作为摄像头 - + Phone controls 手机控制 - + Photos 照片 - + Privacy and security 隐私与安全 - + Remote input 远程输入 - + Screen mirroring 屏幕镜像 - + Select any feature card in the Features guide to inspect its live status, jump directly to its control with Show me, or read how it works and what permissions it uses. 在功能指南中选择任意功能卡片,即可查看其实时状态,用“带我去”直接跳到对应的控制,或了解它的工作方式和所用的权限。 - + Send files 发送文件 - + Share PC folders 共享电脑文件夹 - + Share PC screen 共享电脑屏幕 - + Timeline 时间线 - + Troubleshooting and Connection Doctor 故障排除与连接诊断 - + Type on phone 在手机上输入 - + Voice recorder 录音机 - + Wake PC 唤醒电脑 - + What it does Browses recent photos, camera shots, and screenshots on your phone without copying your entire library first. You can view full-resolution images, copy them to the clipboard, save them to disk, or drag them directly into other Windows apps. @@ -2666,7 +2816,7 @@ What it needs - 在 Android 手机上授予照片和媒体权限。 - + What it does Controls your phone's Do Not Disturb mode, ringer mode (Sound, Vibrate, Silent), flashlight, media volume, screen brightness, Wi-Fi, Bluetooth, and Find My Phone ring from your PC. @@ -2687,7 +2837,7 @@ What it needs - 音量、手电筒和查找手机在标准模式下即可使用;勿扰模式需要“勿扰权限”;在较新的 Android 版本上,Wi‑Fi 和蓝牙开关需要提升模式。 - + What it does Discovers and exchanges files with nearby devices running LocalSend v2 on your local network without requiring pairing. @@ -2708,7 +2858,7 @@ What it needs - 在 设置 › 文件与同步 中开启“局域网内的 LocalSend”,并让两台设备处于同一本地网络。 - + What it does Displays a compact pill at the top center of your primary monitor for incoming calls, live file transfer progress, media playback, and optional notification previews. @@ -2727,7 +2877,7 @@ What it needs - 在 设置 › 通用 中开启 Nectar 岛,并选择要显示哪些事件。 - + What it does Exposes an opt-in localhost HTTP/JSON and Server-Sent Events API on 127.0.0.1 (default port 53320) protected by a bearer token so scripts, CLI tools, and Stream Deck plugins can trigger NectarLink actions. @@ -2746,7 +2896,7 @@ What it needs - 在 设置 › 连接与关于 中开启本地自动化 API,并从 api-token.json 读取持有者令牌。 - + What it does Forwards keystrokes and pasted text from your PC keyboard directly to your phone without needing to mirror the phone screen. @@ -2767,7 +2917,7 @@ What it needs - 手机上的辅助模式(无障碍服务)、NectarLink 输入法或提升模式。 - + What it does Inspects Windows Firewall rules, network profile type (Private vs Public), UDP discovery sockets, and paired phone reachability, and offers one-click fixes. @@ -2788,7 +2938,7 @@ What it needs - 在电脑上随时可用。 - + What it does Keeps selected folders on your phone (such as DCIM/Camera or Download) and folders on your PC in sync in one-way or two-way mode, and lets your phone browse shared PC folders. @@ -2809,7 +2959,7 @@ What it needs - 手机上的文件和媒体访问权限,并在设置中配置至少一条文件夹同步规则。 - + What it does Lets you read and send SMS and MMS conversations, plus reply to notification-backed chat threads from messaging apps such as WhatsApp, Signal, and Telegram. @@ -2832,7 +2982,7 @@ What it needs - 手机上的通知使用权(用于消息应用的对话)。 - + What it does Lets your paired phone wake this PC from sleep or soft power-off over your local network using a Wake-on-LAN packet. @@ -2853,7 +3003,7 @@ What it needs - 在 Windows 设备管理器和 BIOS/UEFI 中开启了网络唤醒的网络适配器(有线以太网最可靠)。 - + What it does Lets your phone act as a wireless trackpad, keyboard, presentation clicker, and on-screen laser pointer for your PC. @@ -2874,7 +3024,7 @@ What it needs - 在电脑上为该设备开启“远程鼠标、键盘与面板”开关(或在手机请求控制时通过一次性提示批准)。 - + What it does Mirrors your phone notifications to the Home feed and Windows notification pop-ups. You can reply inline to messages, click action buttons, dismiss individual notifications or groups, and undo a recent dismissal. @@ -2899,7 +3049,7 @@ What it needs - 在电脑上为该设备开启“通知”开关。 - + What it does Opens a keyboard-first search bar over actions, contacts, message threads, mirrorable apps, phone quick toggles, clipboard history, and navigation pages. @@ -2920,7 +3070,7 @@ What it needs - 随时可用,关闭时不占用后台内存。 - + What it does Opens a lightweight slide-out panel at the edge of your monitor showing your phone's latest photo, latest screenshot, recent clipboard items, and recent received files. Drag items out into any app, or drop files onto the Shelf to send them to your phone. @@ -2943,7 +3093,7 @@ What it needs - 已配对的手机;最新照片和截图卡片需要手机上的照片权限。 - + What it does Provides a unified, searchable chronological feed of files, photos, clipboard items, calls, messages, and voice recordings shared between your phone and PC. @@ -2964,7 +3114,7 @@ What it needs - 对所有已配对设备自动生效;保留时长可在 设置 › 存储与隐私 中调整。 - + What it does Receives voice notes recorded on your phone and saves them to your PC in your chosen audio format (M4A, MP3, WAV, or FLAC). @@ -2985,7 +3135,7 @@ What it needs - 录制语音备忘时需要手机上的麦克风权限。 - + What it does Runs automatic trigger-action rules such as locking your PC when your phone disconnects, pausing media when a call arrives, or running a command when your phone connects. @@ -3004,7 +3154,7 @@ What it needs - 在那里添加并开启一条或多条规则。 - + What it does Sends files, folders, links, and text between your phone and PC at local network speed. If your phone is offline when you send, NectarLink places the item in the Waiting to send queue and delivers it automatically as soon as your phone reconnects. @@ -3025,7 +3175,7 @@ What it needs - 已配对的手机,并为该设备开启“文件”开关。 - + What it does Sends web links, video links with their current playback timestamp, map coordinates or street addresses, and documents to your phone so they open immediately in the right app. @@ -3044,7 +3194,7 @@ What it needs - 已配对的手机;在线时立即发送,手机离线时自动排队。 - + What it does Shares copied text, rich HTML/RTF text, images, and small copied file sets between your PC and phone. Keeps an encrypted local history of up to 50 items with pinning and smart context chips for links, phone numbers, addresses, and tracking codes. @@ -3067,7 +3217,7 @@ What it needs - 在 Android 10 及以上版本上,手机到电脑的后台剪贴板同步通过通知、NectarLink 快捷设置图块或提升模式实现。 - + What it does Shows incoming and active phone calls on your PC with Answer, Decline, and Reject with SMS actions, plus browsable call history, contacts, and a dialpad. @@ -3090,7 +3240,7 @@ What it needs - 通话音频仍在手机上(或与手机配对的耳机上);在 Android 12 及以上版本,提升模式可解锁静音、免提、保持和拨号键盘控制。 - + What it does Streams your PC system audio to your connected phone in real time over the encrypted connection so you can listen through your phone speakers or headphones. @@ -3111,7 +3261,7 @@ What it needs - 通过本地 Wi‑Fi 或 USB 连接的已配对手机。 - + What it does Streams your PC's screen to your phone so you can view your desktop from across the room and use your phone as a trackpad or laser pointer. @@ -3132,7 +3282,7 @@ What it needs - 在电脑上为该设备开启“将电脑屏幕共享到手机”开关,或在手机请求查看屏幕时一次性批准。 - + What it does Streams your phone camera into the NectarLink Virtual Camera on Windows so Zoom, Teams, Meet, Discord, and OBS can use your phone's rear or front camera at up to 4K. @@ -3155,7 +3305,7 @@ What it needs - 让其他应用看到它:设置 › 摄像头与音频 中的虚拟摄像头附加组件。 - + What it does Streams your phone screen into a low-latency desktop window with hardware-accelerated decoding, or opens individual Android apps in their own windows on your PC. @@ -3180,7 +3330,7 @@ What it needs - 辅助模式或提升模式让你可以在镜像窗口中点击、滚动和输入。 - + What it does Syncs media playback between your phone and PC, switches Windows audio output and microphone devices, and turns your phone into a customizable grid of shortcut tiles, macros, and volume sliders. @@ -3199,7 +3349,7 @@ What it needs - 通过任意活动连接相连的已配对手机。 - + What it does Triggers your phone camera from any Windows app to snap a photo or scan a document, then copies the result to the Windows clipboard and pastes it into the active window. @@ -3438,8 +3588,8 @@ What it needs - - + + Wi‑Fi Wi‑Fi @@ -3490,7 +3640,7 @@ What it needs - + Send files 发送文件 @@ -3506,7 +3656,7 @@ What it needs - + Clipboard history 剪贴板历史记录 @@ -3597,14 +3747,14 @@ What it needs - - + + Cancel 取消 - + Timeline 时间线 @@ -3654,448 +3804,458 @@ What it needs 连接 %1 以查看通知 - + + Desktop notifications are off. Turn them on in your system's notification settings. + 桌面通知已关闭。请在系统的通知设置中开启。 + + + Windows notifications are off. Windows 通知已关闭。 - + Turn on 开启 - + Webcam 摄像头 - + Streaming from %1 正在从 %1 传输画面 - - + + Stop 停止 - + Back 后置 - + Front 前置 - + Mirror 镜像 - + Phone controls 手机控制 - + Less 收起 - + Sound & display 声音与显示 - + DND 勿扰 - + Do Not Disturb 勿扰模式 - + Torch 手电筒 - + Flashlight 手电筒 - + BT 蓝牙 - + Bluetooth 蓝牙 - + Hotspot 热点 - + Instant Hotspot 即时热点 - + Phone as speaker 手机作为扬声器 - + Streaming PC audio 正在传输电脑音频 - + Play on phone 在手机上播放 - + Watching on %1 正在 %1 上观看 - + Tap the notification on %1 轻触 %1 上的通知 - + Play a video on this PC first 请先在此电脑上播放视频 - + Turning off Wi‑Fi may disconnect %1. 关闭 Wi‑Fi 可能会断开 %1。 - + Turn off 关闭 - + Ring 响铃 - + Vibrate 振动 - + Silent 静音 - + Volume 音量 - + Brightness 亮度 - + Continuity Camera 接续相机 - + Scan document 扫描文档 - + Phone as webcam 手机作为摄像头 - + Streaming 正在传输 - + Type on phone 在手机上输入 - + Sent: %1 已发送:%1 - + Listening for keys 正在监听按键 - - + + When connected 连接后 - + Start 开始 - - + + Clipboard 剪贴板 - + Type here: keys go to %1 在此输入:按键将发送到 %1 - + Click to focus keyboard 点击以聚焦键盘 - + Ctrl+V pastes · Esc stops Ctrl+V 粘贴 · Esc 停止 - + Paste 粘贴 - + View all 查看全部 - + No recent activity 没有最近活动 - + %1 notifications %1 条通知 - + Show 显示 - + No pop-ups 不弹出提示 - + Hide 隐藏 - + Hidden on this PC. 已在本机隐藏。 - + Shown in feed without pop-ups. 在信息流中显示,但不弹出提示。 - + + Shown in feed and as desktop pop-ups. + 在信息流中显示,并作为桌面弹窗提示。 + + + Shown in feed and as Windows pop-ups. 在信息流中显示,并作为 Windows 弹窗提示。 - - - - + + + + Done 完成 - + Notification history 通知历史记录 - - + + Clear history 清除历史记录 - + Dismissed notifications from the last 24 hours. 最近 24 小时内已忽略的通知。 - + No recent notifications. 没有最近的通知。 - - + + Search notification history 搜索通知历史记录 - - + + Nothing matches “%1”. 没有匹配“%1”的内容。 - + Last 50 shared clips. 最近 50 条共享剪贴内容。 - + No shared clips yet. 还没有共享的剪贴内容。 - - + + Search clipboard history 搜索剪贴板历史记录 - + From %1 来自 %1 - + From phone 来自手机 - + Sent to %1 已发送到 %1 - + From this PC 来自本机 - + Image 图片 - + Pinned 已固定 - + Open on phone 在手机上打开 - + Send to phone 发送到手机 - + Copy 复制 - + Unpin 取消固定 - + Pin 固定 - + Delete 删除 - - + + %1% %1% - + LocalSend nearby · %1 LocalSend 附近 · %1 - + LocalSend nearby LocalSend 附近 - + Refresh 刷新 - + Listening for LocalSend devices on your Wi‑Fi network. 正在您的 Wi‑Fi 网络上监听 LocalSend 设备。 - + LocalSend port 53317 is busy on this PC (another LocalSend app may be running). 本机的 LocalSend 端口 53317 已被占用(可能正在运行另一个 LocalSend 应用)。 - + Nearby device 附近设备 - + Image preview 图片预览 - + Close 关闭 - + Open externally 用外部程序打开 @@ -4103,17 +4263,17 @@ What it needs Hotspot - + %1's hotspot didn't show up. Check that it's on, then pick %2 in Windows' Wi-Fi list. 没有找到 %1 的热点。请确认热点已开启,然后在 Windows 的 Wi-Fi 列表中选择 %2。 - + Connected to %1's hotspot. 已连接到 %1 的热点。 - + Couldn't join %1's hotspot. Pick %2 in Windows' Wi-Fi list. 无法连接到 %1 的热点。请在 Windows 的 Wi-Fi 列表中选择 %2。 @@ -4216,62 +4376,62 @@ What it needs Links - + %1: tap the notification on your phone to continue watching. %1:轻触手机上的通知以继续观看。 - + %1: tap the notification on your phone to open in Maps. %1:轻触手机上的通知以在地图中打开。 - + Copy a link or address first 请先复制链接或地址 - + Copy a web link (https://…), geo: URI, or street address, then choose this again. 复制网页链接(https://…)、geo: URI 或街道地址,然后再次选择此项。 - + Copy a web link, video URL, or street address, then choose this again to open it on your phone. 复制网页链接、视频网址或街道地址,然后再次选择此项,即可在手机上打开。 - + Couldn't send it 无法发送 - + No phone to open it on 没有可打开它的手机 - + Pair a phone first, or enable opening links on your phone. 请先配对手机,或在手机上启用打开链接。 - + Sent to %1 已发送到 %1 - + Tap the notification on your phone to open it. 轻触手机上的通知以打开。 - + That's not a link or address 这不是链接或地址 - + Update NectarLink on %1. 请更新 %1 上的 NectarLink。 @@ -4387,177 +4547,177 @@ What it needs MainWindow - - + + Home 首页 - - + + Messages 消息 - - + + Calls 通话 - - + + Photos 照片 - - + + Apps 应用 - - + + Deck 面板 - + Guide 指南 - - + + Settings 设置 - + Pair a new device 配对新的设备 - + Back to Home 返回首页 - - + + Timeline 时间线 - + Features guide 功能指南 - + Sharing screen with %1 正在与 %1 共享屏幕 - + Sharing screen 正在共享屏幕 - + Stop 停止 - + Stop sharing this PC's screen 停止共享本机屏幕 - + Commands 命令 - + Command palette (%1) 命令面板(%1) - + Command palette (Ctrl+K) 命令面板(Ctrl+K) - + Files, clips, links, photos, recordings and sessions 文件、剪贴内容、链接、照片、录制和会话 - + Refresh 刷新 - + %1 is ringing this PC %1 正在让本机响铃 - + Stop ringing 停止响铃 - + Allow %1 to control this PC? 允许 %1 控制本机吗? - + %1 wants to use this PC's mouse and keyboard. You can change this in Settings. %1 想要使用本机的鼠标和键盘。你可以在设置中更改。 - - + + Not now 暂不 - - + + Allow 允许 - + Allow %1 to view this PC's screen? 允许 %1 查看本机屏幕吗? - + %1 wants to view this PC's screen. Control needs Remote mouse, keyboard & Deck in Settings. %1 想要查看本机屏幕。如需控制,请在设置中开启远程鼠标、键盘和面板。 - + Allow for this device 允许此设备 - + Photo ready on clipboard 照片已复制到剪贴板 - + Keep on clipboard 保留在剪贴板 - + Paste into active window 粘贴到当前窗口 @@ -4565,17 +4725,17 @@ What it needs Media - + Media is turned off for that device. 已为该设备关闭媒体。 - + That player can't do that. 该播放器无法执行此操作。 - + That player isn't playing anymore. 该播放器已不再播放。 @@ -4583,17 +4743,17 @@ What it needs Messages - + Attachment is %1 (MMS supports up to 900 KB) 附件大小为 %1(彩信最多支持 900 KB) - + Group texts can't be sent from the PC yet. 暂不支持从电脑发送群组短信。 - + Only image or voice-note audio files can be attached. 只能附加图片或语音备忘音频文件。 @@ -4627,286 +4787,286 @@ What it needs - + Yesterday 昨天 - + Text from your PC 从电脑发短信 - + %1 isn't connected %1未连接 - + Messages are off for %1 %1的消息功能已关闭 - + Allow texting on %1 在%1上允许收发短信 - + Couldn't load messages 无法加载消息 - + Pair your phone to read and send texts. 配对手机以阅读和发送短信。 - + Connect %1 to view messages. 连接%1以查看消息。 - + Enable Messages in Settings. 请在设置中启用消息。 - + Grant SMS permission on %1. 请在%1上授予短信权限。 - + Unable to load messages. 无法加载消息。 - + Try again 重试 - + Conversations 会话 - + New message (Ctrl+N) 新建消息 (Ctrl+N) - + Refresh 刷新 - + Search conversations 搜索会话 - + Search 搜索 - + Clear search 清除搜索 - + No conversations match. 没有匹配的会话。 - + No conversations yet. 暂无会话。 - + Drop image to attach (MMS) 拖入图片以添加 (MMS) - + PNG, JPEG, WebP, GIF, or BMP up to 900 KB PNG、JPEG、WebP、GIF 或 BMP,最大 900 KB - + Select a conversation 选择一个会话 - + Back to conversations (Esc) 返回会话列表 (Esc) - + Call %1 呼叫%1 - + Call 呼叫 - + Remove conversation from PC 从电脑移除会话 - + To 收件人 - - + + Name or phone number 姓名或电话号码 - + No mirrored messages yet 暂无同步的消息 - + No messages 暂无消息 - + %1 · Sent as MMS %1 · 以彩信发送 - + Remove attachment 移除附件 - + Attach image (MMS) 添加图片 (MMS) - + Reply in %1 在%1中回复 - - + + Text message 短信 - + Replies are available while the notification is active on your phone 手机上的通知处于活动状态时才能回复 - + Reply in %1… 在%1中回复… - + Group texts can't be sent from the PC yet 目前无法从电脑发送群组短信 - + Add a caption (optional)… 添加说明文字(可选)… - + Send 发送 - + %1 characters · %n text(s) %1 个字符 · %n 条短信 - + Open image attachment 打开图片附件 - + Voice note 语音留言 - + · Click to play · 点击播放 - + · Loading… · 加载中… - + Attachment (open it on the phone) 附件(请在手机上打开) - + Not sent 未发送 - + Sending… 正在发送… - + Copy text 复制文本 - + Copied 已复制 - + Copy code %1 复制验证码 %1 - + Image attachment 图片附件 - + Close 关闭 - + Open externally 用外部程序打开 - + Done 完成 @@ -4914,102 +5074,117 @@ What it needs Mirror - + Couldn't start recording. 无法开始录制。 - + Mirror recording 镜像录制 - + Mirror screenshot 镜像截图 - + Mirroring is turned off for this phone. 已为此手机关闭镜像。 - + + Recording from %1, saved in Downloads/Nectarlink + 来自 %1 的录制,已保存到 Downloads/Nectarlink + + + Recording from %1, saved in Downloads\Nectarlink 来自 %1 的录制,已保存到 Downloads\Nectarlink - + Saved %1 已保存 %1 - + + Saved %1 to Downloads/Nectarlink. + 已将 %1 保存到 Downloads/Nectarlink。 + + + Saved %1 to Downloads\Nectarlink. 已将 %1 保存到 Downloads\Nectarlink。 - + Screenshot copied to clipboard. 截图已复制到剪贴板。 - + + Screenshot from %1, saved in Downloads/Nectarlink + 来自 %1 的截图,已保存到 Downloads/Nectarlink + + + Screenshot from %1, saved in Downloads\Nectarlink 来自 %1 的截图,已保存到 Downloads\Nectarlink - + Sending %1 files… 正在发送 %1 个文件… - + Sending %1… 正在发送 %1… - + Set up Wireless debugging in NectarLink on the phone to open its apps here. 在手机上的 NectarLink 中设置无线调试,即可在这里打开其应用。 - + Show in folder 在文件夹中显示 - + The app didn't open on the phone. Try again, or open NectarLink on the phone to check Wireless debugging. 应用未在手机上打开。请重试,或在手机上打开 NectarLink 检查无线调试。 - + The phone isn't connected. 手机未连接。 - + The screenshot couldn't be copied to the clipboard. 无法将截图复制到剪贴板。 - + The screenshot couldn't be saved. 无法保存截图。 - + This PC can't decode the phone's video. 此电脑无法解码手机的视频。 - + This phone's app doesn't share its screen yet. Update NectarLink on the phone. 此手机上的应用尚不能共享屏幕。请更新手机上的 NectarLink。 - + Wait for the screen to appear first. 请等待屏幕先显示出来。 @@ -5161,7 +5336,7 @@ What it needs Mp4 - + No video frames were recorded yet. 尚未录制任何视频帧。 @@ -5255,37 +5430,47 @@ What it needs Notifications - + Code copied 已复制验证码 - + Copy code 复制验证码 - + Dismissed %1 from %2 已清除 %2 的 %1 条通知 - + Dismissed %1 notifications 已清除 %1 条通知 - + Dismissed from %1 已清除来自 %1 的通知 - + Notification dismissed 已清除通知 - + + Open + 打开 + + + + Reply + 回复 + + + That notification is gone from the phone. 该通知已不在手机上。 @@ -5401,17 +5586,17 @@ What it needs Obs - + OBS isn't reachable on port %1. Turn on its WebSocket server. 无法通过端口 %1 连接 OBS。请开启其 WebSocket 服务器。 - + OBS: %1 OBS:%1 - + The service on that port isn't OBS. 该端口上的服务不是 OBS。 @@ -5582,7 +5767,7 @@ What it needs PcMirror - + Can't find the player's window. Play the video on this PC again, then turn on Play on phone. 找不到播放器的窗口。请在此电脑上重新播放视频,然后打开“在手机上播放”。 @@ -5590,17 +5775,17 @@ What it needs PhoneMic - + Tap Start on %1 to use its microphone. 在 %1 上轻触“开始”以使用其麦克风。 - + The microphone is turned off for this PC on %1. %1 上已为此电脑关闭麦克风。 - + Update NectarLink on %1 to use its microphone. 请更新 %1 上的 NectarLink 以使用其麦克风。 @@ -5608,57 +5793,57 @@ What it needs PhotoBackup - + Can't write to the backup folder. 无法写入备份文件夹。 - + Copying, %1 left 正在复制,还剩 %1 项 - + Looking for new photos… 正在查找新照片… - + Off 已关闭 - + Photos are turned off for this PC on %1. %1 上已为此电脑关闭照片。 - + The backup folder's drive is full. 备份文件夹所在的磁盘已满。 - + The backup stopped. It tries again in a few minutes. 备份已停止,几分钟后会再试。 - + Up to date 已是最新 - + Up to date, %1 backed up 已是最新,已备份 %1 项 - + Update NectarLink on %1 to back up its photos. 请在 %1 上更新 NectarLink 以备份其照片。 - + Waiting for %1 正在等待 %1 @@ -5666,67 +5851,67 @@ What it needs Photos - + Copied to clipboard. 已复制到剪贴板。 - + From %1 来自 %1 - + New photo 新照片 - + New screenshot 新截图 - + No recent photos found on the phone. 手机上没有最近的照片。 - + Opening latest photo… 正在打开最新照片… - + Saved %1 items to %2. 已将 %1 个项目保存到 %2。 - + Saved %1 to %2. 已将 %1 保存到 %2。 - + That photo isn't on the phone anymore. 该照片已不在手机上。 - + The phone doesn't share photos or files with this PC. 手机未与此电脑共享照片或文件。 - + The phone isn't connected right now. 手机当前未连接。 - + The photo couldn't be copied to the clipboard. 无法将照片复制到剪贴板。 - + The photo couldn't be copied. It's in Downloads\Nectarlink. 无法复制照片。它在 Downloads\Nectarlink 中。 @@ -5735,7 +5920,7 @@ What it needs PhotosPage - + All photos 所有照片 @@ -5746,259 +5931,259 @@ What it needs - + Today 今天 - + Yesterday 昨天 - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Choose default folder for saved photos 选择保存照片的默认文件夹 - + Save to folder 保存到文件夹 - + Photos 照片 - - + + %1 isn't connected %1 未连接 - + Pair your phone to browse photos and albums. 配对手机即可浏览照片和相册。 - - + + Connect %1 to view photos. 连接 %1 即可查看照片。 - + Albums 相册 - + Refresh 刷新 - + %1+ items %1+ 个项目 - + 1 item 1 个项目 - + %1 items %1 个项目 - + Cancel 取消 - + Photo 照片 - + Take photo 拍照 - + Scan 扫描 - + Scan document 扫描文档 - + Select 选择 - + Cancel selection 取消选择 - + %1 selected 已选择 %1 个 - + Select items 选择项目 - + Click items to select 点击项目以选择 - + Select all 全选 - - + + Copy 复制 - + Open 打开 - + Save (%1) 保存(%1) - - + + Save 保存 - - + + Save to folder… 保存到文件夹… - + Photos are off for %1 %1 的照片功能已关闭 - + Allow photos on %1 允许在 %1 上使用照片 - + Couldn't load photos 无法加载照片 - + Enable Photos in Settings. 请在设置中启用照片。 - + Grant photos permission on %1. 请在 %1 上授予照片权限。 - + Unable to load photos. 无法加载照片。 - + Try again 重试 - + No photos or videos 没有照片或视频 - + Back to gallery 返回图库 - - + + Play video 播放视频 - + Open in Photos 在照片中打开 - + Loading full resolution… 正在加载完整分辨率… - + Previous photo 上一张照片 - + Next photo 下一张照片 - + %1 %1 - + Select photo 选择照片 @@ -6006,32 +6191,32 @@ What it needs PlayOnPhone - + Connect your phone to play on it. 请连接手机后再在其上播放。 - + Media is turned off for %1. 已为 %1 关闭媒体。 - + Opening in %1 on %2. Tap the notification there. 正在 %2 上用 %1 打开。请轻触那里的通知。 - + Sent to %1. Tap the notification there. 已发送到 %1。请轻触那里的通知。 - + Tap the notification on %1 to watch. 轻触 %1 上的通知即可观看。 - + Update NectarLink on %1 to play on it. 请更新 %1 上的 NectarLink 以在其上播放。 @@ -6039,7 +6224,7 @@ What it needs Prefs - + Connected to OBS. 已连接到 OBS。 @@ -6047,7 +6232,7 @@ What it needs Recordings - + Recording from %1 saved 已保存来自 %1 的录音 @@ -6063,57 +6248,57 @@ What it needs SendTo - + Copied to your phone's clipboard. 已复制到手机的剪贴板。 - + Couldn't reach %1. 无法连接 %1。 - + Couldn't send %1 无法发送 %1 - + Couldn't send to %1 无法发送到 %1 - + Couldn't share with %1 无法与 %1 共享 - + File failed integrity check; partial file kept to resume. 文件未通过完整性校验;已保留部分文件以便继续。 - + Send to %1 with NectarLink 用 NectarLink 发送到 %1 - + Sending to %1… 正在发送到 %1… - + Sent to %1 已发送到 %1 - + The transfer didn't go through. 传输未完成。 - + Waiting for %1… 正在等待 %1… @@ -6162,7 +6347,7 @@ What it needs - + Notifications 通知 @@ -6282,1287 +6467,1334 @@ What it needs 显示通知 - - + + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + %1 TB %1 TB - + Messages 消息 - - + + Calls 通话 - + Contacts 联系人 - + Clipboard 剪贴板 - + Files 文件 - - + + Voice recordings 语音录音 - + Media controls 媒体控制 - + Photos 照片 - + Phone controls 手机控制 - + Lock, sleep & wake PC 锁定、睡眠与唤醒电脑 - + Screen mirroring 屏幕镜像 - + Share PC screen to phone 将电脑屏幕共享到手机 - + Webcam 摄像头 - + File Explorer integration 文件资源管理器集成 + File manager integration + 文件管理器集成 + + + Remote mouse, keyboard & Deck 远程鼠标、键盘与面板 - + Run Deck shell commands 运行面板 shell 命令 - + Remote PC file access 远程访问电脑文件 - + Allow this phone to view this PC's screen. 允许此手机查看本机屏幕。 - + Only enable for a phone you trust. 仅对信任的手机启用。 - + Calls, messages & notifications 通话、消息与通知 - + Files, photos & clipboard 文件、照片与剪贴板 - + Screen, camera & control 屏幕、摄像头与控制 - + Back up photos to 将照片备份到 - + Save recordings in 录音保存位置 - + Pair a new device 配对新设备 - + Theme 主题 - + Hive 蜂巢 - + Bloom 绽放 - + Glass 玻璃 - + Graphite 石墨 - + Mode 模式 - - + + System 跟随系统 - + Light 浅色 - + Dark 深色 - + Color 颜色 - + Wallpaper 壁纸 - + AMOLED AMOLED - - - + + + Pure black (AMOLED) 纯黑(AMOLED) - - + + Media artwork 媒体封面 - - + + Animated comb 动态蜂巢 - + + Still while animations are off in system settings. + 系统设置中关闭动画时保持静止。 + + + Still while animations are off in Windows. Windows 关闭动画时保持静止。 - + Still while energy saver is on. 开启节能模式时保持静止。 - + The honeycomb breathes and pulses. 蜂巢会呼吸与脉动。 - + Pitch-black #000000 surfaces. 纯黑 #000000 界面。 - - + + Mica backdrop 云母背景 - + Tint window with desktop wallpaper. 用桌面壁纸为窗口着色。 - - + + Language 语言 - + NectarLink follows Windows unless you pick one. 除非你另行选择,NectarLink 会跟随 Windows 的语言。 - + More 更多 - - + + Start with Windows 随 Windows 启动 - + + + Start at login + 登录时启动 + + + Launch in the tray when you sign in. 登录时在托盘中启动。 - - + + Stay in system tray 保持在系统托盘 - + Keep running when the window is closed. 关闭窗口后继续运行。 - - + + Nectar Island Nectar 岛 - + Top-screen pill for calls, transfers & media. 屏幕顶部的胶囊条,用于通话、传输与媒体。 - - + + Battery alerts 电池提醒 - + Notify when phone battery is low or full. 手机电量低或充满时通知。 - + Island triggers 岛触发条件 - + Choose when the top pill appears. 选择顶部胶囊条何时出现。 - + Transfers 传输 - + Media 媒体 - + Alerts 提醒 - - + + Sync clipboard to phone 同步剪贴板到手机 - + Share copied text and images with your phone. 与手机共享复制的文字和图片。 - - + + Clipboard follows control 剪贴板跟随控制 - + Send your clipboard to the phone when you start controlling it, or when the mouse moves into its window. 开始控制手机或将鼠标移入其窗口时,把剪贴板发送到手机。 - - + + Copy one-time codes 复制一次性验证码 - + Auto-copy verification codes from texts. 自动从短信中复制验证码。 - - + + Copy received files 复制收到的文件 - + Ready to Ctrl+V incoming files anywhere. 随时可按 Ctrl+V 粘贴收到的文件。 - - + + Suggest clipboard actions 建议剪贴板操作 - + Open links, maps, numbers & emails from phone clips. 打开手机剪贴内容中的链接、地图、号码与邮箱。 - + No phones paired yet 尚未配对手机 - + Pair your phone to get started. 配对你的手机即可开始。 - + Pair a phone 配对手机 - + Connected 已连接 - + Not connected 未连接 - - + + Unpair 取消配对 - + Join this phone's hotspot 连接此手机的热点 - + When Instant Hotspot turns it on, this PC joins %1. 即时热点开启后,此电脑会连接到 %1。 - + + Join the phone's hotspot once from your system Wi-Fi list, then choose it here. + 请先在系统的 Wi-Fi 列表中连接一次手机热点,然后在这里选择它。 + + + Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here. 请先在 Windows 的 Wi-Fi 列表中连接一次手机热点,然后在这里选择它。 - + Choose network 选择网络 - - - - - - + + + + + + Open 打开 - - + + Stream PC audio to phone 将电脑音频串流到手机 - + Listen to PC audio through your phone. 通过手机收听电脑音频。 - - + + Pause media during calls 通话时暂停媒体 - + Auto-pause PC media when phone rings. 手机响铃时自动暂停电脑媒体。 - - + + Mute this PC while a phone watches 手机观看时将此电脑静音 - + With Play on phone, the phone still gets the sound. 使用“在手机上播放”时,手机仍会收到声音。 - + Phone as microphone 手机作为麦克风 - + Live from %1, playing into %2. 正在接收 %1,播放到 %2。 - + Tap Start on %1. 请在 %1 上轻触“开始”。 - + Plays into %1, so apps can pick its other end as a microphone. 播放到 %1,应用可以把它的另一端选作麦克风。 - + Plays into %1. To use it as a microphone in other apps, install a virtual audio cable such as VB-CABLE. 播放到 %1。要在其他应用中用作麦克风,请安装虚拟音频线,例如 VB-CABLE。 - + Output 输出 - + Start 开始 - + Stop 停止 - - + + Microphone volume 麦克风音量 - - + + %1% %1% - + Virtual camera add-on 虚拟摄像头插件 - + Registered for Zoom, Teams & Meet. 已注册到 Zoom、Teams 与 Meet。 - + One-time setup (admin required) for Zoom, Teams & Meet. 为 Zoom、Teams 与 Meet 进行一次性设置(需要管理员权限)。 - - + + Ready 就绪 - - - + + + Remove 移除 - + Set up 设置 - + Phone 手机 - + Camera 摄像头 - + Back 后置 - + Front 前置 - - + + Mirror image 镜像 - + Resolution 分辨率 - + Preview · %1 预览 · %1 - + Pair a phone to use its camera as a webcam 配对手机即可将其摄像头用作电脑摄像头 - + Stop webcam 停止摄像头 - + Start webcam 启动摄像头 - - + + Follow phone Do not disturb 跟随手机免打扰 - + Mute pop-ups while phone is on DND. 手机处于免打扰时静音弹窗。 - - + + Forward PC notifications 转发电脑通知 - + Send PC alerts to phone while away. 离开时把电脑提醒发送到手机。 - + Quiet hours 免打扰时段 - + Configure automatic DND schedule in Windows. 在 Windows 中配置自动免打扰时间表。 - + Set a schedule 设置时间表 - + Show 显示 - + No pop-ups 不弹出提示 - + Hide 隐藏 - - + + File Explorer Send to 文件资源管理器“发送到” - + + + File manager Send to + 文件管理器“发送到” + + + Send files from right-click menu. 从右键菜单发送文件。 - - + + Your PC in your pocket 口袋里的电脑 - + Share Downloads, Documents, Desktop & Pictures. 共享下载、文档、桌面和图片文件夹。 - - + + LocalSend on local network 局域网内的 LocalSend - + Share with nearby LocalSend devices without pairing. 无需配对即可与附近的 LocalSend 设备共享。 - - + + Open files shared from the phone 打开手机共享的文件 - + Opens a shared photo or document as soon as it arrives. 手机共享的照片或文档一到就自动打开。 - - - - - + + + + + Reset 重置 - - + + Choose folder 选择文件夹 - + Photo backup 照片备份 - + Copies new photos from every album into a folder on this PC, by year and month. Nothing is deleted, here or on the phone. Needs only the phone's photos permission. 将所有相册中的新照片按年和月复制到此电脑上的文件夹。电脑和手机上的内容都不会被删除。只需要手机的照片权限。 - + Back up photos from %1 备份 %1 的照片 - + Backup folder 备份文件夹 - + Include videos 包括视频 - + Videos can be large. Copied one at a time, so a lost connection costs at most one file. 视频可能很大。逐个复制,因此连接中断最多只影响一个文件。 - + Back up now 立即备份 - + Synced folders 同步文件夹 - + One-way or two-way folder sync between phone and PC. 手机与电脑之间的单向或双向文件夹同步。 - + Sync now 立即同步 - + Add DCIM → Pictures 添加 DCIM → 图片 - + Two-way sync · conflict copies kept 双向同步 · 保留冲突副本 - + One-way · phone to PC 单向 · 手机到电脑 - + Command palette 命令面板 - + Open from any app. 可在任何应用中打开。 - + Command palette shortcut 命令面板快捷键 - + File shelf 文件收纳架 - + Toggle screen-edge shelf. 切换屏幕边缘收纳架。 - + Shelf shortcut 收纳架快捷键 - + Take photo 拍照 - + Capture & paste photo. 拍摄并粘贴照片。 - + Take photo shortcut 拍照快捷键 - + Scan document 扫描文档 - + Scan & paste document. 扫描并粘贴文档。 - + Scan document shortcut 扫描文档快捷键 - + Trigger-action rules 触发-动作规则 - + Run actions when your phone connects, disconnects, or rings. 手机连接、断开或响铃时执行动作。 - + Lock PC on disconnect 断开时锁定电脑 - + Pause media on ring 响铃时暂停媒体 - + OBS Studio OBS Studio - + For Deck tiles that run obs: commands. Turn on OBS's WebSocket server in Tools › WebSocket Server Settings. 用于运行 obs: 命令的面板磁贴。请在 OBS 的“工具 › WebSocket 服务器设置”中开启 WebSocket 服务器。 - + Port 端口 - + OBS WebSocket port OBS WebSocket 端口 - + Password 密码 - + OBS WebSocket password OBS WebSocket 密码 - + Saved 已保存 - + None 无 - + Save 保存 - + Test connection 测试连接 - + Remove password 移除密码 - - + + Clipboard history 剪贴板历史记录 - + %1 clips · %2 · kept 7 days %1 条剪贴板记录 · %2 · 保留 7 天 - - - - - - + + + + + + Clear 清除 - - + + Notification history 通知历史记录 - + %1 alerts · %2 · kept 24h %1 条提醒 · %2 · 保留 24 小时 - + Cross-device timeline 跨设备时间线 - + 1 item stored (max 5,000). 已存储 1 个项目(上限 5,000)。 - + %1 items stored (max 5,000). 已存储 %1 个项目(上限 5,000)。 - + 30d 30 天 - + 90d 90 天 - + 1y 1 年 - + All 全部 - + Cached messages 缓存的消息 - + 1 chat 1 个会话 - + %1 chats %1 个会话 - + 1 message 1 条消息 - + %1 messages %1 条消息 - + %1 · %2 · %3 · kept 90 days %1 · %2 · %3 · 保留 90 天 - + Photo thumbnails 照片缩略图 - + %1 cached · %2 · kept 30 days 已缓存 %1 · %2 · 保留 30 天 - + Transfer history 传输历史记录 - + %1 records · kept with timeline %1 条记录 · 随时间线保留 - + Clear all local history & caches 清除所有本地历史记录和缓存 - + Wipes local history and caches; keeps paired devices and settings. 清除本地历史记录和缓存;保留已配对设备和设置。 - + Clear everything… 清除全部… - + Connection Doctor 连接诊断 - + Diagnose and fix firewall, network, or VPN issues. 诊断并修复防火墙、网络或 VPN 问题。 - + Check 检查 - + Connect over mobile data 通过移动数据连接 - + Stay connected over LTE/5G or relay when away from Wi-Fi. 离开 Wi-Fi 时通过 LTE/5G 或中继保持连接。 - + Wake from your phone 从手机唤醒 - + No active network adapter found. 未找到可用的网络适配器。 - + %1 · Wake on Magic Packet ready. %1 · Magic Packet 网络唤醒已就绪。 - + %1 · Enable Wake on Magic Packet in Device Manager. %1 · 请在设备管理器中启用 Magic Packet 网络唤醒。 - + Off in Windows 在 Windows 中已关闭 - + + Off in the adapter's settings + 在网卡设置中已关闭 + + + Check settings 检查设置 - + No adapter 无适配器 - - + + Local automation API 本地自动化 API - + Listening on 127.0.0.1:%1 · Token in %2 正在监听 127.0.0.1:%1 · 令牌位于 %2 - + Local HTTP/SSE server (127.0.0.1:%1) for scripts and tools. 供脚本和工具使用的本地 HTTP/SSE 服务器(127.0.0.1:%1)。 - + NectarLink %1 NectarLink %1 - + This PC: %1 本机:%1 - + Update download 更新下载 - + Update to %1 更新到 %1 - - - + + + Check for updates 检查更新 - + Open logs 打开日志 - + Looks for a new version on GitHub once a day. 每天在 GitHub 上检查一次新版本。 - - + + Install updates automatically 自动安装更新 - + When nobody is using the PC and nothing is in progress. 在无人使用电脑且没有任务进行时。 - + Setup walkthrough 设置引导 - + Re-open the setup guide. 重新打开设置指南。 - + Features guide 功能指南 - + What each feature needs and where to find it. 了解各项功能需要什么以及在哪里找到。 - + Phone's hotspot network 手机热点网络 - + + Wi-Fi networks this PC has joined before. Your system keeps their passwords; NectarLink never sees them. + 此电脑以前连接过的 Wi-Fi 网络。密码由系统保存,NectarLink 不会看到。 + + + + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from your system Wi-Fi list first. + 此电脑还没有保存任何 Wi-Fi 网络。请先在系统的 Wi-Fi 列表中连接一次手机热点。 + + + Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them. 此电脑以前连接过的 Wi-Fi 网络。密码由 Windows 保存,NectarLink 不会看到。 - + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first. 此电脑还没有保存任何 Wi-Fi 网络。请先在 Windows 的 Wi-Fi 列表中连接一次手机热点。 - + Don't join 不连接 - + Play the phone's microphone into 将手机麦克风播放到 - - - + + + Close 关闭 - + + NectarLink follows the system unless you pick one. + 除非你另行选择,NectarLink 会跟随系统语言。 + + + Unpair %1? 取消配对 %1? - + Both devices forget each other. You can pair them again any time. 两台设备将互相遗忘。你可以随时重新配对。 - - + + Cancel 取消 - + Clear all local history & caches? 清除所有本地历史记录和缓存? - + Deletes clipboard history, Timeline, notification history, cached messages, photo thumbnails and transfer history on this PC. Paired devices, settings and downloaded files are kept. 删除本机的剪贴板历史记录、时间线、通知历史记录、缓存的消息、照片缩略图和传输历史记录。已配对设备、设置和已下载的文件会保留。 - + Clear everything 清除全部 @@ -7570,25 +7802,35 @@ What it needs Setup - + Couldn't start setup: %1 无法启动安装程序:%1 - + + Couldn't uninstall NectarLink: %1 + 无法卸载 NectarLink:%1 + + + Setup is taking too long. Try again after restarting the PC. 安装时间过长。请重启电脑后再试。 - + Setup stopped with code %1. 安装已停止,代码 %1。 - + The uninstaller is missing. Reinstall NectarLink, then uninstall it. 缺少卸载程序。请重新安装 NectarLink,然后再卸载。 + + + Uninstall stopped with code %1. + 卸载已停止,代码 %1。 + SetupWindow @@ -7790,164 +8032,164 @@ What it needs 手机 - + %1 B %1 B - + %1 KB %1 KB - + %1 MB %1 MB - + %1 GB %1 GB - + Shelf 收纳架 - + No phone connected 未连接手机 - + Refresh Shelf 刷新收纳架 - + Close Shelf 关闭收纳架 - + Release to send to %1 松开以发送到 %1 - + Sent to %1 已发送到 %1 - + Send cancelled 已取消发送 - + Drop or flick items here to send 将项目拖放到此处或向上轻扫即可发送 - + Sending to %1… 正在发送到 %1… - + Undo 撤销 - + Latest photo 最新照片 - + No recent photo 暂无近期照片 - + Latest screenshot 最新截图 - + No recent screenshot 暂无近期截图 - + Copy 复制 - + Open 打开 - + Current clip 当前剪贴内容 - + Send clip to phone 发送剪贴内容到手机 - - + + Clip 剪贴内容 - + Copy clip 复制剪贴内容 - - + + Clipboard is empty 剪贴板为空 - + Current clip: %1 当前剪贴内容:%1 - + Recent received files 最近接收的文件 - + No received files yet 暂无接收的文件 - + Send to phone 发送到手机 - + Open file 打开文件 - + Show in folder 在文件夹中显示 - + Flick any card up to send to phone, drag out to use, or drop files in 向上轻扫任意卡片即可发送到手机,拖出即可使用,也可将文件拖入 @@ -7955,147 +8197,147 @@ What it needs Timeline - + App window #%1 应用窗口 #%1 - + Clip (removed from clipboard history) 剪贴内容(已从剪贴板历史中删除) - + Copied image 已复制的图片 - + Copied link to clipboard. 已将链接复制到剪贴板。 - + Copied to clipboard. 已复制到剪贴板。 - + From %1 来自 %1 - + In progress 进行中 - + No phone is paired yet. 尚未配对手机。 - + Opened from %1 从 %1 打开 - + Opened on %1 在 %1 上打开 - + Phone microphone 手机麦克风 - + Phone webcam 手机摄像头 - + Recorded on %1 在 %1 上录制 - + Saved from %1 从 %1 保存 - + Screen mirroring 屏幕镜像 - + Sending to %1… 正在发送到 %1… - + Sent clip to %1. 已将剪贴内容发送到 %1。 - + Sent to %1 已发送到 %1 - + Session 会话 - + That clip is no longer in clipboard history. 该剪贴内容已不在剪贴板历史中。 - + That entry couldn't be removed. Try again. 无法删除该条目,请重试。 - + That file was moved or deleted. 该文件已被移动或删除。 - + That isn't a web link. 这不是网页链接。 - + That photo was moved or deleted. 该照片已被移动或删除。 - + The Timeline couldn't be cleared. Try again. 无法清除时间线,请重试。 - + The photo couldn't be copied to the clipboard. 无法将照片复制到剪贴板。 - + Voice recording 录音 - + With %1 与 %1 - + device 设备 @@ -8140,8 +8382,8 @@ What it needs - - + + All devices 所有设备 @@ -8156,92 +8398,92 @@ What it needs 昨天 - + Search timeline 搜索时间线 - + Search timeline… 搜索时间线… - + Clear search 清除搜索 - + Clear all 全部清除 - + Device filter: %1 设备筛选:%1 - + Clear timeline history? Saved files on disk are kept. 清除时间线历史记录?已保存到磁盘的文件会保留。 - + Cancel 取消 - + Clear timeline 清除时间线 - + No matching items 没有匹配的项目 - + No recent activity 暂无近期活动 - + Clear the search or filter. 请清除搜索或筛选条件。 - + Shared files, clips, links, and photos appear here. 共享的文件、片段、链接和照片会显示在这里。 - + Open 打开 - + Show in folder 在文件夹中显示 - + Copy 复制 - + Send again 再次发送 - + Remove from timeline 从时间线移除 - + Load more 加载更多 @@ -8387,37 +8629,37 @@ What it needs Transfers - + Accept in NectarLink to save to Downloads\Nectarlink 在 NectarLink 中接受即可保存到 Downloads\Nectarlink - + Files are turned off for that device. 已为该设备关闭文件。 - + From %1, opened from Downloads\Nectarlink 来自 %1,已从 Downloads\Nectarlink 打开 - + From %1, saved in Downloads\Nectarlink 来自 %1,已保存到 Downloads\Nectarlink - + Nearby device 附近的设备 - + That's too many files to send at once. 文件太多,无法一次发送。 - + There's nothing to send in there. 那里没有可发送的内容。 @@ -8425,67 +8667,67 @@ What it needs Updater - + Couldn't check for updates. Check your internet connection. 无法检查更新。请检查网络连接。 - + NectarLink %1 is available NectarLink %1 已发布 - + NectarLink %1 is available. NectarLink %1 已发布。 - + NectarLink is still on the version you had. Try again from Settings. NectarLink 仍是原来的版本。请在设置中重试。 - + NectarLink is up to date. NectarLink 已是最新版本。 - + NectarLink is updated to %1 NectarLink 已更新到 %1 - + The download was damaged, so it wasn't installed. Try again. 下载的文件已损坏,因此未安装。请重试。 - + The update couldn't be downloaded. Check your internet connection and try again. 无法下载更新。请检查网络连接后重试。 - + The update couldn't be saved. Check that there's free space and try again. 无法保存更新。请确认有足够的空间后重试。 - + The update couldn't be started. Try again. 无法启动更新。请重试。 - + The update didn't install 更新未安装 - + Update now, or later from Settings. 立即更新,或稍后在设置中更新。 - + Your phone reconnects on its own. 手机会自动重新连接。 @@ -8493,191 +8735,202 @@ What it needs WalkthroughPage - + Skip setup 跳过设置 - + Notifications and Calls 通知与通话 - + Clipboard and Send files 剪贴板与发送文件 - + Mirroring, Camera, and Audio 投屏、摄像头与音频 - + Direct local QUIC link 本地 QUIC 直连 - + End-to-end encrypted over QUIC 基于 QUIC 的端到端加密 - + Hardware-backed device keys 硬件级设备密钥 - + Local network by default 默认走本地网络 - + Option 1: Download the Android APK (Android 8.0+) 方式一:下载安卓 APK(Android 8.0 或更新版本) - + Open the latest GitHub Release on your phone or copy the release link. 在手机上打开最新的 GitHub Release,或复制发布链接。 - + Open release page 打开发布页 - + Option 2: Install over USB with ADB 方式二:用 ADB 通过 USB 安装 - + Paired with %1. You can continue to the next step. 已与 %1 配对。你可以继续下一步。 - - + + your phone 你的手机 - - + + On by default 默认开启 - - + + Off until you turn them on 开启前一直关闭 - + Change these per phone on Home after pairing. 配对后可在首页按手机分别修改。 - + Sharing settings for %1 %1 的共享设置 - + Notifications 通知 - + Clipboard 剪贴板 - + Send files 发送文件 - + Now playing 正在播放 - + Messages 消息 - + Calls 通话 - + Screen mirroring 屏幕镜像 - + Remote input 远程输入 - + Share PC folders 共享电脑文件夹 - + Windows notifications Windows 通知 - + + Desktop notifications + 桌面通知 + + + Windows Settings Windows 设置 - + Recheck 重新检测 - - + + Start with Windows 随 Windows 启动 + + Start at login + 登录时启动 + + + Keep NectarLink ready in the system tray when you sign in. 登录后让 NectarLink 常驻系统托盘待命。 - + Firewall and network check 防火墙与网络检查 - + Command Palette (Ctrl+K) 命令面板(Ctrl+K) - + Features guide 功能指南 - + Back 返回 - + Open features guide 打开功能指南 @@ -8685,12 +8938,12 @@ What it needs Webcam - + Starting camera on %1… 正在启动 %1 上的相机… - + Streaming %1 × %2 · %3 fps from %4 正在从 %4 传输 %1 × %2 · %3 fps @@ -8721,17 +8974,17 @@ What it needs Widget - + No phone connected 未连接手机 - + No recent clipboard items 没有最近的剪贴板内容 - + Not available 不可用 diff --git a/desktop/app/i18n/rust_strings.h b/desktop/app/i18n/rust_strings.h index 29ee72c1..2601f04a 100644 --- a/desktop/app/i18n/rust_strings.h +++ b/desktop/app/i18n/rust_strings.h @@ -190,14 +190,24 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Doctor", "%1 minutes ago"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "%1 · %2 ms"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "A VPN is on"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect."), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Calls and alerts can't play sound yet"), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Connect it to the same Wi-Fi or wired network as your phone."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Connected to %1"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Connected to a network"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Connecting to %1…"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions."), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Install fuse3 to browse your phone's files in your file manager."), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing."), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC."), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera."), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "It hasn't connected yet."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Keep %1 reachable in the background (%2)"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Last seen %1."), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "NectarLink can't show its icon in the top bar"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "NectarLink is in the top bar"), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "NectarLink isn't allowed through Windows Firewall"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "NectarLink isn't allowed through the firewall"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On Huawei / Honor: open Settings → Battery → App launch, turn off Manage automatically for NectarLink, and keep Auto-launch, Secondary launch, and Run in background on."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On OnePlus / OxygenOS: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On Oppo / Realme: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch."), // desktop/app/src/doctor.rs @@ -207,8 +217,19 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Doctor", "On Vivo / OriginOS / Funtouch: open Settings → Battery → Background power consumption management → NectarLink and choose Unrestricted, and allow Autostart."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On Xiaomi / HyperOS / MIUI: open Settings → Apps → Manage apps → NectarLink, turn on Autostart, and set Battery saver to No restrictions."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private."), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "On public networks the firewall keeps other devices from finding this PC. If it's your home or work network, open network settings and set its firewall zone to Home or Trusted."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "On this network"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Phone as webcam is ready"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Phone as webcam needs one more step"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Phone storage can open in your file manager"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Phone storage needs one more step"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Showing this PC's screen needs one more step"), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Someone chose not to allow it when Windows asked. Allow it, and your phone can connect."), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Sound is ready"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "The firewall blocks NectarLink"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "The firewall is off for this network"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "The firewall lets your phone connect"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput."), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "This PC isn't on a network"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "This network"), // desktop/app/src/doctor.rs @@ -218,6 +239,9 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Doctor", "Windows Firewall blocks NectarLink"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Windows Firewall is off for this network"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Windows Firewall lets your phone connect"), // desktop/app/src/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Your phone can control this PC"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Your phone can show this PC's screen"), // desktop/app/src/linux/doctor.rs + QT_TRANSLATE_NOOP("Doctor", "Your phone can't control this PC yet"), // desktop/app/src/linux/doctor.rs QT_TRANSLATE_NOOP("Doctor", "Your phone may not be able to connect. Allow it once here."), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "a minute ago"), // desktop/app/src/doctor.rs QT_TRANSLATE_NOOP("Doctor", "an hour ago"), // desktop/app/src/doctor.rs @@ -230,7 +254,7 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Errors", "The device's app doesn't support that yet."), // desktop/app/src/bridge/app.rs QT_TRANSLATE_NOOP("FolderSync", "Folders are up to date."), // desktop/app/src/folder_sync.rs QT_TRANSLATE_NOOP("FolderSync", "Synced %1 file(s)."), // desktop/app/src/folder_sync.rs - QT_TRANSLATE_NOOP("Guide", "1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer).\n2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable.\n3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail.\n4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens.\n5. Choose which features to share for that phone and grant only the Android permissions you want to use."), // desktop/app/src/bridge/app.rs + QT_TRANSLATE_NOOP("Guide", "1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer).\n2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable.\n3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail.\n4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens.\n5. Choose which features to share for that phone and grant only the Android permissions you want to use."), // desktop/app/src/bridge/app.rs QT_TRANSLATE_NOOP("Guide", "Android permissions and why each is optional"), // desktop/app/src/bridge/app.rs QT_TRANSLATE_NOOP("Guide", "Automations"), // desktop/app/src/bridge/app.rs QT_TRANSLATE_NOOP("Guide", "Calls"), // desktop/app/src/bridge/app.rs @@ -347,6 +371,8 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Notifications", "Dismissed %1 notifications"), // desktop/app/src/bridge/notifications.rs QT_TRANSLATE_NOOP("Notifications", "Dismissed from %1"), // desktop/app/src/bridge/notifications.rs QT_TRANSLATE_NOOP("Notifications", "Notification dismissed"), // desktop/app/src/bridge/notifications.rs + QT_TRANSLATE_NOOP("Notifications", "Open"), // desktop/app/src/linux/toast.rs + QT_TRANSLATE_NOOP("Notifications", "Reply"), // desktop/app/src/linux/toast.rs QT_TRANSLATE_NOOP("Notifications", "That notification is gone from the phone."), // desktop/app/src/notifications.rs QT_TRANSLATE_NOOP("Obs", "OBS isn't reachable on port %1. Turn on its WebSocket server."), // desktop/app/src/obs.rs QT_TRANSLATE_NOOP("Obs", "OBS: %1"), // desktop/app/src/obs.rs diff --git a/desktop/app/i18n/template/nectarlink.ts b/desktop/app/i18n/template/nectarlink.ts index 34c3f103..246b16d2 100644 --- a/desktop/app/i18n/template/nectarlink.ts +++ b/desktop/app/i18n/template/nectarlink.ts @@ -177,57 +177,62 @@ - + + Opening apps in windows isn't supported on this phone. + + + + Set up Wireless debugging in NectarLink on the phone to open its apps here. - + Search apps - + Search %1's apps - + Refresh - + No apps match. - + No apps to open. - + Getting the phone's apps… - + Try again - + Recent - + All apps - + %1, open @@ -430,277 +435,277 @@ - + Yesterday - + %1s - + Call from your PC - - - + + + %1 isn't connected - + Pair your phone to view calls and contacts. - + Connect %1 to view calls and contacts. - + Recent - - + + Contacts - + Refresh - - + + Search calls - - + + Search contacts - + Clear search - + Calls are off for %1 - + Allow call log on %1 - + Couldn't load recent calls - + Enable Calls in Settings. - + Grant call log permission on %1. - + Connect %1 to view recent calls. - + Unable to load call log. - - + + Try again - + No calls match. - + No recent calls. - + Contacts are off for %1 - + Allow contacts on %1 - + Couldn't load contacts - + Enable Contacts in Settings. - + Grant contacts permission on %1. - + Connect %1 to view contacts. - + Unable to load contacts. - + No contacts match. - + No contacts on this phone. - + Close contact - + Copy number - - - + + + Text %1 - - - + + + Call %1 - + Phone number to call - + Enter a number - + Backspace - + Text - + Calling… - + Call - + Clear number - + PC call audio connected - + PC call audio ready - + PC call audio (Bluetooth) - + Connect PC audio - + Open Bluetooth settings - + Missed - + Outgoing - + Declined - + Incoming - + Favorites - + +%1 more @@ -708,22 +713,22 @@ CaptionButtons - + Minimize - + Restore - + Maximize - + Close @@ -1404,51 +1409,56 @@ - Enter an http:// or https:// web address. + Enter an application path (/usr/bin/..., .desktop, or .AppImage). - Enter the text snippet to type. + Enter an http:// or https:// web address. - Invalid action configuration. + Enter the text snippet to type. - Invalid tile properties. + Invalid action configuration. - Media key + Invalid tile properties. - Page not found. + Media key - This page already has the maximum number of tiles (24). + Page not found. - Tile not found. + This page already has the maximum number of tiles (24). - Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.). + Tile not found. + Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.). + + + + Web address must start with http:// or https://. @@ -1525,6 +1535,11 @@ Show desktop (Win+D) + + + Show desktop (Super+D) + + Switch window (Alt+Tab) @@ -1535,6 +1550,11 @@ Screenshot (Win+Shift+S) + + + Screenshot + + Lock PC @@ -1728,6 +1748,11 @@ Win + + + Super + + Key (e.g. c, v, z, f5, tab, enter, space, escape, up, down): @@ -1840,212 +1865,332 @@ Doctor - + %1 Open NectarLink on the phone and keep both on the same network. If the phone asks to run in the background, allow it. - + %1 days ago - + %1 hours ago - + %1 is connected - + %1 is on this Wi-Fi, but the router blocks direct connections (AP isolation) - + %1 is set as a public network - + %1 isn't connected - + %1 minutes ago - + %1 · %2 ms - + A VPN is on - + + A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect. + + + + + Calls and alerts can't play sound yet + + + + Connect it to the same Wi-Fi or wired network as your phone. - + Connected to %1 - + Connected to a network - + Connecting to %1… - + + GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions. + + + + + Install fuse3 to browse your phone's files in your file manager. + + + + + Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing. + + + + + Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC. + + + + + Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera. + + + + It hasn't connected yet. - + Keep %1 reachable in the background (%2) - + Last seen %1. - + + NectarLink can't show its icon in the top bar + + + + + NectarLink is in the top bar + + + + NectarLink isn't allowed through Windows Firewall - + + NectarLink isn't allowed through the firewall + + + + On Huawei / Honor: open Settings → Battery → App launch, turn off Manage automatically for NectarLink, and keep Auto-launch, Secondary launch, and Run in background on. - + On OnePlus / OxygenOS: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. - + On Oppo / Realme: open Settings → Apps → App management → NectarLink → Battery usage and turn on Allow background activity and Allow auto-launch. - + On Pixel: open Settings → Apps → See all apps → NectarLink → App battery usage, tap Allow background usage, and choose Unrestricted. - + On Samsung: open Settings → Apps → NectarLink → Battery and choose Unrestricted, then in Device care → Battery → Background usage limits make sure NectarLink is never put to sleep. - + On USB - + On Vivo / OriginOS / Funtouch: open Settings → Battery → Background power consumption management → NectarLink and choose Unrestricted, and allow Autostart. - + On Xiaomi / HyperOS / MIUI: open Settings → Apps → Manage apps → NectarLink, turn on Autostart, and set Battery saver to No restrictions. - + On public networks Windows keeps other devices from finding this PC. If it's your home or work network, set it to Private. - + + On public networks the firewall keeps other devices from finding this PC. If it's your home or work network, open network settings and set its firewall zone to Home or Trusted. + + + + On this network - + + Phone as webcam is ready + + + + + Phone as webcam needs one more step + + + + + Phone storage can open in your file manager + + + + + Phone storage needs one more step + + + + + Showing this PC's screen needs one more step + + + + Someone chose not to allow it when Windows asked. Allow it, and your phone can connect. - + + Sound is ready + + + + + The firewall blocks NectarLink + + + + + The firewall is off for this network + + + + + The firewall lets your phone connect + + + + + The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput. + + + + This PC can see %1 announce itself on the network, but the router is blocking devices from talking directly to each other (often called AP Isolation, Client Isolation, or Guest Wi-Fi). Move both devices off any Guest Wi-Fi network, turn off AP / Client Isolation in your router's Wi-Fi settings, or connect with a USB cable. - + This PC isn't on a network - + This network - + This usually takes a few seconds. - + VPNs often keep devices on your network from reaching this PC. If your phone can't connect, try turning it off. - + While away - + Windows Firewall blocks NectarLink - + Windows Firewall is off for this network - + Windows Firewall lets your phone connect - + + Your phone can control this PC + + + + + Your phone can show this PC's screen + + + + + Your phone can't control this PC yet + + + + Your phone may not be able to connect. Allow it once here. - + a minute ago - + an hour ago - + just now @@ -2053,52 +2198,57 @@ DoctorSheet - + Allow - + Open settings - + Reconnect - + Connection Doctor - + Checking network and firewall… - + Everything looks right. - + + Your phone can reach this PC. A few features need a step from you: + + + + Issues blocking your phone from reaching this PC: - + Firewall settings - + Check again - + Done @@ -2106,32 +2256,32 @@ Errors - + Something went wrong: %1 - + That device isn't paired anymore. - + The device declined. - + The device didn't answer in time. - + The device isn't connected right now. - + The device's app doesn't support that yet. @@ -2340,12 +2490,12 @@ FolderSync - + Folders are up to date. - + Synced %1 file(s). @@ -2353,8 +2503,8 @@ Guide - - 1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer). + + 1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer). 2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable. 3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail. 4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens. @@ -2362,72 +2512,72 @@ - + Android permissions and why each is optional - + Automations - + Calls - + Clipboard - + Command palette - + Connection Doctor - + Continuity camera - + Deck - + Drop shelf - + Elevated and Assist modes - + Features guide - + Features reference - + Handoff - + If your phone and PC do not see each other: 1. Open Connection Doctor from Settings › Connection & About or the Command Palette. @@ -2437,17 +2587,17 @@ - + Install and first-time setup - + Local HTTP API - + Local by default Your phone and PC talk directly over your local network or USB cable. When Connect over mobile data is on, encrypted traffic can use a relay when away from home. Automatic update checks contact GitHub Releases and can be turned off in Settings › Connection & About › Check for updates. @@ -2459,17 +2609,17 @@ Sensitive capabilities such as Share PC folders, Remote input, and Reverse PC sc - + LocalSend - + Messages - + Most NectarLink features work in Standard mode with regular Android permissions. Two optional modes unlock deeper system integration: Elevated mode (Wireless debugging or root) @@ -2483,12 +2633,12 @@ Assist mode (Android Accessibility Service) - + Nectar Island - + NectarLink links your Android phone and your PC directly on your local network or over a USB cable. How it works @@ -2503,7 +2653,7 @@ Capability tiers on Android - + NectarLink never asks for every permission up front. Each feature works independently: - Notification Access: Required only if you want phone notifications, inline replies, and notification-backed chat threads on your PC. @@ -2514,97 +2664,97 @@ Capability tiers on Android - + NectarLink overview - + Notifications - + Phone as speaker - + Phone as webcam - + Phone controls - + Photos - + Privacy and security - + Remote input - + Screen mirroring - + Select any feature card in the Features guide to inspect its live status, jump directly to its control with Show me, or read how it works and what permissions it uses. - + Send files - + Share PC folders - + Share PC screen - + Timeline - + Troubleshooting and Connection Doctor - + Type on phone - + Voice recorder - + Wake PC - + What it does Browses recent photos, camera shots, and screenshots on your phone without copying your entire library first. You can view full-resolution images, copy them to the clipboard, save them to disk, or drag them directly into other Windows apps. @@ -2617,7 +2767,7 @@ What it needs - + What it does Controls your phone's Do Not Disturb mode, ringer mode (Sound, Vibrate, Silent), flashlight, media volume, screen brightness, Wi-Fi, Bluetooth, and Find My Phone ring from your PC. @@ -2630,7 +2780,7 @@ What it needs - + What it does Discovers and exchanges files with nearby devices running LocalSend v2 on your local network without requiring pairing. @@ -2643,7 +2793,7 @@ What it needs - + What it does Displays a compact pill at the top center of your primary monitor for incoming calls, live file transfer progress, media playback, and optional notification previews. @@ -2655,7 +2805,7 @@ What it needs - + What it does Exposes an opt-in localhost HTTP/JSON and Server-Sent Events API on 127.0.0.1 (default port 53320) protected by a bearer token so scripts, CLI tools, and Stream Deck plugins can trigger NectarLink actions. @@ -2667,7 +2817,7 @@ What it needs - + What it does Forwards keystrokes and pasted text from your PC keyboard directly to your phone without needing to mirror the phone screen. @@ -2680,7 +2830,7 @@ What it needs - + What it does Inspects Windows Firewall rules, network profile type (Private vs Public), UDP discovery sockets, and paired phone reachability, and offers one-click fixes. @@ -2693,7 +2843,7 @@ What it needs - + What it does Keeps selected folders on your phone (such as DCIM/Camera or Download) and folders on your PC in sync in one-way or two-way mode, and lets your phone browse shared PC folders. @@ -2706,7 +2856,7 @@ What it needs - + What it does Lets you read and send SMS and MMS conversations, plus reply to notification-backed chat threads from messaging apps such as WhatsApp, Signal, and Telegram. @@ -2720,7 +2870,7 @@ What it needs - + What it does Lets your paired phone wake this PC from sleep or soft power-off over your local network using a Wake-on-LAN packet. @@ -2733,7 +2883,7 @@ What it needs - + What it does Lets your phone act as a wireless trackpad, keyboard, presentation clicker, and on-screen laser pointer for your PC. @@ -2746,7 +2896,7 @@ What it needs - + What it does Mirrors your phone notifications to the Home feed and Windows notification pop-ups. You can reply inline to messages, click action buttons, dismiss individual notifications or groups, and undo a recent dismissal. @@ -2761,7 +2911,7 @@ What it needs - + What it does Opens a keyboard-first search bar over actions, contacts, message threads, mirrorable apps, phone quick toggles, clipboard history, and navigation pages. @@ -2774,7 +2924,7 @@ What it needs - + What it does Opens a lightweight slide-out panel at the edge of your monitor showing your phone's latest photo, latest screenshot, recent clipboard items, and recent received files. Drag items out into any app, or drop files onto the Shelf to send them to your phone. @@ -2788,7 +2938,7 @@ What it needs - + What it does Provides a unified, searchable chronological feed of files, photos, clipboard items, calls, messages, and voice recordings shared between your phone and PC. @@ -2801,7 +2951,7 @@ What it needs - + What it does Receives voice notes recorded on your phone and saves them to your PC in your chosen audio format (M4A, MP3, WAV, or FLAC). @@ -2814,7 +2964,7 @@ What it needs - + What it does Runs automatic trigger-action rules such as locking your PC when your phone disconnects, pausing media when a call arrives, or running a command when your phone connects. @@ -2826,7 +2976,7 @@ What it needs - + What it does Sends files, folders, links, and text between your phone and PC at local network speed. If your phone is offline when you send, NectarLink places the item in the Waiting to send queue and delivers it automatically as soon as your phone reconnects. @@ -2839,7 +2989,7 @@ What it needs - + What it does Sends web links, video links with their current playback timestamp, map coordinates or street addresses, and documents to your phone so they open immediately in the right app. @@ -2851,7 +3001,7 @@ What it needs - + What it does Shares copied text, rich HTML/RTF text, images, and small copied file sets between your PC and phone. Keeps an encrypted local history of up to 50 items with pinning and smart context chips for links, phone numbers, addresses, and tracking codes. @@ -2865,7 +3015,7 @@ What it needs - + What it does Shows incoming and active phone calls on your PC with Answer, Decline, and Reject with SMS actions, plus browsable call history, contacts, and a dialpad. @@ -2879,7 +3029,7 @@ What it needs - + What it does Streams your PC system audio to your connected phone in real time over the encrypted connection so you can listen through your phone speakers or headphones. @@ -2892,7 +3042,7 @@ What it needs - + What it does Streams your PC's screen to your phone so you can view your desktop from across the room and use your phone as a trackpad or laser pointer. @@ -2905,7 +3055,7 @@ What it needs - + What it does Streams your phone camera into the NectarLink Virtual Camera on Windows so Zoom, Teams, Meet, Discord, and OBS can use your phone's rear or front camera at up to 4K. @@ -2919,7 +3069,7 @@ What it needs - + What it does Streams your phone screen into a low-latency desktop window with hardware-accelerated decoding, or opens individual Android apps in their own windows on your PC. @@ -2934,7 +3084,7 @@ What it needs - + What it does Syncs media playback between your phone and PC, switches Windows audio output and microphone devices, and turns your phone into a customizable grid of shortcut tiles, macros, and volume sliders. @@ -2946,7 +3096,7 @@ What it needs - + What it does Triggers your phone camera from any Windows app to snap a photo or scan a document, then copies the result to the Windows clipboard and pastes it into the active window. @@ -3177,8 +3327,8 @@ What it needs - - + + Wi‑Fi @@ -3229,7 +3379,7 @@ What it needs - + Send files @@ -3245,7 +3395,7 @@ What it needs - + Clipboard history @@ -3336,14 +3486,14 @@ What it needs - - + + Cancel - + Timeline @@ -3393,448 +3543,458 @@ What it needs - + + Desktop notifications are off. Turn them on in your system's notification settings. + + + + Windows notifications are off. - + Turn on - + Webcam - + Streaming from %1 - - + + Stop - + Back - + Front - + Mirror - + Phone controls - + Less - + Sound & display - + DND - + Do Not Disturb - + Torch - + Flashlight - + BT - + Bluetooth - + Hotspot - + Instant Hotspot - + Phone as speaker - + Streaming PC audio - + Play on phone - + Watching on %1 - + Tap the notification on %1 - + Play a video on this PC first - + Turning off Wi‑Fi may disconnect %1. - + Turn off - + Ring - + Vibrate - + Silent - + Volume - + Brightness - + Continuity Camera - + Scan document - + Phone as webcam - + Streaming - + Type on phone - + Sent: %1 - + Listening for keys - - + + When connected - + Start - - + + Clipboard - + Type here: keys go to %1 - + Click to focus keyboard - + Ctrl+V pastes · Esc stops - + Paste - + View all - + No recent activity - + %1 notifications - + Show - + No pop-ups - + Hide - + Hidden on this PC. - + Shown in feed without pop-ups. - + + Shown in feed and as desktop pop-ups. + + + + Shown in feed and as Windows pop-ups. - - - - + + + + Done - + Notification history - - + + Clear history - + Dismissed notifications from the last 24 hours. - + No recent notifications. - - + + Search notification history - - + + Nothing matches “%1”. - + Last 50 shared clips. - + No shared clips yet. - - + + Search clipboard history - + From %1 - + From phone - + Sent to %1 - + From this PC - + Image - + Pinned - + Open on phone - + Send to phone - + Copy - + Unpin - + Pin - + Delete - - + + %1% - + LocalSend nearby · %1 - + LocalSend nearby - + Refresh - + Listening for LocalSend devices on your Wi‑Fi network. - + LocalSend port 53317 is busy on this PC (another LocalSend app may be running). - + Nearby device - + Image preview - + Close - + Open externally @@ -3842,17 +4002,17 @@ What it needs Hotspot - + %1's hotspot didn't show up. Check that it's on, then pick %2 in Windows' Wi-Fi list. - + Connected to %1's hotspot. - + Couldn't join %1's hotspot. Pick %2 in Windows' Wi-Fi list. @@ -3955,62 +4115,62 @@ What it needs Links - + %1: tap the notification on your phone to continue watching. - + %1: tap the notification on your phone to open in Maps. - + Copy a link or address first - + Copy a web link (https://…), geo: URI, or street address, then choose this again. - + Copy a web link, video URL, or street address, then choose this again to open it on your phone. - + Couldn't send it - + No phone to open it on - + Pair a phone first, or enable opening links on your phone. - + Sent to %1 - + Tap the notification on your phone to open it. - + That's not a link or address - + Update NectarLink on %1. @@ -4126,177 +4286,177 @@ What it needs MainWindow - - + + Home - - + + Messages - - + + Calls - - + + Photos - - + + Apps - - + + Deck - + Guide - - + + Settings - + Pair a new device - + Back to Home - - + + Timeline - + Features guide - + Sharing screen with %1 - + Sharing screen - + Stop - + Stop sharing this PC's screen - + Commands - + Command palette (%1) - + Command palette (Ctrl+K) - + Files, clips, links, photos, recordings and sessions - + Refresh - + %1 is ringing this PC - + Stop ringing - + Allow %1 to control this PC? - + %1 wants to use this PC's mouse and keyboard. You can change this in Settings. - - + + Not now - - + + Allow - + Allow %1 to view this PC's screen? - + %1 wants to view this PC's screen. Control needs Remote mouse, keyboard & Deck in Settings. - + Allow for this device - + Photo ready on clipboard - + Keep on clipboard - + Paste into active window @@ -4304,17 +4464,17 @@ What it needs Media - + Media is turned off for that device. - + That player can't do that. - + That player isn't playing anymore. @@ -4322,17 +4482,17 @@ What it needs Messages - + Attachment is %1 (MMS supports up to 900 KB) - + Group texts can't be sent from the PC yet. - + Only image or voice-note audio files can be attached. @@ -4366,286 +4526,286 @@ What it needs - + Yesterday - + Text from your PC - + %1 isn't connected - + Messages are off for %1 - + Allow texting on %1 - + Couldn't load messages - + Pair your phone to read and send texts. - + Connect %1 to view messages. - + Enable Messages in Settings. - + Grant SMS permission on %1. - + Unable to load messages. - + Try again - + Conversations - + New message (Ctrl+N) - + Refresh - + Search conversations - + Search - + Clear search - + No conversations match. - + No conversations yet. - + Drop image to attach (MMS) - + PNG, JPEG, WebP, GIF, or BMP up to 900 KB - + Select a conversation - + Back to conversations (Esc) - + Call %1 - + Call - + Remove conversation from PC - + To - - + + Name or phone number - + No mirrored messages yet - + No messages - + %1 · Sent as MMS - + Remove attachment - + Attach image (MMS) - + Reply in %1 - - + + Text message - + Replies are available while the notification is active on your phone - + Reply in %1… - + Group texts can't be sent from the PC yet - + Add a caption (optional)… - + Send - + %1 characters · %n text(s) - + Open image attachment - + Voice note - + · Click to play - + · Loading… - + Attachment (open it on the phone) - + Not sent - + Sending… - + Copy text - + Copied - + Copy code %1 - + Image attachment - + Close - + Open externally - + Done @@ -4653,102 +4813,117 @@ What it needs Mirror - + Couldn't start recording. - + Mirror recording - + Mirror screenshot - + Mirroring is turned off for this phone. - + + Recording from %1, saved in Downloads/Nectarlink + + + + Recording from %1, saved in Downloads\Nectarlink - + Saved %1 - + + Saved %1 to Downloads/Nectarlink. + + + + Saved %1 to Downloads\Nectarlink. - + Screenshot copied to clipboard. - + + Screenshot from %1, saved in Downloads/Nectarlink + + + + Screenshot from %1, saved in Downloads\Nectarlink - + Sending %1 files… - + Sending %1… - + Set up Wireless debugging in NectarLink on the phone to open its apps here. - + Show in folder - + The app didn't open on the phone. Try again, or open NectarLink on the phone to check Wireless debugging. - + The phone isn't connected. - + The screenshot couldn't be copied to the clipboard. - + The screenshot couldn't be saved. - + This PC can't decode the phone's video. - + This phone's app doesn't share its screen yet. Update NectarLink on the phone. - + Wait for the screen to appear first. @@ -4900,7 +5075,7 @@ What it needs Mp4 - + No video frames were recorded yet. @@ -4994,39 +5169,49 @@ What it needs Notifications - + Code copied - + Copy code - + Dismissed %1 from %2 - + Dismissed %1 notifications - + Dismissed from %1 - + Notification dismissed - - That notification is gone from the phone. - + + Open + + + + + Reply + + + + + That notification is gone from the phone. + @@ -5140,17 +5325,17 @@ What it needs Obs - + OBS isn't reachable on port %1. Turn on its WebSocket server. - + OBS: %1 - + The service on that port isn't OBS. @@ -5321,7 +5506,7 @@ What it needs PcMirror - + Can't find the player's window. Play the video on this PC again, then turn on Play on phone. @@ -5329,17 +5514,17 @@ What it needs PhoneMic - + Tap Start on %1 to use its microphone. - + The microphone is turned off for this PC on %1. - + Update NectarLink on %1 to use its microphone. @@ -5347,57 +5532,57 @@ What it needs PhotoBackup - + Can't write to the backup folder. - + Copying, %1 left - + Looking for new photos… - + Off - + Photos are turned off for this PC on %1. - + The backup folder's drive is full. - + The backup stopped. It tries again in a few minutes. - + Up to date - + Up to date, %1 backed up - + Update NectarLink on %1 to back up its photos. - + Waiting for %1 @@ -5405,67 +5590,67 @@ What it needs Photos - + Copied to clipboard. - + From %1 - + New photo - + New screenshot - + No recent photos found on the phone. - + Opening latest photo… - + Saved %1 items to %2. - + Saved %1 to %2. - + That photo isn't on the phone anymore. - + The phone doesn't share photos or files with this PC. - + The phone isn't connected right now. - + The photo couldn't be copied to the clipboard. - + The photo couldn't be copied. It's in Downloads\Nectarlink. @@ -5474,7 +5659,7 @@ What it needs PhotosPage - + All photos @@ -5485,259 +5670,259 @@ What it needs - + Today - + Yesterday - + %1 B - + %1 KB - + %1 MB - + %1 GB - + %1 TB - + Choose default folder for saved photos - + Save to folder - + Photos - - + + %1 isn't connected - + Pair your phone to browse photos and albums. - - + + Connect %1 to view photos. - + Albums - + Refresh - + %1+ items - + 1 item - + %1 items - + Cancel - + Photo - + Take photo - + Scan - + Scan document - + Select - + Cancel selection - + %1 selected - + Select items - + Click items to select - + Select all - - + + Copy - + Open - + Save (%1) - - + + Save - - + + Save to folder… - + Photos are off for %1 - + Allow photos on %1 - + Couldn't load photos - + Enable Photos in Settings. - + Grant photos permission on %1. - + Unable to load photos. - + Try again - + No photos or videos - + Back to gallery - - + + Play video - + Open in Photos - + Loading full resolution… - + Previous photo - + Next photo - + %1 - + Select photo @@ -5745,32 +5930,32 @@ What it needs PlayOnPhone - + Connect your phone to play on it. - + Media is turned off for %1. - + Opening in %1 on %2. Tap the notification there. - + Sent to %1. Tap the notification there. - + Tap the notification on %1 to watch. - + Update NectarLink on %1 to play on it. @@ -5778,7 +5963,7 @@ What it needs Prefs - + Connected to OBS. @@ -5786,7 +5971,7 @@ What it needs Recordings - + Recording from %1 saved @@ -5802,57 +5987,57 @@ What it needs SendTo - + Copied to your phone's clipboard. - + Couldn't reach %1. - + Couldn't send %1 - + Couldn't send to %1 - + Couldn't share with %1 - + File failed integrity check; partial file kept to resume. - + Send to %1 with NectarLink - + Sending to %1… - + Sent to %1 - + The transfer didn't go through. - + Waiting for %1… @@ -5901,7 +6086,7 @@ What it needs - + Notifications @@ -6021,1287 +6206,1334 @@ What it needs - - + + %1 B - + %1 KB - + %1 MB - + %1 GB - + %1 TB - + Messages - - + + Calls - + Contacts - + Clipboard - + Files - - + + Voice recordings - + Media controls - + Photos - + Phone controls - + Lock, sleep & wake PC - + Screen mirroring - + Share PC screen to phone - + Webcam - + File Explorer integration - Remote mouse, keyboard & Deck + File manager integration - Run Deck shell commands + Remote mouse, keyboard & Deck + Run Deck shell commands + + + + Remote PC file access - + Allow this phone to view this PC's screen. - + Only enable for a phone you trust. - + Calls, messages & notifications - + Files, photos & clipboard - + Screen, camera & control - + Back up photos to - + Save recordings in - + Pair a new device - + Theme - + Hive - + Bloom - + Glass - + Graphite - + Mode - - + + System - + Light - + Dark - + Color - + Wallpaper - + AMOLED - - - + + + Pure black (AMOLED) - - + + Media artwork - - + + Animated comb - + + Still while animations are off in system settings. + + + + Still while animations are off in Windows. - + Still while energy saver is on. - + The honeycomb breathes and pulses. - + Pitch-black #000000 surfaces. - - + + Mica backdrop - + Tint window with desktop wallpaper. - - + + Language - + + NectarLink follows the system unless you pick one. + + + + NectarLink follows Windows unless you pick one. - + More - - + + Start with Windows - + + + Start at login + + + + Launch in the tray when you sign in. - - + + Stay in system tray - + Keep running when the window is closed. - - + + Nectar Island - + Top-screen pill for calls, transfers & media. - - + + Battery alerts - + Notify when phone battery is low or full. - + Island triggers - + Choose when the top pill appears. - + Transfers - + Media - + Alerts - - + + Sync clipboard to phone - + Share copied text and images with your phone. - - + + Clipboard follows control - + Send your clipboard to the phone when you start controlling it, or when the mouse moves into its window. - - + + Copy one-time codes - + Auto-copy verification codes from texts. - - + + Copy received files - + Ready to Ctrl+V incoming files anywhere. - - + + Suggest clipboard actions - + Open links, maps, numbers & emails from phone clips. - + No phones paired yet - + Pair your phone to get started. - + Pair a phone - + Connected - + Not connected - - + + Unpair - + Join this phone's hotspot - + When Instant Hotspot turns it on, this PC joins %1. - + + Join the phone's hotspot once from your system Wi-Fi list, then choose it here. + + + + Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here. - + Choose network - - - - - - + + + + + + Open - - + + Stream PC audio to phone - + Listen to PC audio through your phone. - - + + Pause media during calls - + Auto-pause PC media when phone rings. - - + + Mute this PC while a phone watches - + With Play on phone, the phone still gets the sound. - + Phone as microphone - + Live from %1, playing into %2. - + Tap Start on %1. - + Plays into %1, so apps can pick its other end as a microphone. - + Plays into %1. To use it as a microphone in other apps, install a virtual audio cable such as VB-CABLE. - + Output - + Start - + Stop - - + + Microphone volume - - + + %1% - + Virtual camera add-on - + Registered for Zoom, Teams & Meet. - + One-time setup (admin required) for Zoom, Teams & Meet. - - + + Ready - - - + + + Remove - + Set up - + Phone - + Camera - + Back - + Front - - + + Mirror image - + Resolution - + Preview · %1 - + Pair a phone to use its camera as a webcam - + Stop webcam - + Start webcam - - + + Follow phone Do not disturb - + Mute pop-ups while phone is on DND. - - + + Forward PC notifications - + Send PC alerts to phone while away. - + Quiet hours - + Configure automatic DND schedule in Windows. - + Set a schedule - + Show - + No pop-ups - + Hide - - + + File Explorer Send to - + + + File manager Send to + + + + Send files from right-click menu. - - + + Your PC in your pocket - + Share Downloads, Documents, Desktop & Pictures. - - + + LocalSend on local network - + Share with nearby LocalSend devices without pairing. - - + + Open files shared from the phone - + Opens a shared photo or document as soon as it arrives. - - - - - + + + + + Reset - - + + Choose folder - + Photo backup - + Copies new photos from every album into a folder on this PC, by year and month. Nothing is deleted, here or on the phone. Needs only the phone's photos permission. - + Back up photos from %1 - + Backup folder - + Include videos - + Videos can be large. Copied one at a time, so a lost connection costs at most one file. - + Back up now - + Synced folders - + One-way or two-way folder sync between phone and PC. - + Sync now - + Add DCIM → Pictures - + Two-way sync · conflict copies kept - + One-way · phone to PC - + Command palette - + Open from any app. - + Command palette shortcut - + File shelf - + Toggle screen-edge shelf. - + Shelf shortcut - + Take photo - + Capture & paste photo. - + Take photo shortcut - + Scan document - + Scan & paste document. - + Scan document shortcut - + Trigger-action rules - + Run actions when your phone connects, disconnects, or rings. - + Lock PC on disconnect - + Pause media on ring - + OBS Studio - + For Deck tiles that run obs: commands. Turn on OBS's WebSocket server in Tools › WebSocket Server Settings. - + Port - + OBS WebSocket port - + Password - + OBS WebSocket password - + Saved - + None - + Save - + Test connection - + Remove password - - + + Clipboard history - + %1 clips · %2 · kept 7 days - - - - - - + + + + + + Clear - - + + Notification history - + %1 alerts · %2 · kept 24h - + Cross-device timeline - + 1 item stored (max 5,000). - + %1 items stored (max 5,000). - + 30d - + 90d - + 1y - + All - + Cached messages - + 1 chat - + %1 chats - + 1 message - + %1 messages - + %1 · %2 · %3 · kept 90 days - + Photo thumbnails - + %1 cached · %2 · kept 30 days - + Transfer history - + %1 records · kept with timeline - + Clear all local history & caches - + Wipes local history and caches; keeps paired devices and settings. - + Clear everything… - + Connection Doctor - + Diagnose and fix firewall, network, or VPN issues. - + Check - + Connect over mobile data - + Stay connected over LTE/5G or relay when away from Wi-Fi. - + Wake from your phone - + No active network adapter found. - + %1 · Wake on Magic Packet ready. - + %1 · Enable Wake on Magic Packet in Device Manager. - + Off in Windows - + + Off in the adapter's settings + + + + Check settings - + No adapter - - + + Local automation API - + Listening on 127.0.0.1:%1 · Token in %2 - + Local HTTP/SSE server (127.0.0.1:%1) for scripts and tools. - + NectarLink %1 - + This PC: %1 - + Update download - + Update to %1 - - - + + + Check for updates - + Open logs - + Looks for a new version on GitHub once a day. - - + + Install updates automatically - + When nobody is using the PC and nothing is in progress. - + Setup walkthrough - + Re-open the setup guide. - + Features guide - + What each feature needs and where to find it. - + Phone's hotspot network - + + Wi-Fi networks this PC has joined before. Your system keeps their passwords; NectarLink never sees them. + + + + + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from your system Wi-Fi list first. + + + + Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them. - + This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first. - + Don't join - - - + + + Close - + Play the phone's microphone into - + Unpair %1? - + Both devices forget each other. You can pair them again any time. - - + + Cancel - + Clear all local history & caches? - + Deletes clipboard history, Timeline, notification history, cached messages, photo thumbnails and transfer history on this PC. Paired devices, settings and downloaded files are kept. - + Clear everything @@ -7309,25 +7541,35 @@ What it needs Setup - + Couldn't start setup: %1 - + + Couldn't uninstall NectarLink: %1 + + + + Setup is taking too long. Try again after restarting the PC. - + Setup stopped with code %1. - + The uninstaller is missing. Reinstall NectarLink, then uninstall it. + + + Uninstall stopped with code %1. + + SetupWindow @@ -7529,164 +7771,164 @@ What it needs - + %1 B - + %1 KB - + %1 MB - + %1 GB - + Shelf - + No phone connected - + Refresh Shelf - + Close Shelf - + Release to send to %1 - + Sent to %1 - + Send cancelled - + Drop or flick items here to send - + Sending to %1… - + Undo - + Latest photo - + No recent photo - + Latest screenshot - + No recent screenshot - + Copy - + Open - + Current clip - + Send clip to phone - - + + Clip - + Copy clip - - + + Clipboard is empty - + Current clip: %1 - + Recent received files - + No received files yet - + Send to phone - + Open file - + Show in folder - + Flick any card up to send to phone, drag out to use, or drop files in @@ -7694,147 +7936,147 @@ What it needs Timeline - + App window #%1 - + Clip (removed from clipboard history) - + Copied image - + Copied link to clipboard. - + Copied to clipboard. - + From %1 - + In progress - + No phone is paired yet. - + Opened from %1 - + Opened on %1 - + Phone microphone - + Phone webcam - + Recorded on %1 - + Saved from %1 - + Screen mirroring - + Sending to %1… - + Sent clip to %1. - + Sent to %1 - + Session - + That clip is no longer in clipboard history. - + That entry couldn't be removed. Try again. - + That file was moved or deleted. - + That isn't a web link. - + That photo was moved or deleted. - + The Timeline couldn't be cleared. Try again. - + The photo couldn't be copied to the clipboard. - + Voice recording - + With %1 - + device @@ -7879,8 +8121,8 @@ What it needs - - + + All devices @@ -7895,92 +8137,92 @@ What it needs - + Search timeline - + Search timeline… - + Clear search - + Clear all - + Device filter: %1 - + Clear timeline history? Saved files on disk are kept. - + Cancel - + Clear timeline - + No matching items - + No recent activity - + Clear the search or filter. - + Shared files, clips, links, and photos appear here. - + Open - + Show in folder - + Copy - + Send again - + Remove from timeline - + Load more @@ -8126,37 +8368,37 @@ What it needs Transfers - + Accept in NectarLink to save to Downloads\Nectarlink - + Files are turned off for that device. - + From %1, opened from Downloads\Nectarlink - + From %1, saved in Downloads\Nectarlink - + Nearby device - + That's too many files to send at once. - + There's nothing to send in there. @@ -8164,67 +8406,67 @@ What it needs Updater - + Couldn't check for updates. Check your internet connection. - + NectarLink %1 is available - + NectarLink %1 is available. - + NectarLink is still on the version you had. Try again from Settings. - + NectarLink is up to date. - + NectarLink is updated to %1 - + The download was damaged, so it wasn't installed. Try again. - + The update couldn't be downloaded. Check your internet connection and try again. - + The update couldn't be saved. Check that there's free space and try again. - + The update couldn't be started. Try again. - + The update didn't install - + Update now, or later from Settings. - + Your phone reconnects on its own. @@ -8232,191 +8474,202 @@ What it needs WalkthroughPage - + Skip setup - + Notifications and Calls - + Clipboard and Send files - + Mirroring, Camera, and Audio - + Direct local QUIC link - + End-to-end encrypted over QUIC - + Hardware-backed device keys - + Local network by default - + Option 1: Download the Android APK (Android 8.0+) - + Open the latest GitHub Release on your phone or copy the release link. - + Open release page - + Option 2: Install over USB with ADB - + Paired with %1. You can continue to the next step. - - + + your phone - - + + On by default - - + + Off until you turn them on - + Change these per phone on Home after pairing. - + Sharing settings for %1 - + Notifications - + Clipboard - + Send files - + Now playing - + Messages - + Calls - + Screen mirroring - + Remote input - + Share PC folders - + Windows notifications - + + Desktop notifications + + + + Windows Settings - + Recheck - - + + Start with Windows + + Start at login + + + + Keep NectarLink ready in the system tray when you sign in. - + Firewall and network check - + Command Palette (Ctrl+K) - + Features guide - + Back - + Open features guide @@ -8424,12 +8677,12 @@ What it needs Webcam - + Starting camera on %1… - + Streaming %1 × %2 · %3 fps from %4 @@ -8460,17 +8713,17 @@ What it needs Widget - + No phone connected - + No recent clipboard items - + Not available diff --git a/desktop/app/qml/MainWindow.qml b/desktop/app/qml/MainWindow.qml index 276f41c3..1bad044b 100644 --- a/desktop/app/qml/MainWindow.qml +++ b/desktop/app/qml/MainWindow.qml @@ -36,10 +36,8 @@ NativeWindow { if (targetId.indexOf("__grab__:") === 0) { const outPath = targetId.substring("__grab__:".length) AppController.clearHighlight() - if (window.contentItem && outPath.length > 0) { - window.contentItem.grabToImage(function(result) { - result.saveToFile(outPath) - }) + if (outPath.length > 0) { + window.grabToFile(outPath) } return } diff --git a/desktop/app/qml/components/DoctorSheet.qml b/desktop/app/qml/components/DoctorSheet.qml index cb71e378..e2b1cc81 100644 --- a/desktop/app/qml/components/DoctorSheet.qml +++ b/desktop/app/qml/components/DoctorSheet.qml @@ -10,6 +10,10 @@ Sheet { readonly property var checks: { try { return JSON.parse(AppController.doctorChecks) } catch (e) { return [] } } readonly property bool allWell: checks.length > 0 && checks.every(c => c.outcome === "ok") + // Checks for features that need setup (ids "linux-..."), as opposed to + // what stands between the phone and this PC. + readonly property bool connectionWell: checks.length > 0 + && checks.every(c => c.outcome === "ok" || c.id.startsWith("linux-")) function fixLabel(fix) { switch (fix) { @@ -49,83 +53,95 @@ Sheet { ? qsTr("Checking network and firewall…") : sheet.allWell ? qsTr("Everything looks right.") - : qsTr("Issues blocking your phone from reaching this PC:") + : sheet.connectionWell + ? qsTr("Your phone can reach this PC. A few features need a step from you:") + : qsTr("Issues blocking your phone from reaching this PC:") } - Column { + Flickable { width: parent.width - spacing: 2 - opacity: AppController.doctorBusy ? 0.55 : 1 - Behavior on opacity { NumberAnimation { duration: Theme.fadeNormal } } - Repeater { - model: sheet.checks - delegate: Rectangle { - id: row - required property var modelData - readonly property bool hasFix: row.modelData.fix !== undefined && row.modelData.fix !== "" - width: parent.width - height: Math.max(badge.height, words.height, actionsRow.height) + 20 - radius: Theme.radiusMd - color: "transparent" + height: Math.min(checksColumn.height, 360) + contentWidth: width + contentHeight: checksColumn.height + clip: true + boundsBehavior: Flickable.StopAtBounds - Rectangle { - id: badge - x: 8 - anchors.verticalCenter: parent.verticalCenter - width: 28; height: 28 - radius: 14 - color: row.modelData.outcome === "ok" ? Theme.secondaryContainer - : row.modelData.outcome === "warn" ? Qt.alpha(Theme.warning, 0.18) - : Qt.alpha(Theme.error, 0.16) - Icon { - anchors.centerIn: parent - width: 16; height: 16 - path: row.modelData.outcome === "ok" ? Icons.check : Icons.warning - color: row.modelData.outcome === "ok" ? Theme.secondaryContainerContent - : row.modelData.outcome === "warn" ? Theme.warning : Theme.error + Column { + id: checksColumn + width: parent.width + spacing: 2 + opacity: AppController.doctorBusy ? 0.55 : 1 + Behavior on opacity { NumberAnimation { duration: Theme.fadeNormal } } + Repeater { + model: sheet.checks + delegate: Rectangle { + id: row + required property var modelData + readonly property bool hasFix: row.modelData.fix !== undefined && row.modelData.fix !== "" + width: parent.width + height: Math.max(badge.height, words.height, actionsRow.height) + 20 + radius: Theme.radiusMd + color: "transparent" + + Rectangle { + id: badge + x: 8 + anchors.verticalCenter: parent.verticalCenter + width: 28; height: 28 + radius: 14 + color: row.modelData.outcome === "ok" ? Theme.secondaryContainer + : row.modelData.outcome === "warn" ? Qt.alpha(Theme.warning, 0.18) + : Qt.alpha(Theme.error, 0.16) + Icon { + anchors.centerIn: parent + width: 16; height: 16 + path: row.modelData.outcome === "ok" ? Icons.check : Icons.warning + color: row.modelData.outcome === "ok" ? Theme.secondaryContainerContent + : row.modelData.outcome === "warn" ? Theme.warning : Theme.error + } } - } - Column { - id: words - anchors.left: badge.right - anchors.leftMargin: 14 - anchors.right: actionsRow.visible ? actionsRow.left : parent.right - anchors.rightMargin: actionsRow.visible ? 12 : 8 - anchors.verticalCenter: parent.verticalCenter - spacing: 3 - Txt { width: parent.width; text: row.modelData.title; role: "title"; size: 14; wrapMode: Text.WordWrap } - Txt { - width: parent.width - visible: row.modelData.detail.length > 0 - text: row.modelData.detail - role: "bodySmall" - muted: true - wrapMode: Text.WordWrap + Column { + id: words + anchors.left: badge.right + anchors.leftMargin: 14 + anchors.right: actionsRow.visible ? actionsRow.left : parent.right + anchors.rightMargin: actionsRow.visible ? 12 : 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Txt { width: parent.width; text: row.modelData.title; role: "title"; size: 14; wrapMode: Text.WordWrap } + Txt { + width: parent.width + visible: row.modelData.detail.length > 0 + text: row.modelData.detail + role: "bodySmall" + muted: true + wrapMode: Text.WordWrap + } } - } - Row { - id: actionsRow - anchors.right: parent.right - anchors.rightMargin: 6 - anchors.verticalCenter: parent.verticalCenter - spacing: 6 - visible: row.hasFix + Row { + id: actionsRow + anchors.right: parent.right + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + visible: row.hasFix - Button { - visible: row.modelData.fix === "firewall" - enabled: !AppController.doctorBusy - variant: "tonal" - size: "sm" - text: qsTr("Firewall settings") - onClicked: AppController.doctorFix("firewall-settings") - } - Button { - id: fixButton - enabled: !AppController.doctorBusy - variant: row.modelData.outcome === "fail" ? "fill" : "tonal" - size: "sm" - text: sheet.fixLabel(row.modelData.fix) - onClicked: AppController.doctorFix(row.modelData.fix) + Button { + visible: row.modelData.fix === "firewall" + enabled: !AppController.doctorBusy + variant: "tonal" + size: "sm" + text: qsTr("Firewall settings") + onClicked: AppController.doctorFix("firewall-settings") + } + Button { + id: fixButton + enabled: !AppController.doctorBusy + variant: row.modelData.outcome === "fail" ? "fill" : "tonal" + size: "sm" + text: sheet.fixLabel(row.modelData.fix) + onClicked: AppController.doctorFix(row.modelData.fix) + } } } } diff --git a/desktop/app/qml/pages/AppsPage.qml b/desktop/app/qml/pages/AppsPage.qml index 0aac3d78..42c33109 100644 --- a/desktop/app/qml/pages/AppsPage.qml +++ b/desktop/app/qml/pages/AppsPage.qml @@ -87,7 +87,9 @@ Item { muted: true text: page.deviceId.length === 0 ? qsTr("Pair your phone to open its apps in windows on this PC.") - : (page.feature.reason || qsTr("Set up Wireless debugging in NectarLink on the phone to open its apps here.")) + : page.feature.reason === "notOnThisDevice" + ? qsTr("Opening apps in windows isn't supported on this phone.") + : (page.feature.reason || qsTr("Set up Wireless debugging in NectarLink on the phone to open its apps here.")) } LockChip { anchors.horizontalCenter: parent.horizontalCenter diff --git a/desktop/app/qml/pages/HomePage.qml b/desktop/app/qml/pages/HomePage.qml index e7d0aa5c..4952ec1d 100644 --- a/desktop/app/qml/pages/HomePage.qml +++ b/desktop/app/qml/pages/HomePage.qml @@ -1730,10 +1730,12 @@ Item { Icon { anchors.verticalCenter: parent.verticalCenter; path: Icons.bell; color: Theme.surfaceContentVariant } Txt { anchors.verticalCenter: parent.verticalCenter - width: parent.width - 32 - turnOn.width - 24 + width: parent.width - 32 - (turnOn.visible ? turnOn.width + 24 : 0) role: "bodySmall" wrapMode: Text.WordWrap - text: qsTr("Windows notifications are off.") + text: Qt.platform.os === "linux" + ? qsTr("Desktop notifications are off. Turn them on in your system's notification settings.") + : qsTr("Windows notifications are off.") } Button { id: turnOn @@ -1741,6 +1743,7 @@ Item { variant: "tonal" size: "sm" text: qsTr("Turn on") + visible: Qt.platform.os === "windows" onClicked: Qt.openUrlExternally("ms-settings:notifications") } } @@ -2548,7 +2551,9 @@ Item { ? qsTr("Hidden on this PC.") : appSheet.rule === "quiet" ? qsTr("Shown in feed without pop-ups.") - : qsTr("Shown in feed and as Windows pop-ups.") + : Qt.platform.os === "linux" + ? qsTr("Shown in feed and as desktop pop-ups.") + : qsTr("Shown in feed and as Windows pop-ups.") } Row { anchors.right: parent.right diff --git a/desktop/app/qml/pages/SettingsPage.qml b/desktop/app/qml/pages/SettingsPage.qml index 668af9d4..4c70a3a4 100644 --- a/desktop/app/qml/pages/SettingsPage.qml +++ b/desktop/app/qml/pages/SettingsPage.qml @@ -83,7 +83,7 @@ Item { if (event.modifiers & Qt.ControlModifier) mods.push("Ctrl") if (event.modifiers & Qt.AltModifier) mods.push("Alt") if (event.modifiers & Qt.ShiftModifier) mods.push("Shift") - if (event.modifiers & Qt.MetaModifier) mods.push("Win") + if (event.modifiers & Qt.MetaModifier) mods.push(Qt.platform.os === "linux" ? "Super" : "Win") if (mods.length === 0) return "" let keyName = "" @@ -167,7 +167,7 @@ Item { mirroring: qsTr("Screen mirroring"), pc_mirror: qsTr("Share PC screen to phone"), webcam: qsTr("Webcam"), - storage: qsTr("File Explorer integration"), + storage: Qt.platform.os === "linux" ? qsTr("File manager integration") : qsTr("File Explorer integration"), remote_input: qsTr("Remote mouse, keyboard & Deck"), commands: qsTr("Run Deck shell commands"), remote_files: qsTr("Remote PC file access") @@ -800,7 +800,9 @@ Item { iconPath: Icons.sparkle title: qsTr("Animated comb") description: AppController.reduceMotion - ? qsTr("Still while animations are off in Windows.") + ? (Qt.platform.os === "linux" + ? qsTr("Still while animations are off in system settings.") + : qsTr("Still while animations are off in Windows.")) : (AppController.powerSaver ? qsTr("Still while energy saver is on.") : qsTr("The honeycomb breathes and pulses.")) @@ -814,16 +816,19 @@ Item { // Side-by-side when Graphite Dark shows Pure Black + Mica backdrop Item { id: appearanceBottomPair - visible: !Theme.hive - width: parent.width readonly property bool showGraphiteAmoled: Theme.graphite && Theme.dark - readonly property bool splitCols: showGraphiteAmoled && width >= 460 + readonly property bool showMica: Qt.platform.os === "windows" + visible: !Theme.hive && (showMica || showGraphiteAmoled) + width: parent.width + readonly property bool splitCols: showGraphiteAmoled && showMica && width >= 460 readonly property real colWidth: splitCols ? Math.floor((width - 37) / 2) : width implicitHeight: !showGraphiteAmoled - ? micaRow.implicitHeight - : (splitCols - ? Math.max(graphiteAmoledRow.implicitHeight, micaRow.implicitHeight) - : (graphiteAmoledRow.implicitHeight + 1 + micaRow.implicitHeight)) + ? (showMica ? micaRow.implicitHeight : 0) + : (!showMica + ? graphiteAmoledRow.implicitHeight + : (splitCols + ? Math.max(graphiteAmoledRow.implicitHeight, micaRow.implicitHeight) + : (graphiteAmoledRow.implicitHeight + 1 + micaRow.implicitHeight))) ListRow { id: graphiteAmoledRow @@ -851,13 +856,14 @@ Item { } Divider { - visible: appearanceBottomPair.showGraphiteAmoled && !appearanceBottomPair.splitCols + visible: appearanceBottomPair.showGraphiteAmoled && appearanceBottomPair.showMica && !appearanceBottomPair.splitCols y: graphiteAmoledRow.implicitHeight width: parent.width } ListRow { id: micaRow + visible: appearanceBottomPair.showMica x: appearanceBottomPair.splitCols ? (parent.width - width) : 0 y: !appearanceBottomPair.showGraphiteAmoled ? 0 @@ -898,7 +904,9 @@ Item { width: parent.width iconPath: Icons.globe title: qsTr("Language") - description: qsTr("NectarLink follows Windows unless you pick one.") + description: Qt.platform.os === "linux" + ? qsTr("NectarLink follows the system unless you pick one.") + : qsTr("NectarLink follows Windows unless you pick one.") Segmented { readonly property var other: page.languages.find( (l) => l.code === Preferences.language && l.code !== "en") @@ -936,10 +944,10 @@ Item { y: startupPairRow.splitCols ? Math.round((parent.height - height) / 2) : 0 width: startupPairRow.colWidth iconPath: Icons.power - title: qsTr("Start with Windows") + title: Qt.platform.os === "linux" ? qsTr("Start at login") : qsTr("Start with Windows") description: qsTr("Launch in the tray when you sign in.") Toggle { - label: qsTr("Start with Windows") + label: Qt.platform.os === "linux" ? qsTr("Start at login") : qsTr("Start with Windows") checked: Preferences.startWithWindows onToggled: (on) => Preferences.startWithWindows = on } @@ -1390,7 +1398,9 @@ Item { title: qsTr("Join this phone's hotspot") description: hotspotRow.network.length > 0 ? qsTr("When Instant Hotspot turns it on, this PC joins %1.").arg(hotspotRow.network) - : qsTr("Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here.") + : (Qt.platform.os === "linux" + ? qsTr("Join the phone's hotspot once from your system Wi-Fi list, then choose it here.") + : qsTr("Join the phone's hotspot once from Windows' Wi-Fi list, then choose it here.")) Button { variant: "tonal" size: "sm" @@ -1960,9 +1970,10 @@ Item { } } - Divider { width: parent.width } + Divider { width: parent.width; visible: Qt.platform.os === "windows" } ListRow { width: parent.width + visible: Qt.platform.os === "windows" iconPath: Icons.moon title: qsTr("Quiet hours") description: qsTr("Configure automatic DND schedule in Windows.") @@ -2056,10 +2067,10 @@ Item { y: filesSharePairRow.splitCols ? Math.round((parent.height - height) / 2) : 0 width: filesSharePairRow.colWidth iconPath: Icons.send - title: qsTr("File Explorer Send to") + title: Qt.platform.os === "linux" ? qsTr("File manager Send to") : qsTr("File Explorer Send to") description: qsTr("Send files from right-click menu.") Toggle { - label: qsTr("File Explorer Send to") + label: Qt.platform.os === "linux" ? qsTr("File manager Send to") : qsTr("File Explorer Send to") checked: Preferences.sendToMenu onToggled: (on) => Preferences.sendToMenu = on } @@ -3142,7 +3153,8 @@ Item { role: "bodySmall" muted: true text: AppController.wakeState === "enabled" ? qsTr("Ready") - : AppController.wakeState === "disabled" ? qsTr("Off in Windows") + : AppController.wakeState === "disabled" + ? (Qt.platform.os === "linux" ? qsTr("Off in the adapter's settings") : qsTr("Off in Windows")) : AppController.wakeState === "unknown" ? qsTr("Check settings") : qsTr("No adapter") } @@ -3361,9 +3373,13 @@ Item { wrapMode: Text.WordWrap role: "body" muted: true - text: hotspotSheet.networks.length > 0 - ? qsTr("Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them.") - : qsTr("This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first.") + text: Qt.platform.os === "linux" + ? (hotspotSheet.networks.length > 0 + ? qsTr("Wi-Fi networks this PC has joined before. Your system keeps their passwords; NectarLink never sees them.") + : qsTr("This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from your system Wi-Fi list first.")) + : (hotspotSheet.networks.length > 0 + ? qsTr("Wi-Fi networks this PC has joined before. Windows keeps their passwords; NectarLink never sees them.") + : qsTr("This PC hasn't saved any Wi-Fi networks yet. Join the phone's hotspot once from Windows' Wi-Fi list first.")) } // A PC can have saved dozens of networks: the list scrolls. ListView { diff --git a/desktop/app/qml/pages/WalkthroughPage.qml b/desktop/app/qml/pages/WalkthroughPage.qml index dadc123f..32d25f56 100644 --- a/desktop/app/qml/pages/WalkthroughPage.qml +++ b/desktop/app/qml/pages/WalkthroughPage.qml @@ -189,7 +189,9 @@ FocusScope { if (AppController.doctorBusy) return "Checking firewall and local network..." if (parsedDoctorChecks.length === 0) - return "Run a quick check to verify Windows Firewall and local discovery." + return Qt.platform.os === "linux" + ? "Run a quick check to verify your firewall and local discovery." + : "Run a quick check to verify Windows Firewall and local discovery." let problems = 0 for (let i = 0; i < parsedDoctorChecks.length; i++) { if (parsedDoctorChecks[i].status !== "ok") @@ -1461,15 +1463,19 @@ FocusScope { Txt { Layout.fillWidth: true - text: qsTr("Windows notifications") + text: Qt.platform.os === "linux" ? qsTr("Desktop notifications") : qsTr("Windows notifications") role: "title" } Txt { Layout.fillWidth: true text: AppController.toastsEnabled - ? "Enabled in Windows Settings." - : "Notifications are off for NectarLink in Windows Settings." + ? (Qt.platform.os === "linux" + ? "Enabled in desktop notification settings." + : "Enabled in Windows Settings.") + : (Qt.platform.os === "linux" + ? "Notifications are muted or Do Not Disturb is active." + : "Notifications are off for NectarLink in Windows Settings.") role: "caption" color: Theme.surfaceContentVariant wrapMode: Text.WordWrap @@ -1483,7 +1489,7 @@ FocusScope { text: qsTr("Windows Settings") variant: "tonal" size: "sm" - visible: !AppController.toastsEnabled + visible: !AppController.toastsEnabled && Qt.platform.os === "windows" Accessible.name: "Open Windows notification settings" onClicked: Qt.openUrlExternally("ms-settings:notifications") } @@ -1493,7 +1499,7 @@ FocusScope { iconPath: Icons.refresh variant: "text" size: "sm" - Accessible.name: "Recheck Windows notification status" + Accessible.name: "Recheck notification status" onClicked: AppController.refreshToastsEnabled() } } @@ -1539,7 +1545,7 @@ FocusScope { Txt { Layout.fillWidth: true - text: qsTr("Start with Windows") + text: Qt.platform.os === "linux" ? qsTr("Start at login") : qsTr("Start with Windows") role: "title" } @@ -1553,7 +1559,7 @@ FocusScope { } Toggle { - label: qsTr("Start with Windows") + label: Qt.platform.os === "linux" ? qsTr("Start at login") : qsTr("Start with Windows") checked: Preferences.startWithWindows onToggled: next => Preferences.startWithWindows = next } diff --git a/desktop/app/src/bridge/app.rs b/desktop/app/src/bridge/app.rs index bc299ae7..09c35fee 100644 --- a/desktop/app/src/bridge/app.rs +++ b/desktop/app/src/bridge/app.rs @@ -484,6 +484,7 @@ pub struct AppControllerRust { /// reach it. static CONTROLLER: OnceLock>>> = OnceLock::new(); static SHELF_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static QUIT_REQUESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[derive(Debug, Clone)] enum ToastAction { @@ -599,11 +600,18 @@ fn controller() -> Option> { /// Asks the app to quit (from any thread), as Quit in the tray does. pub fn request_quit() { + QUIT_REQUESTED.store(true, Ordering::Release); if let Some(qt) = controller() { let _ = qt.queue(|object| object.quit_requested()); } } +/// Whether [`request_quit`] has already been called (for example by a signal +/// arriving while the QML engine is still loading). +pub fn is_quit_requested() -> bool { + QUIT_REQUESTED.load(Ordering::Acquire) +} + /// Asks the UI to show the main window (from any thread). pub fn request_activation() { if let Some(qt) = controller() { @@ -750,6 +758,9 @@ impl cxx_qt::Initialize for qobject::AppController { self.as_mut().refresh_localsend_state(); let qt = self.qt_thread(); *CONTROLLER.get_or_init(Mutex::default).lock().unwrap_or_else(|e| e.into_inner()) = Some(qt.clone()); + if QUIT_REQUESTED.load(Ordering::Acquire) { + let _ = qt.queue(|object| object.quit_requested()); + } if let Some(task) = PENDING_JUMP_TASK.lock().unwrap_or_else(|e| e.into_inner()).take() { dispatch_jump_task(&qt, task); } @@ -2222,7 +2233,7 @@ fn load_guide_doc_content(raw_key: &str) -> (String, String) { tr!("Guide", "Install and first-time setup"), tr!( "Guide", - "1. Install NectarLink on your Windows 11 PC and your Android phone (Android 8.0 or newer).\n\ + "1. Install NectarLink on your PC (Windows 11 or Linux) and your Android phone (Android 8.0 or newer).\n\ 2. Connect both devices to the same Wi-Fi network, or plug your phone into your PC with a USB cable.\n\ 3. Open the Pair step in the setup walkthrough, or click + (Pair a new device) at the bottom of the left rail.\n\ 4. Scan the QR code from the NectarLink Android app, or select your phone from the nearby devices list and confirm that the 6-digit code matches on both screens.\n\ diff --git a/desktop/app/src/bridge/native.rs b/desktop/app/src/bridge/native.rs index 6a0c85d9..3ef4e1ef 100644 --- a/desktop/app/src/bridge/native.rs +++ b/desktop/app/src/bridge/native.rs @@ -102,6 +102,18 @@ pub mod ffi { #[allow(dead_code)] fn linux_tray_wait_action(timeout_ms: u32) -> String; + #[allow(dead_code)] + fn linux_tray_is_available() -> bool; + + #[allow(dead_code)] + fn linux_work_area(x: &mut i32, y: &mut i32, w: &mut i32, h: &mut i32) -> bool; + + #[allow(dead_code)] + fn linux_screen_geometry(x: &mut i32, y: &mut i32, w: &mut i32, h: &mut i32) -> bool; + + #[allow(dead_code)] + fn linux_set_activation_token(token: &str); + #[allow(dead_code)] fn linux_clipboard_sequence() -> u64; @@ -110,5 +122,29 @@ pub mod ffi { #[allow(dead_code)] fn linux_clipboard_notify_external(); + + #[allow(dead_code)] + #[allow(clippy::too_many_arguments)] + fn linux_clipboard_read( + check_privacy: bool, + is_private: &mut bool, + has_files: &mut bool, + text: &mut String, + html: &mut String, + rtf: &mut String, + png_bytes: &mut Vec, + ) -> bool; + + #[allow(dead_code)] + fn linux_clipboard_read_files() -> Vec; + + #[allow(dead_code)] + fn linux_clipboard_write_text(text: &str, html: &str, rtf: &str, is_private: bool) -> bool; + + #[allow(dead_code)] + fn linux_clipboard_write_image_png(png: &[u8]) -> bool; + + #[allow(dead_code)] + fn linux_clipboard_write_files(paths: &[String]) -> bool; } } diff --git a/desktop/app/src/bridge/photos.rs b/desktop/app/src/bridge/photos.rs index ad9a0a37..ba3aa046 100644 --- a/desktop/app/src/bridge/photos.rs +++ b/desktop/app/src/bridge/photos.rs @@ -217,9 +217,13 @@ fn folder_from_url(url: &QString) -> Option { impl cxx_qt::Initialize for qobject::Photos { fn initialize(mut self: Pin<&mut Self>) { + #[cfg(windows)] + const DEFAULT_SAVE_FOLDER: &str = r"Downloads\Nectarlink"; + #[cfg(not(windows))] + const DEFAULT_SAVE_FOLDER: &str = "Downloads/Nectarlink"; self.as_mut().set_status(QString::from("idle")); self.as_mut().set_albums(QString::from("[]")); - self.as_mut().set_save_folder(QString::from(r"Downloads\Nectarlink")); + self.as_mut().set_save_folder(QString::from(DEFAULT_SAVE_FOLDER)); super::subscribe(self.qt_thread(), Changes::PHOTOS, Self::update_view); self.update_view(); } diff --git a/desktop/app/src/calls.rs b/desktop/app/src/calls.rs index 69893b44..d7d786ad 100644 --- a/desktop/app/src/calls.rs +++ b/desktop/app/src/calls.rs @@ -58,8 +58,11 @@ fn note_call_ended(device: DeviceId) { let all_done = ongoing_calls(|c| c.remove(&device) && c.is_empty()); if all_done { crate::win::media_sessions::resume_after_call(); - bt_hfp::disconnect_audio(); - refresh_hfp_cache(None); + let _ = std::thread::Builder::new().name("hfp-disconnect".into()).spawn(|| { + bt_hfp::disconnect_audio(); + refresh_hfp_cache(None); + page_changed(); + }); } } @@ -604,7 +607,7 @@ pub fn open_device(device: DeviceId) { } same }); - refresh_hfp_cache(Some(device)); + refresh_hfp_cache_async(Some(device)); if !same { page_changed(); } @@ -630,7 +633,7 @@ pub fn ensure_contacts(device: DeviceId) { pub fn reload() { let dev = page_state(|s| s.device); - refresh_hfp_cache(dev); + refresh_hfp_cache_async(dev); load_call_log(None); load_contacts(); } @@ -901,35 +904,52 @@ fn refresh_hfp_cache(device: Option) -> bt_hfp::HfpStatus { status } +fn refresh_hfp_cache_async(device: Option) { + let target = device.or_else(|| page_state(|s| s.device)); + let _ = std::thread::Builder::new().name("hfp-probe".into()).spawn(move || { + refresh_hfp_cache(target); + page_changed(); + }); +} + /// Returns the current Bluetooth HFP / PC audio status for `device`. pub fn hfp_status(device: Option) -> bt_hfp::HfpStatus { let target = device.or_else(|| page_state(|s| s.device)); - if let Ok(guard) = HFP_CACHE.lock() - && let Some((cached_dev, status)) = guard.as_ref() - && *cached_dev == target - { - return status.clone(); + if let Ok(mut guard) = HFP_CACHE.lock() { + if let Some((cached_dev, status)) = guard.as_ref() + && *cached_dev == target + { + return status.clone(); + } + let placeholder = bt_hfp::HfpStatus::default(); + *guard = Some((target, placeholder.clone())); + drop(guard); + refresh_hfp_cache_async(target); + return placeholder; } - refresh_hfp_cache(target) + bt_hfp::HfpStatus::default() } /// Opens a Bluetooth connection to route call audio through this PC. pub fn connect_pc_audio(device: Option) { let target = device.or_else(|| page_state(|s| s.device)); let name = target.map(phone_name); - match bt_hfp::connect_audio(name.as_deref()) { - Ok(status) => { - if let Ok(mut guard) = HFP_CACHE.lock() { - *guard = Some((target, status)); + let _ = + std::thread::Builder::new().name("hfp-connect".into()).spawn(move || { + match bt_hfp::connect_audio(name.as_deref()) { + Ok(status) => { + if let Ok(mut guard) = HFP_CACHE.lock() { + *guard = Some((target, status)); + } + page_changed(); + } + Err(err) => { + refresh_hfp_cache(target); + page_changed(); + show_message(err); + } } - page_changed(); - } - Err(err) => { - refresh_hfp_cache(target); - page_changed(); - show_message(err); - } - } + }); } /// Opens Windows Bluetooth & devices settings (`ms-settings:bluetooth`). diff --git a/desktop/app/src/core_host.rs b/desktop/app/src/core_host.rs index a150d274..d2517551 100644 --- a/desktop/app/src/core_host.rs +++ b/desktop/app/src/core_host.rs @@ -180,8 +180,11 @@ async fn run(data_dir: PathBuf, platform: Arc) { let wake = crate::win::wol::query_wake_status(); let mut config = NodeConfig::new(&data_dir, this_device(), env!("CARGO_PKG_VERSION")); config.downloads_dir = Some(downloads_dir()); - // NECTARLINK_NO_ADB=1 keeps a development copy from reaching phones plugged in over USB. - let adb_disabled = std::env::var_os("NECTARLINK_NO_ADB").is_some_and(|v| v == "1"); + // NECTARLINK_NO_ADB=1 keeps a development copy from reaching phones plugged in over USB, + // unless NECTARLINK_ADB_ONLY_SERIAL explicitly restricts ADB to an Android emulator. + let only_emulator = + std::env::var("NECTARLINK_ADB_ONLY_SERIAL").is_ok_and(|s| s.trim().starts_with("emulator-")); + let adb_disabled = !only_emulator && std::env::var_os("NECTARLINK_NO_ADB").is_some_and(|v| v == "1"); config.adb_server = (!adb_disabled).then_some(nectarlink_core::DEFAULT_ADB_ADDR); config.away_mode = crate::settings::Settings::load(&data_dir).away_mode; // This PC's own players, for phones (crate::win::media_sessions). diff --git a/desktop/app/src/doctor.rs b/desktop/app/src/doctor.rs index 14fafa9c..7c4f8ef9 100644 --- a/desktop/app/src/doctor.rs +++ b/desktop/app/src/doctor.rs @@ -3,7 +3,7 @@ //! and a button to fix what can be fixed from here (the firewall, the //! network's profile, reconnecting). //! -//! Checks run on a worker thread (they ask Windows a few slow questions) +//! Checks run on a worker thread (they ask the OS a few slow questions) //! and come back as a list for the UI. use crate::i18n::tr; @@ -44,7 +44,7 @@ pub struct DeviceFact { pub dial_failures: u32, } -/// What Windows said, gathered first so the wording can be tested. +/// What the OS said, gathered first so the wording can be tested. #[derive(Debug, Clone, Default)] pub struct Facts { pub local_address: bool, @@ -58,9 +58,12 @@ pub struct Facts { /// Optional helper tools missing from `PATH` on Linux. #[cfg(target_os = "linux")] pub missing_tools: Vec<&'static str>, + /// Linux-specific system readiness checks: `(id, title, ok, detail, fix)`. + #[cfg(target_os = "linux")] + pub linux_checks: Vec<(String, String, bool, String, Option<&'static str>)>, } -/// Asks Windows and the core (blocking; call on a worker thread). +/// Asks the OS and the core (blocking; call on a worker thread). pub fn gather() -> Facts { let adapters = windows_checks::adapters(); let networks = windows_checks::networks() @@ -92,6 +95,8 @@ pub fn gather() -> Facts { devices, #[cfg(target_os = "linux")] missing_tools: windows_checks::missing_tools(), + #[cfg(target_os = "linux")] + linux_checks: windows_checks::linux_checks(), } } @@ -215,12 +220,23 @@ pub fn diagnose(facts: &Facts, now_secs: i64) -> Vec { checks.push(check("network", Outcome::Ok, title, "", None)); } if let Some((name, _)) = facts.networks.iter().find(|(_, trusted)| !trusted) { + #[cfg(target_os = "linux")] + let profile_detail = tr!( + "Doctor", + "On public networks the firewall keeps other devices from finding this PC. If it's your home or work \ + network, open network settings and set its firewall zone to Home or Trusted." + ); + #[cfg(not(target_os = "linux"))] + let profile_detail = tr!( + "Doctor", + "On public networks Windows keeps other devices from finding this PC. If it's your home or work \ + network, set it to Private." + ); checks.push(check( "profile", Outcome::Warn, tr!("Doctor", "%1 is set as a public network", name), - tr!("Doctor", "On public networks Windows keeps other devices from finding this PC. If it's your home or work \ - network, set it to Private."), + profile_detail, Some("network-settings"), )); } @@ -229,38 +245,51 @@ pub fn diagnose(facts: &Facts, now_secs: i64) -> Vec { match facts.firewall { None => {} Some(f) if !f.on => { + #[cfg(target_os = "linux")] + let title = tr!("Doctor", "The firewall is off for this network"); + #[cfg(not(target_os = "linux"))] + let title = tr!("Doctor", "Windows Firewall is off for this network"); + checks.push(check("firewall", Outcome::Ok, title, "", None)); + } + Some(f) if f.blocked => { + #[cfg(target_os = "linux")] + let (title, detail) = ( + tr!("Doctor", "The firewall blocks NectarLink"), + tr!( + "Doctor", + "A firewall rule blocks incoming NectarLink connections. Allow it, and your phone can connect." + ), + ); + #[cfg(not(target_os = "linux"))] + let (title, detail) = ( + tr!("Doctor", "Windows Firewall blocks NectarLink"), + tr!( + "Doctor", + "Someone chose not to allow it when Windows asked. Allow it, and your phone can connect." + ), + ); + checks.push(check("firewall", Outcome::Fail, title, detail, Some("firewall"))); + } + Some(f) if !f.allowed => { + #[cfg(target_os = "linux")] + let title = tr!("Doctor", "NectarLink isn't allowed through the firewall"); + #[cfg(not(target_os = "linux"))] + let title = tr!("Doctor", "NectarLink isn't allowed through Windows Firewall"); checks.push(check( "firewall", - Outcome::Ok, - tr!("Doctor", "Windows Firewall is off for this network"), - "", - None, + Outcome::Warn, + title, + tr!("Doctor", "Your phone may not be able to connect. Allow it once here."), + Some("firewall"), )); } - Some(f) if f.blocked => checks.push(check( - "firewall", - Outcome::Fail, - tr!("Doctor", "Windows Firewall blocks NectarLink"), - tr!( - "Doctor", - "Someone chose not to allow it when Windows asked. Allow it, and your phone can connect." - ), - Some("firewall"), - )), - Some(f) if !f.allowed => checks.push(check( - "firewall", - Outcome::Warn, - tr!("Doctor", "NectarLink isn't allowed through Windows Firewall"), - tr!("Doctor", "Your phone may not be able to connect. Allow it once here."), - Some("firewall"), - )), - Some(_) => checks.push(check( - "firewall", - Outcome::Ok, - tr!("Doctor", "Windows Firewall lets your phone connect"), - "", - None, - )), + Some(_) => { + #[cfg(target_os = "linux")] + let title = tr!("Doctor", "The firewall lets your phone connect"); + #[cfg(not(target_os = "linux"))] + let title = tr!("Doctor", "Windows Firewall lets your phone connect"); + checks.push(check("firewall", Outcome::Ok, title, "", None)); + } } if facts.vpn { @@ -282,12 +311,23 @@ pub fn diagnose(facts: &Facts, now_secs: i64) -> Vec { Outcome::Warn, format!("Optional helper tools not found: {list}"), format!( - "Install {list} with your package manager for full clipboard, window focus, Wi-Fi hotspot, BLE discovery, and firewall setup support." + "Install {list} with your package manager for background clipboard sync and opening files or links." ), None, )); } + #[cfg(target_os = "linux")] + for (id, title, ok, detail, fix) in &facts.linux_checks { + checks.push(check( + id, + if *ok { Outcome::Ok } else { Outcome::Warn }, + title.clone(), + detail.clone(), + *fix, + )); + } + // Each phone. for (i, d) in facts.devices.iter().enumerate() { let name = &d.name; @@ -398,7 +438,7 @@ fn ago(seconds: i64) -> String { } } -/// Runs a fix (blocking: the firewall one waits for Windows' prompt). +/// Runs a fix (blocking: the firewall one waits for the elevation prompt). /// Returns whether anything was done. pub fn fix(id: &str) -> bool { match id { @@ -420,6 +460,9 @@ pub fn fix(id: &str) -> bool { true } "firewall-settings" => windows_checks::open_firewall_settings(), + #[cfg(target_os = "linux")] + "network-settings" => windows_checks::open_network_settings(), + #[cfg(not(target_os = "linux"))] "network-settings" => crate::win::shell::open_url("ms-settings:network-status").is_ok(), "reconnect" => { if let Some(node) = core_host::node() { @@ -455,6 +498,8 @@ mod tests { devices: vec![device_fact("Pixel", LinkState::Online { path: ConnectionPath::Lan, rtt_ms: 4 })], #[cfg(target_os = "linux")] missing_tools: Vec::new(), + #[cfg(target_os = "linux")] + linux_checks: Vec::new(), } } @@ -592,4 +637,33 @@ mod tests { assert_eq!(ago(5400), "an hour ago"); assert_eq!(ago(3 * 86400), "3 days ago"); } + + #[cfg(target_os = "linux")] + #[test] + fn includes_linux_readiness_checks_and_platform_titles() { + let mut f = facts(); + f.linux_checks = vec![ + ( + "linux-appindicator".into(), + "GNOME system tray extension not detected".into(), + false, + "Install gnome-shell-extension-appindicator".into(), + None, + ), + ( + "linux-fuse".into(), + "Phone storage can open in your file manager".into(), + true, + String::new(), + None, + ), + ]; + let checks = diagnose(&f, 0); + let fw = checks.iter().find(|c| c.id == "firewall").expect("firewall check"); + assert_eq!(fw.title, "The firewall lets your phone connect"); + let appind = checks.iter().find(|c| c.id == "linux-appindicator").expect("appindicator check"); + assert_eq!(appind.outcome, Outcome::Warn); + let fuse = checks.iter().find(|c| c.id == "linux-fuse").expect("fuse check"); + assert_eq!(fuse.outcome, Outcome::Ok); + } } diff --git a/desktop/app/src/linux/autostart.rs b/desktop/app/src/linux/autostart.rs index 4f8b85ad..91fa0f5f 100644 --- a/desktop/app/src/linux/autostart.rs +++ b/desktop/app/src/linux/autostart.rs @@ -109,7 +109,7 @@ pub fn set_enabled(enabled: bool) -> Result<(), String> { /// Whether `exe` is installed in a standard Linux application location (rather /// than run directly from a `target/` build directory). -pub fn is_installed(exe: &Path) -> bool { +pub fn is_installed_location(exe: &Path) -> bool { if std::env::var_os("APPIMAGE").is_some() { return true; } @@ -117,10 +117,24 @@ pub fn is_installed(exe: &Path) -> bool { if s.contains("/target/debug/") || s.contains("/target/release/") { return false; } - exe.starts_with("/usr/bin") - || exe.starts_with("/usr/local/bin") + exe.starts_with("/usr") || exe.starts_with("/opt") - || dirs::data_local_dir().is_some_and(|d| exe.starts_with(d)) + || s.contains("/.local/share/nectarlink-app/") + || s.contains("/.local/bin/") + || dirs::data_local_dir() + .is_some_and(|d| exe.starts_with(&d) || exe.starts_with(d.join("nectarlink-app"))) + || dirs::executable_dir().is_some_and(|d| exe.starts_with(d)) + || dirs::home_dir().is_some_and(|h| { + exe.starts_with(h.join(".local/share/nectarlink-app")) + || exe.starts_with(h.join(".local/bin")) + || exe.starts_with(h.join(".local/share")) + }) +} + +/// Whether `exe` is installed in a standard Linux application location (rather +/// than run directly from a `target/` build directory). +pub fn is_installed(exe: &Path) -> bool { + is_installed_location(exe) } #[cfg(test)] @@ -148,7 +162,12 @@ mod tests { #[test] fn detects_installed_vs_build_paths() { assert!(is_installed(Path::new("/usr/bin/nectarlink-desktop"))); + assert!(is_installed(Path::new("/usr/local/bin/nectarlink-desktop"))); assert!(is_installed(Path::new("/opt/nectarlink/nectarlink-desktop"))); + assert!(is_installed_location(Path::new( + "/home/user/.local/share/nectarlink-app/nectarlink-desktop" + ))); + assert!(is_installed_location(Path::new("/home/user/.local/bin/nectarlink-desktop"))); assert!(!is_installed(Path::new("/home/user/repo/target/debug/nectarlink-desktop"))); assert!(!is_installed(Path::new("/home/user/repo/target/release/nectarlink-desktop"))); } diff --git a/desktop/app/src/linux/ble_beacon.rs b/desktop/app/src/linux/ble_beacon.rs index 8eda06f0..65aef076 100644 --- a/desktop/app/src/linux/ble_beacon.rs +++ b/desktop/app/src/linux/ble_beacon.rs @@ -1,26 +1,52 @@ // SPDX-License-Identifier: GPL-3.0-or-later +//! Linux BLE proximity RSSI monitor via BlueZ D-Bus (`org.freedesktop.DBus.ObjectManager`). +//! Never spawns interactive `bluetoothctl` sessions or `scan on`, and matches only +//! paired NectarLink phones (or devices advertising the NectarLink BLE UUID / +//! manufacturer frame) when walk-away automation is enabled. + use std::collections::HashMap; -use std::process::{Child, Command, Stdio}; use std::sync::{ - Mutex, + Condvar, Mutex, atomic::{AtomicBool, Ordering}, }; use std::thread::{self, JoinHandle}; use std::time::Duration; use nectarlink_core::DeviceId; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; pub const COMPANY_ID: u16 = 0xFFFF; pub const MAGIC: &[u8; 5] = b"NCTR\x01"; pub const NEAR_RSSI_DBM: i16 = -72; pub const WALK_AWAY_RSSI_DBM: i16 = -85; +pub const NECTARLINK_BLE_UUID: &str = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"; const POLL_INTERVAL: Duration = Duration::from_secs(2); -const POLL_STEP: Duration = Duration::from_millis(100); static RUNNING: AtomicBool = AtomicBool::new(false); +static STOP_MU: Mutex = Mutex::new(false); +static STOP_CV: Condvar = Condvar::new(); static WORKER: Mutex>> = Mutex::new(None); +type ManagedObjects = HashMap>>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairedPhone { + pub prefix: [u8; 8], + pub name: String, + pub model: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct BluezDeviceCandidate { + pub address: Option, + pub name: Option, + pub alias: Option, + pub rssi: Option, + pub uuids: Vec, + pub mfg_payload: Option>, +} + #[derive(Debug, Default)] pub struct RssiTracker { peers: HashMap<[u8; 8], PeerRssiState>, @@ -84,6 +110,7 @@ pub fn start(device_id: DeviceId) { if RUNNING.swap(true, Ordering::SeqCst) { return; } + *STOP_MU.lock().unwrap_or_else(|e| e.into_inner()) = false; let handle = thread::Builder::new().name("ble-beacon".into()).spawn(move || run_worker(device_id)).ok(); if let Ok(mut guard) = WORKER.lock() { *guard = handle; @@ -92,6 +119,11 @@ pub fn start(device_id: DeviceId) { pub fn stop() { RUNNING.store(false, Ordering::SeqCst); + { + let mut stopped = STOP_MU.lock().unwrap_or_else(|e| e.into_inner()); + *stopped = true; + STOP_CV.notify_all(); + } let handle = WORKER.lock().ok().and_then(|mut guard| guard.take()); if let Some(h) = handle { let _ = h.join(); @@ -103,59 +135,17 @@ fn walk_away_lock_enabled() -> bool { !crate::automations::matching_rules(&rules, crate::automations::TRIGGER_PHONE_WALKED_AWAY).is_empty() } -fn start_bluetoothctl_advertisement(payload: &[u8; 13]) -> Option { - let hex_bytes = payload.iter().map(|b| format!("0x{b:02x}")).collect::>().join(" "); - let mut child = Command::new("bluetoothctl") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - let script = - format!("advertise.manufacturer 0xffff {hex_bytes}\nadvertise.name off\nadvertise on\nscan on\n"); - let _ = stdin.write_all(script.as_bytes()); - let _ = stdin.flush(); - child.stdin = Some(stdin); - } - Some(child) -} - -fn stop_bluetoothctl_advertisement(child: Option) { - let Some(mut child) = child else { return }; - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - let _ = stdin.write_all(b"scan off\nadvertise off\nquit\n"); - let _ = stdin.flush(); - } - let _ = child.kill(); - let _ = child.wait(); -} - -fn is_adapter_powered() -> bool { - if let Ok(conn) = zbus::blocking::Connection::system() - && let Ok(reply) = conn.call_method( - Some("org.bluez"), - "/org/bluez/hci0", - Some("org.freedesktop.DBus.Properties"), - "Get", - &("org.bluez.Adapter1", "Powered"), - ) - && let Ok(val) = reply.body().deserialize::() - && let Ok(powered) = bool::try_from(val) - { - return powered; - } - if let Ok(out) = Command::new("bluetoothctl").arg("show").output() - && out.status.success() - { - let stdout = String::from_utf8_lossy(&out.stdout); - return stdout.lines().any(|l| { - l.trim().strip_prefix("Powered:").is_some_and(|v| v.trim().eq_ignore_ascii_case("yes")) - }); - } - false +fn paired_phones_from_hub() -> Vec { + crate::core_host::host().hub.read(|s| { + s.devices + .iter() + .map(|d| { + let mut prefix = [0u8; 8]; + prefix.copy_from_slice(&d.id.as_bytes()[0..8]); + PairedPhone { prefix, name: d.info.name.clone(), model: d.info.model.clone() } + }) + .collect() + }) } fn fallback_peer_prefix(id_str: &str) -> [u8; 8] { @@ -168,131 +158,205 @@ fn fallback_peer_prefix(id_str: &str) -> [u8; 8] { out } -fn parse_rssi_from_info(info: &str) -> Option { - for line in info.lines() { - let trimmed = line.trim(); - let Some(rest) = trimmed.strip_prefix("RSSI:") else { - continue; - }; - let rest = rest.trim(); - if let Some(open) = rest.find('(') - && let Some(close) = rest[open + 1..].find(')') - && let Ok(val) = rest[open + 1..open + 1 + close].trim().parse::() - { - return Some(val); +fn names_match(candidate: &str, phone_name: &str) -> bool { + let c = candidate.trim(); + let p = phone_name.trim(); + !c.is_empty() && !p.is_empty() && c.eq_ignore_ascii_case(p) +} + +/// Matches a BlueZ device candidate ONLY if: +/// 1. It carries a valid `NCTR\x01` manufacturer payload (not our own prefix), OR +/// 2. It advertises the NectarLink BLE service UUID (`f81d4fae-7dec-11d0-a765-00a0c91e6bf6`), OR +/// 3. Its `Name` or `Alias` matches a paired phone in `hub`. +/// +/// Arbitrary Bluetooth peripherals (headphones, mice, keyboards, etc.) never match. +pub fn match_nectarlink_device( + candidate: &BluezDeviceCandidate, + own_prefix: &[u8; 8], + paired_phones: &[PairedPhone], +) -> Option<[u8; 8]> { + if let Some(mfg_bytes) = &candidate.mfg_payload + && let Some(prefix) = parse_manufacturer_payload(mfg_bytes) + { + if &prefix == own_prefix { + return None; } - if let Some(tok) = rest.split_whitespace().next() - && let Ok(val) = tok.parse::() - { - return Some(val); + if paired_phones.is_empty() || paired_phones.iter().any(|p| p.prefix == prefix) { + return Some(prefix); + } + } + + let find_matching_phone = || -> Option<&PairedPhone> { + paired_phones.iter().find(|phone| { + candidate.name.as_deref().is_some_and(|n| { + names_match(n, &phone.name) || phone.model.as_deref().is_some_and(|m| names_match(n, m)) + }) || candidate.alias.as_deref().is_some_and(|a| { + names_match(a, &phone.name) || phone.model.as_deref().is_some_and(|m| names_match(a, m)) + }) + }) + }; + + if candidate.uuids.iter().any(|u| u.eq_ignore_ascii_case(NECTARLINK_BLE_UUID)) { + if let Some(phone) = find_matching_phone() { + return Some(phone.prefix); + } + if let Some(first) = paired_phones.first() { + return Some(first.prefix); } + if let Some(addr) = &candidate.address { + let prefix = fallback_peer_prefix(addr); + if &prefix != own_prefix { + return Some(prefix); + } + } + return None; + } + + find_matching_phone().map(|p| p.prefix) +} + +fn extract_string(val: &OwnedValue) -> Option { + <&str>::try_from(val).ok().map(str::to_owned) +} + +fn extract_i16(val: &OwnedValue) -> Option { + if let Ok(v) = i16::try_from(val) { + return Some(v); + } + if let Ok(v) = i32::try_from(val) { + return i16::try_from(v).ok(); } None } -fn parse_manufacturer_from_info(info: &str) -> Option> { - let mut in_ffff_block = false; - let mut collected = Vec::new(); - for line in info.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("ManufacturerData Key:") { - if !collected.is_empty() { - break; - } - let lower = rest.to_ascii_lowercase(); - in_ffff_block = lower.contains("0xffff") || lower.contains("65535"); +fn extract_string_list(val: &OwnedValue) -> Vec { + if let Ok(list) = Vec::::try_from(val.clone()) { + return list; + } + let Value::Array(arr) = &**val else { + return Vec::new(); + }; + arr.iter().filter_map(|item| <&str>::try_from(item).ok().map(str::to_owned)).collect() +} + +fn extract_mfg_ffff(val: &OwnedValue) -> Option> { + let Value::Dict(dict) = &**val else { + return None; + }; + for (k, v) in dict.iter() { + let key_matches = u16::try_from(k).is_ok_and(|id| id == COMPANY_ID); + if !key_matches { continue; } - if in_ffff_block && let Some(rest) = trimmed.strip_prefix("ManufacturerData Value:") { - for tok in rest.split_whitespace() { - let hex = tok.strip_prefix("0x").unwrap_or(tok); - if hex.len() == 2 - && let Ok(b) = u8::from_str_radix(hex, 16) - { - collected.push(b); - } else { - break; - } + let inner = match v { + Value::Value(inner) => inner.as_ref(), + other => other, + }; + if let Value::Array(bytes_arr) = inner { + let mut out = Vec::with_capacity(bytes_arr.len()); + for item in bytes_arr.iter() { + out.push(u8::try_from(item).ok()?); } + return Some(out); } } - if collected.is_empty() { None } else { Some(collected) } + None } -fn poll_bluez_devices(own_prefix: &[u8; 8], tracker: &mut RssiTracker) { - let Ok(out) = Command::new("bluetoothctl").arg("devices").output() else { - return; +fn is_adapter_powered(conn: &zbus::blocking::Connection) -> bool { + if !super::dbus_name_has_owner(conn, "org.bluez") { + return false; + } + let Ok(reply) = conn.call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) else { + return false; }; - if !out.status.success() { + let Ok(objects) = reply.body().deserialize::() else { + return false; + }; + objects.values().any(|ifaces| { + ifaces + .get("org.bluez.Adapter1") + .and_then(|props| props.get("Powered")) + .and_then(|v| bool::try_from(v).ok()) + .unwrap_or(false) + }) +} + +fn poll_bluez_devices(conn: &zbus::blocking::Connection, own_prefix: &[u8; 8], tracker: &mut RssiTracker) { + if !walk_away_lock_enabled() { + return; + } + let paired_phones = paired_phones_from_hub(); + if paired_phones.is_empty() { return; } - let devices = super::bt_hfp::parse_bluetoothctl_devices(&String::from_utf8_lossy(&out.stdout)); - for (mac, _) in devices { - let Ok(info_out) = Command::new("bluetoothctl").args(["info", &mac]).output() else { + + let Ok(reply) = conn.call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) else { + return; + }; + let Ok(objects) = reply.body().deserialize::() else { + return; + }; + + for ifaces in objects.values() { + let Some(props) = ifaces.get("org.bluez.Device1") else { continue; }; - if !info_out.status.success() { - continue; - } - let info = String::from_utf8_lossy(&info_out.stdout); - let Some(rssi) = parse_rssi_from_info(&info) else { + let Some(rssi) = props.get("RSSI").and_then(extract_i16) else { continue; }; - let connected = super::bt_hfp::parse_bluetoothctl_connected(&info); - let paired = info - .lines() - .any(|l| l.trim().strip_prefix("Paired:").is_some_and(|v| v.trim().eq_ignore_ascii_case("yes"))); - - let peer_prefix = if let Some(mfg_bytes) = parse_manufacturer_from_info(&info) - && let Some(prefix) = parse_manufacturer_payload(&mfg_bytes) - { - if &prefix == own_prefix { - continue; - } - Some(prefix) - } else if connected && paired && walk_away_lock_enabled() { - Some(fallback_peer_prefix(&mac)) - } else { - None + let candidate = BluezDeviceCandidate { + address: props.get("Address").and_then(extract_string), + name: props.get("Name").and_then(extract_string), + alias: props.get("Alias").and_then(extract_string), + rssi: Some(rssi), + uuids: props.get("UUIDs").map(extract_string_list).unwrap_or_default(), + mfg_payload: props.get("ManufacturerData").and_then(extract_mfg_ffff), }; - - if let Some(prefix) = peer_prefix + if let Some(prefix) = match_nectarlink_device(&candidate, own_prefix, &paired_phones) && tracker.record(prefix, rssi) { - tracing::info!("BLE walk-away threshold reached; locking workstation and firing trigger"); - let _ = super::shell::lock_workstation(); + tracing::info!("BLE walk-away threshold reached for paired phone; firing trigger"); crate::automations::fire_trigger(crate::automations::TRIGGER_PHONE_WALKED_AWAY); } } } fn run_worker(device_id: DeviceId) { - if !is_adapter_powered() { - tracing::debug!("BLE beacon: /org/bluez/hci0 unavailable or not powered; exiting worker"); + let Ok(conn) = zbus::blocking::Connection::system() else { + tracing::debug!("BLE beacon: system D-Bus unavailable; exiting worker"); + RUNNING.store(false, Ordering::SeqCst); + return; + }; + if !is_adapter_powered(&conn) { + tracing::debug!("BLE beacon: no powered BlueZ adapter found; exiting worker"); RUNNING.store(false, Ordering::SeqCst); return; } - let payload = build_manufacturer_payload(&device_id); let mut own_prefix = [0u8; 8]; own_prefix.copy_from_slice(&device_id.as_bytes()[0..8]); - - let adv_child = start_bluetoothctl_advertisement(&payload); let mut tracker = RssiTracker::default(); while RUNNING.load(Ordering::Relaxed) { - poll_bluez_devices(&own_prefix, &mut tracker); + poll_bluez_devices(&conn, &own_prefix, &mut tracker); - let steps = (POLL_INTERVAL.as_millis() / POLL_STEP.as_millis()).max(1); - for _ in 0..steps { - if !RUNNING.load(Ordering::Relaxed) { - break; - } - thread::sleep(POLL_STEP); - } + let guard = STOP_MU.lock().unwrap_or_else(|e| e.into_inner()); + let _ = STOP_CV + .wait_timeout_while(guard, POLL_INTERVAL, |stopped| !*stopped && RUNNING.load(Ordering::Relaxed)); } - - stop_bluetoothctl_advertisement(adv_child); } #[cfg(test)] @@ -374,6 +438,50 @@ mod tests { } } + #[test] + fn matches_only_paired_phones_or_nectarlink_uuid_and_ignores_peripherals() { + let own_prefix = [0xAA; 8]; + let phone_prefix = [1, 2, 3, 4, 5, 6, 7, 8]; + let paired = vec![PairedPhone { + prefix: phone_prefix, + name: "Pixel 9 Pro".into(), + model: Some("Pixel 9 Pro".into()), + }]; + + // Arbitrary Bluetooth headphones / mice must NEVER match. + let headphones = BluezDeviceCandidate { + address: Some("11:22:33:44:55:66".into()), + name: Some("Sony WH-1000XM5".into()), + alias: Some("Sony WH-1000XM5".into()), + rssi: Some(-90), + uuids: vec!["0000110b-0000-1000-8000-00805f9b34fb".into()], + mfg_payload: None, + }; + assert_eq!(match_nectarlink_device(&headphones, &own_prefix, &paired), None); + + // Paired phone matched by Name/Alias. + let by_name = BluezDeviceCandidate { + address: Some("AA:BB:CC:DD:EE:FF".into()), + name: Some("pixel 9 pro".into()), + alias: None, + rssi: Some(-65), + uuids: Vec::new(), + mfg_payload: None, + }; + assert_eq!(match_nectarlink_device(&by_name, &own_prefix, &paired), Some(phone_prefix)); + + // Device advertising the NectarLink BLE service UUID matches. + let by_uuid = BluezDeviceCandidate { + address: Some("AA:BB:CC:DD:EE:FF".into()), + name: Some("Android".into()), + alias: None, + rssi: Some(-65), + uuids: vec![NECTARLINK_BLE_UUID.to_uppercase()], + mfg_payload: None, + }; + assert_eq!(match_nectarlink_device(&by_uuid, &own_prefix, &paired), Some(phone_prefix)); + } + #[test] fn start_and_stop_never_panic_when_ble_radio_is_absent_or_present() { let id = DeviceId([0x42; 32]); diff --git a/desktop/app/src/linux/clipboard.rs b/desktop/app/src/linux/clipboard.rs index a03da2ae..5c134257 100644 --- a/desktop/app/src/linux/clipboard.rs +++ b/desktop/app/src/linux/clipboard.rs @@ -10,11 +10,10 @@ //! before updating the system clipboard so the watcher thread never echoes //! phone clipboard writes back to the phone. -use std::io::{BufRead, BufReader, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, Instant}; use super::image::{self, Bitmap}; @@ -173,50 +172,37 @@ where return; } seed_initial_clipboard(); - if is_wayland() { - let _ = std::thread::Builder::new().name("clipboard-wl-watch".into()).spawn(wayland_watch_loop); - } let spawned = std::thread::Builder::new().name("clipboard".into()).spawn(watcher_loop); if let Err(e) = spawned { tracing::warn!(error = %e, "can't start Linux clipboard watcher"); } } -/// Runs `wl-paste --watch` on Wayland so clipboard changes while NectarLink is -/// unfocused are detected on compositors that implement `wlr-data-control` or -/// `ext-data-control`. If the compositor does not support data-control (for -/// example GNOME Wayland), `wl-paste --watch` exits immediately and we do not -/// respawn it, relying on `QClipboard::dataChanged` instead. -fn wayland_watch_loop() { - let started = Instant::now(); - let Ok(mut child) = Command::new("wl-paste") - .args(["--watch", "printf", "\\n"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - else { - return; - }; - if let Some(stdout) = child.stdout.take() { - let reader = BufReader::new(stdout); - for line in reader.lines() { - if line.is_err() { - break; - } - crate::bridge::native::ffi::linux_clipboard_notify_external(); - } - } - let _ = child.wait(); - if started.elapsed() < Duration::from_secs(2) { - tracing::debug!("wl-paste --watch exited early; relying on QClipboard::dataChanged"); - } -} - fn watcher_loop() { + let mut seeded_from_qt = false; let mut last_seq = crate::bridge::native::ffi::linux_clipboard_sequence(); loop { let next_seq = crate::bridge::native::ffi::linux_clipboard_wait_change(last_seq, 2000); + if !seeded_from_qt { + let mut is_priv = false; + let mut has_files = false; + let mut text = String::new(); + let mut html = String::new(); + let mut rtf = String::new(); + let mut png = Vec::new(); + if crate::bridge::native::ffi::linux_clipboard_read( + true, + &mut is_priv, + &mut has_files, + &mut text, + &mut html, + &mut rtf, + &mut png, + ) { + seeded_from_qt = true; + seed_initial_clipboard(); + } + } if next_seq == last_seq { continue; } @@ -261,14 +247,24 @@ where true } +fn is_gnome_wayland() -> bool { + is_wayland() + && std::env::var("XDG_CURRENT_DESKTOP").ok().is_some_and(|d| d.to_ascii_lowercase().contains("gnome")) +} + fn query_os_targets() -> Option<(bool, String)> { + // Never spawn `wl-paste` on GNOME Wayland where Mutter lacks data-control + // and `wl-paste` maps a transient surface that steals window focus. if is_wayland() + && !is_gnome_wayland() && let Ok(out) = Command::new("wl-paste").arg("--list-types").output() && out.status.success() { return Some((true, String::from_utf8_lossy(&out.stdout).into_owned())); } - if let Ok(out) = Command::new("xclip").args(["-selection", "clipboard", "-t", "TARGETS", "-o"]).output() + if std::env::var_os("DISPLAY").is_some() + && let Ok(out) = + Command::new("xclip").args(["-selection", "clipboard", "-t", "TARGETS", "-o"]).output() && out.status.success() { return Some((false, String::from_utf8_lossy(&out.stdout).into_owned())); @@ -375,6 +371,51 @@ fn read_from_os(check_privacy: bool) -> Option { if cfg!(test) || !has_display_server() { return None; } + + // Primary path: read directly from Qt's in-process `QClipboard`/`QMimeData` + // without spawning external helper processes. + let mut is_private = false; + let mut has_files = false; + let mut qt_text = String::new(); + let mut qt_html = String::new(); + let mut qt_rtf = String::new(); + let mut qt_png = Vec::new(); + if crate::bridge::native::ffi::linux_clipboard_read( + check_privacy, + &mut is_private, + &mut has_files, + &mut qt_text, + &mut qt_html, + &mut qt_rtf, + &mut qt_png, + ) { + if is_private { + return Some(Clip::Private); + } + if !qt_png.is_empty() { + return Some(Clip::Image(Image::Png(qt_png))); + } + if has_files { + return Some(Clip::Empty); + } + if let Some(text) = clean_valid_utf8_text(qt_text.into_bytes()) { + if is_copied_file_uri_text(&text) { + return Some(Clip::Empty); + } + let html = { + let trimmed = qt_html.trim_matches('\0').trim(); + if trimmed.is_empty() { + None + } else { + decode_cf_html(trimmed.as_bytes()).or_else(|| Some(trimmed.to_owned())) + } + }; + let rtf = decode_cf_rtf(qt_rtf.as_bytes()); + return Some(enrich_with_last_written_rich(text, html, rtf)); + } + return Some(Clip::Empty); + } + if let Some((wayland, targets)) = query_os_targets() { if check_privacy { if has_gnome_privacy_hint(&targets) { @@ -435,9 +476,12 @@ fn read_from_os(check_privacy: bool) -> Option { }; return Some(enrich_with_last_written_rich(text, html, rtf)); } + return Some(Clip::Empty); } - if let Some(text) = read_os_plain_text(is_wayland()) { + if !is_gnome_wayland() + && let Some(text) = read_os_plain_text(is_wayland()) + { if is_copied_file_uri_text(&text) { return Some(Clip::Empty); } @@ -543,15 +587,19 @@ fn write_text(text: &str, html: Option<&str>, rtf: Option<&str>, private: bool) } if !cfg!(test) && has_display_server() { - if private { - let _ = pipe_to_cmd("wl-copy", &["--sensitive"], text.as_bytes()) - .or_else(|_| pipe_to_cmd("wl-copy", &[], text.as_bytes())) - .or_else(|_| pipe_to_cmd("xclip", &["-selection", "clipboard"], text.as_bytes())) - .or_else(|_| pipe_to_cmd("xsel", &["--clipboard", "--input"], text.as_bytes())); - } else { - let _ = pipe_to_cmd("wl-copy", &[], text.as_bytes()) - .or_else(|_| pipe_to_cmd("xclip", &["-selection", "clipboard"], text.as_bytes())) - .or_else(|_| pipe_to_cmd("xsel", &["--clipboard", "--input"], text.as_bytes())); + let html_str = html_owned.as_deref().unwrap_or(""); + let rtf_str = rtf_owned.as_deref().unwrap_or(""); + if !crate::bridge::native::ffi::linux_clipboard_write_text(text, html_str, rtf_str, private) { + if private { + let _ = pipe_to_cmd("wl-copy", &["--sensitive"], text.as_bytes()) + .or_else(|_| pipe_to_cmd("wl-copy", &[], text.as_bytes())) + .or_else(|_| pipe_to_cmd("xclip", &["-selection", "clipboard"], text.as_bytes())) + .or_else(|_| pipe_to_cmd("xsel", &["--clipboard", "--input"], text.as_bytes())); + } else { + let _ = pipe_to_cmd("wl-copy", &[], text.as_bytes()) + .or_else(|_| pipe_to_cmd("xclip", &["-selection", "clipboard"], text.as_bytes())) + .or_else(|_| pipe_to_cmd("xsel", &["--clipboard", "--input"], text.as_bytes())); + } } } Ok(()) @@ -571,7 +619,10 @@ pub fn write_image(mime: &str, bytes: &[u8]) -> Result<(), String> { *LAST_PRIVATE_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = None; *LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()) = Clip::Image(Image::Png(png.clone())); - if !cfg!(test) && has_display_server() { + if !cfg!(test) + && has_display_server() + && !crate::bridge::native::ffi::linux_clipboard_write_image_png(&png) + { let _ = pipe_to_cmd("wl-copy", &["--type", "image/png"], &png) .or_else(|_| pipe_to_cmd("xclip", &["-selection", "clipboard", "-t", "image/png"], &png)); } @@ -693,10 +744,29 @@ pub fn build_dropfiles_payload(paths: &[PathBuf]) -> Vec { pub fn read_files() -> Option> { if !cfg!(test) && has_display_server() { - let wayland = is_wayland(); - for mime in ["text/uri-list", "x-special/gnome-copied-files", "x-special/mate-copied-files"] { - if let Some(bytes) = read_os_mime(wayland, mime) - && let Ok(text) = String::from_utf8(bytes) + let qt_files = crate::bridge::native::ffi::linux_clipboard_read_files(); + if !qt_files.is_empty() { + let parsed: Vec = + qt_files.into_iter().map(PathBuf::from).filter(|p| p.exists()).collect(); + if !parsed.is_empty() { + return Some(parsed); + } + } + if !is_gnome_wayland() { + let wayland = is_wayland(); + for mime in ["text/uri-list", "x-special/gnome-copied-files", "x-special/mate-copied-files"] { + if let Some(bytes) = read_os_mime(wayland, mime) + && let Ok(text) = String::from_utf8(bytes) + { + let parsed: Vec = + parse_uri_list(&text).into_iter().filter(|p| p.exists()).collect(); + if !parsed.is_empty() { + return Some(parsed); + } + } + } + if let Some(text) = read_os_plain_text(wayland) + && is_copied_file_uri_text(&text) { let parsed: Vec = parse_uri_list(&text).into_iter().filter(|p| p.exists()).collect(); if !parsed.is_empty() { @@ -704,14 +774,6 @@ pub fn read_files() -> Option> { } } } - if let Some(text) = read_os_plain_text(wayland) - && is_copied_file_uri_text(&text) - { - let parsed: Vec = parse_uri_list(&text).into_iter().filter(|p| p.exists()).collect(); - if !parsed.is_empty() { - return Some(parsed); - } - } } LAST_FILES.lock().unwrap_or_else(|e| e.into_inner()).clone().filter(|v| !v.is_empty()) } @@ -729,9 +791,12 @@ pub fn write_files(paths: &[PathBuf]) -> bool { *LAST_CLIP.lock().unwrap_or_else(|e| e.into_inner()) = Clip::Empty; *LAST_FILES.lock().unwrap_or_else(|e| e.into_inner()) = Some(paths.to_vec()); if !cfg!(test) && has_display_server() { - let _ = pipe_to_cmd("wl-copy", &["--type", "text/uri-list"], uri_list.as_bytes()).or_else(|_| { - pipe_to_cmd("xclip", &["-selection", "clipboard", "-t", "text/uri-list"], uri_list.as_bytes()) - }); + let string_paths: Vec = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect(); + if !crate::bridge::native::ffi::linux_clipboard_write_files(&string_paths) { + let _ = pipe_to_cmd("wl-copy", &["--type", "text/uri-list"], uri_list.as_bytes()).or_else(|_| { + pipe_to_cmd("xclip", &["-selection", "clipboard", "-t", "text/uri-list"], uri_list.as_bytes()) + }); + } } true } @@ -836,7 +901,7 @@ mod tests { #[test] fn cf_html_round_trips_utf8_fragment() { - let fragment = "

Hello world — 🐝

"; + let fragment = "

Hello world · café 日本

"; let encoded = encode_cf_html(fragment); assert_eq!(encoded.last(), Some(&0)); let decoded = decode_cf_html(&encoded).expect("decoded fragment"); @@ -940,7 +1005,7 @@ mod tests { assert_eq!(from_file_uri("# comment line"), None); let paths = - vec![PathBuf::from("/tmp/nectarlink_a.txt"), PathBuf::from("/tmp/nectarlink dir/b 🐝.png")]; + vec![PathBuf::from("/tmp/nectarlink_a.txt"), PathBuf::from("/tmp/nectarlink dir/b café.png")]; let list = format_uri_list(&paths); assert_eq!(parse_uri_list(&list), paths); diff --git a/desktop/app/src/linux/doctor.rs b/desktop/app/src/linux/doctor.rs index 0748703d..ac765cc8 100644 --- a/desktop/app/src/linux/doctor.rs +++ b/desktop/app/src/linux/doctor.rs @@ -1,8 +1,16 @@ // SPDX-License-Identifier: GPL-3.0-or-later -use std::fs; +use std::collections::HashMap; +use std::fs::{self, OpenOptions}; use std::io::Result; +use std::net::Ipv4Addr; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub const DEFAULT_PORT: u16 = 7718; +pub const MDNS_PORT: u16 = 5353; + +static FIREWALL_VERIFIED: AtomicBool = AtomicBool::new(false); #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Firewall { @@ -11,19 +19,52 @@ pub struct Firewall { pub blocked: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Network { pub name: String, pub trusted: bool, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Adapters { pub local_address: bool, pub vpn: bool, } +pub fn active_node_port() -> u16 { + let port_file = crate::core_host::host().data_dir.join("port"); + fs::read_to_string(port_file) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .filter(|&p| p > 0) + .unwrap_or(DEFAULT_PORT) +} + +fn record_firewall_verified(port: u16) { + FIREWALL_VERIFIED.store(true, Ordering::SeqCst); + let verified_path = crate::core_host::host().data_dir.join("firewall_verified"); + let _ = fs::write(verified_path, format!("{port}\n")); +} + +fn is_firewall_verified(port: u16) -> bool { + if FIREWALL_VERIFIED.load(Ordering::SeqCst) { + return true; + } + let verified_path = crate::core_host::host().data_dir.join("firewall_verified"); + if let Ok(content) = fs::read_to_string(verified_path) + && content.lines().any(|l| l.trim().parse::().ok() == Some(port)) + { + FIREWALL_VERIFIED.store(true, Ordering::SeqCst); + return true; + } + false +} + pub fn parse_ufw_status(output: &str) -> Option { + parse_ufw_status_for_port(output, DEFAULT_PORT) +} + +pub fn parse_ufw_status_for_port(output: &str, port: u16) -> Option { let lower = output.to_ascii_lowercase(); if lower.contains("status: inactive") { return Some(Firewall { on: false, allowed: true, blocked: false }); @@ -32,6 +73,11 @@ pub fn parse_ufw_status(output: &str) -> Option { return None; } + let port_str = port.to_string(); + let port_tcp = format!("{port}/tcp"); + let port_udp = format!("{port}/udp"); + let port_colon = format!("{port}:"); + let mut tcp_allowed = false; let mut udp_allowed = false; let mut blocked = false; @@ -50,23 +96,27 @@ pub fn parse_ufw_status(output: &str) -> Option { continue; } let port_spec = tokens[0].to_ascii_lowercase(); - let touches_7718 = port_spec == "7718" + let touches_port = port_spec == port_str + || port_spec == port_tcp + || port_spec == port_udp + || port_spec.starts_with(&port_colon) + || port_spec == "7718" || port_spec == "7718/tcp" || port_spec == "7718/udp" || port_spec.starts_with("7718:"); - if !touches_7718 { + if !touches_port { continue; } let upper_line = trimmed.to_ascii_uppercase(); if upper_line.contains("DENY") || upper_line.contains("REJECT") { blocked = true; } else if upper_line.contains("ALLOW") { - if port_spec == "7718" { + if port_spec == port_str || port_spec == "7718" { tcp_allowed = true; udp_allowed = true; - } else if port_spec == "7718/tcp" || port_spec.ends_with("/tcp") { + } else if port_spec == port_tcp || port_spec == "7718/tcp" || port_spec.ends_with("/tcp") { tcp_allowed = true; - } else if port_spec == "7718/udp" || port_spec.ends_with("/udp") { + } else if port_spec == port_udp || port_spec == "7718/udp" || port_spec.ends_with("/udp") { udp_allowed = true; } } @@ -77,15 +127,40 @@ pub fn parse_ufw_status(output: &str) -> Option { } pub fn parse_firewalld_output(state: &str, ports: &str) -> Firewall { + parse_firewalld_output_for_port(state, ports, "", DEFAULT_PORT) +} + +pub fn parse_firewalld_output_for_port(state: &str, ports: &str, rich_rules: &str, port: u16) -> Firewall { if !state.trim().eq_ignore_ascii_case("running") { return Firewall { on: false, allowed: true, blocked: false }; } - let allowed = - ports.split_whitespace().any(|p| p.eq_ignore_ascii_case("7718/tcp") || p.starts_with("7718-")); - Firewall { on: true, allowed, blocked: false } + let port_tcp = format!("{port}/tcp"); + let port_dash = format!("{port}-"); + let port_attr = format!("port=\"{port}\""); + + let mut blocked = false; + let mut rich_allowed = false; + for line in rich_rules.lines() { + let lower = line.to_ascii_lowercase(); + if lower.contains(&port_attr) || lower.contains("port=\"7718\"") { + if lower.contains("reject") || lower.contains("drop") { + blocked = true; + } else if lower.contains("accept") { + rich_allowed = true; + } + } + } + + let port_allowed = ports.split_whitespace().any(|p| { + p.eq_ignore_ascii_case(&port_tcp) + || p.starts_with(&port_dash) + || p.eq_ignore_ascii_case("7718/tcp") + || p.starts_with("7718-") + }); + Firewall { on: true, allowed: !blocked && (port_allowed || rich_allowed), blocked } } -fn check_ufw_config_fallback() -> Option { +fn check_ufw_config_fallback(port: u16) -> Option { let conf = fs::read_to_string("/etc/ufw/ufw.conf").ok()?; let enabled = conf.lines().any(|l| { let t = l.trim(); @@ -94,21 +169,28 @@ fn check_ufw_config_fallback() -> Option { if !enabled { return Some(Firewall { on: false, allowed: true, blocked: false }); } - if let Ok(rules) = fs::read_to_string("/etc/ufw/user.rules") { - let has_drop_7718 = - rules.lines().any(|l| l.contains("7718") && (l.contains("-j DROP") || l.contains("-j REJECT"))); - let has_accept_7718 = rules.lines().any(|l| l.contains("7718") && l.contains("-j ACCEPT")); - return Some(Firewall { - on: true, - allowed: !has_drop_7718 && has_accept_7718, - blocked: has_drop_7718, - }); - } - Some(Firewall { on: true, allowed: false, blocked: false }) + let port_str = port.to_string(); + for rules_path in ["/etc/ufw/user.rules", "/lib/ufw/user.rules"] { + if let Ok(rules) = fs::read_to_string(rules_path) { + let touches = |l: &str| l.contains(&port_str) || l.contains("7718"); + let has_drop = + rules.lines().any(|l| touches(l) && (l.contains("-j DROP") || l.contains("-j REJECT"))); + let has_accept = rules.lines().any(|l| touches(l) && l.contains("-j ACCEPT")); + return Some(Firewall { on: true, allowed: !has_drop && has_accept, blocked: has_drop }); + } + } + // Non-root users cannot read `/etc/ufw/user.rules` (0640 root:root); consult verified state. + let verified = is_firewall_verified(port) || is_firewall_verified(DEFAULT_PORT); + Some(Firewall { on: true, allowed: verified, blocked: false }) } fn is_service_active(service: &str) -> bool { - Command::new("systemctl").args(["is-active", "--quiet", service]).status().is_ok_and(|s| s.success()) + Command::new("systemctl") + .args(["is-active", "--quiet", service]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) } fn has_command(cmd: &str) -> bool { @@ -138,22 +220,24 @@ fn has_command(cmd: &str) -> bool { } pub fn firewall(_exe: &Path) -> Result { + let port = active_node_port(); + // 1. Try ufw status if let Ok(out) = Command::new("ufw").arg("status").output() && out.status.success() { let stdout = String::from_utf8_lossy(&out.stdout); - if let Some(fw) = parse_ufw_status(&stdout) { + if let Some(fw) = parse_ufw_status_for_port(&stdout, port) { return Ok(fw); } } if is_service_active("ufw") - && let Some(fw) = check_ufw_config_fallback() + && let Some(fw) = check_ufw_config_fallback(port) { return Ok(fw); } - // 2. Try firewalld (firewall-cmd --state & firewall-cmd --list-ports) + // 2. Try firewalld (firewall-cmd --state, --list-ports, --list-rich-rules) if let Ok(state_out) = Command::new("firewall-cmd").arg("--state").output() { let state_str = String::from_utf8_lossy(&state_out.stdout); if state_str.trim().eq_ignore_ascii_case("running") { @@ -162,72 +246,219 @@ pub fn firewall(_exe: &Path) -> Result { .output() .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) .unwrap_or_default(); - return Ok(parse_firewalld_output(&state_str, &ports_str)); + let rich_str = Command::new("firewall-cmd") + .arg("--list-rich-rules") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + let mut fw = parse_firewalld_output_for_port(&state_str, &ports_str, &rich_str, port); + if !fw.allowed + && !fw.blocked + && (is_firewall_verified(port) || is_firewall_verified(DEFAULT_PORT)) + { + fw.allowed = true; + } + return Ok(fw); } } // 3. Try nftables - if is_service_active("nftables") - && let Ok(conf) = fs::read_to_string("/etc/nftables.conf") - { - let lower = conf.to_ascii_lowercase(); - let has_policy_drop = lower.contains("policy drop"); - let has_7718 = lower.contains("7718"); - if has_policy_drop || has_7718 { - return Ok(Firewall { on: true, allowed: has_7718, blocked: false }); + if is_service_active("nftables") { + let ruleset = Command::new("nft") + .args(["list", "ruleset"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .or_else(|| fs::read_to_string("/etc/nftables.conf").ok()); + if let Some(conf) = ruleset { + let lower = conf.to_ascii_lowercase(); + let has_policy_drop = lower.contains("policy drop"); + let port_str = port.to_string(); + let has_port = lower.contains(&port_str) || lower.contains("7718"); + if has_policy_drop || has_port { + let verified = is_firewall_verified(port) || is_firewall_verified(DEFAULT_PORT); + return Ok(Firewall { on: true, allowed: has_port || verified, blocked: false }); + } } } Ok(Firewall { on: false, allowed: true, blocked: false }) } -pub fn networks() -> Result> { - let mut list = Vec::new(); - if let Ok(entries) = fs::read_dir("/sys/class/net") { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - if name == "lo" { - continue; +/// Parses `nmcli -t -f GENERAL.CONNECTION,GENERAL.ZONE device show` output into a +/// map from connection name to firewall zone. +pub fn parse_nmcli_device_zones(stdout: &str) -> HashMap { + let mut zones = HashMap::new(); + let mut current_conn: Option = None; + for line in stdout.lines() { + let trimmed = line.trim(); + if let Some(conn) = trimmed.strip_prefix("GENERAL.CONNECTION:") { + let name = super::wifi::unescape_nmcli(conn.trim()); + if !name.is_empty() && name != "--" { + current_conn = Some(name); + } else { + current_conn = None; } - let state = fs::read_to_string(entry.path().join("operstate")) - .unwrap_or_default() - .trim() - .to_ascii_lowercase(); - if state == "up" { - list.push(Network { name, trusted: true }); + } else if let Some(zone) = trimmed.strip_prefix("GENERAL.ZONE:") { + let zone_trimmed = zone.trim(); + if let Some(conn) = current_conn.take() + && !zone_trimmed.is_empty() + && zone_trimmed != "--" + { + zones.insert(conn, zone_trimmed.to_ascii_lowercase()); } } } + zones +} + +/// Parses `nmcli -t -f DEVICE,NAME,TYPE,STATE connection show --active` output and combines it +/// with firewall zones from `device show`, marking `public` firewall zones as `trusted: false`. +pub fn parse_nmcli_active_networks(active_stdout: &str, zones: &HashMap) -> Vec { + let mut list = Vec::new(); + for line in active_stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + // Split from the right: `...:TYPE:STATE`, and from the left: `DEVICE:NAME`. + let Some((rest, state)) = trimmed.rsplit_once(':') else { + continue; + }; + let Some((dev_and_name, kind)) = rest.rsplit_once(':') else { + continue; + }; + let Some((device, raw_name)) = dev_and_name.split_once(':') else { + continue; + }; + if state != "activated" && state != "activating" { + continue; + } + if device == "lo" + || kind == "loopback" + || kind == "bridge" + || kind == "tun" + || kind == "wireguard" + || super::wol::is_virtual_interface_name(device) + { + continue; + } + let name = super::wifi::unescape_nmcli(raw_name.trim()); + if name.is_empty() { + continue; + } + let trusted = + !zones.get(&name).is_some_and(|z| z.eq_ignore_ascii_case("public") || z.starts_with("public")); + if !list.iter().any(|n: &Network| n.name == name) { + list.push(Network { name, trusted }); + } + } + list +} + +fn interfaces_with_ipv4() -> (Vec, bool) { + let mut ipv4_ifaces = Vec::new(); + let mut vpn = false; + let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut(); + + // SAFETY: `getifaddrs` allocates and populates a linked list freed by `freeifaddrs`. + if unsafe { libc::getifaddrs(&mut ifap) } == 0 && !ifap.is_null() { + let mut cur = ifap; + // SAFETY: iterating valid `ifaddrs` nodes before `freeifaddrs`. + unsafe { + while let Some(ifa) = cur.as_ref() { + cur = ifa.ifa_next; + if ifa.ifa_name.is_null() { + continue; + } + let flags = ifa.ifa_flags as i32; + if (flags & libc::IFF_UP) == 0 || (flags & libc::IFF_LOOPBACK) != 0 { + continue; + } + let Ok(name) = std::ffi::CStr::from_ptr(ifa.ifa_name).to_str() else { + continue; + }; + let lower = name.to_ascii_lowercase(); + if lower == "lo" { + continue; + } + let is_vpn = lower.starts_with("tun") + || lower.starts_with("tap") + || lower.starts_with("wg") + || lower.starts_with("tailscale") + || lower.starts_with("nordlynx") + || lower.starts_with("ppp") + || lower.starts_with("zt"); + if is_vpn { + vpn = true; + continue; + } + if !ifa.ifa_addr.is_null() && i32::from((*ifa.ifa_addr).sa_family) == libc::AF_INET { + let sin_addr = (*ifa.ifa_addr.cast::()).sin_addr.s_addr; + let ip = Ipv4Addr::from(u32::from_be(sin_addr)); + if !ip.is_loopback() && !ip.is_link_local() && !ip.is_unspecified() { + let owned = name.to_string(); + if !ipv4_ifaces.contains(&owned) { + ipv4_ifaces.push(owned); + } + } + } + } + libc::freeifaddrs(ifap); + } + } + + (ipv4_ifaces, vpn) +} + +pub fn networks() -> Result> { + if let Ok(active_out) = Command::new("nmcli") + .args(["-t", "-f", "DEVICE,NAME,TYPE,STATE", "connection", "show", "--active"]) + .output() + && active_out.status.success() + { + let zones = Command::new("nmcli") + .args(["-t", "-f", "GENERAL.CONNECTION,GENERAL.ZONE", "device", "show"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| parse_nmcli_device_zones(&String::from_utf8_lossy(&o.stdout))) + .unwrap_or_default(); + let active_str = String::from_utf8_lossy(&active_out.stdout); + let parsed = parse_nmcli_active_networks(&active_str, &zones); + if !parsed.is_empty() { + return Ok(parsed); + } + } + + let (ipv4_ifaces, _) = interfaces_with_ipv4(); + let list = ipv4_ifaces.into_iter().map(|name| Network { name, trusted: true }).collect(); Ok(list) } pub fn adapters() -> Adapters { - let mut local_address = false; - let mut vpn = false; + let (ipv4_ifaces, mut vpn) = interfaces_with_ipv4(); + let local_address = !ipv4_ifaces.is_empty(); - if let Ok(entries) = fs::read_dir("/sys/class/net") { + if !vpn && let Ok(entries) = fs::read_dir("/sys/class/net") { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); - if name == "lo" { - continue; - } let state = fs::read_to_string(entry.path().join("operstate")) .unwrap_or_default() .trim() .to_ascii_lowercase(); - if state != "up" && state != "unknown" { - continue; - } - if name.starts_with("tun") - || name.starts_with("tap") - || name.starts_with("wg") - || name.starts_with("tailscale") - || name.starts_with("nordlynx") - || name.starts_with("ppp") + if (state == "up" || state == "unknown") + && (name.starts_with("tun") + || name.starts_with("tap") + || name.starts_with("wg") + || name.starts_with("tailscale") + || name.starts_with("nordlynx") + || name.starts_with("ppp") + || name.starts_with("zt")) { vpn = true; - } else { - local_address = true; + break; } } } @@ -236,8 +467,8 @@ pub fn adapters() -> Adapters { } /// Returns the names of optional session-relevant helper commands that are not -/// currently installed on `PATH` (`wl-copy`/`wl-paste` on Wayland, -/// `xclip`/`xdotool` on X11, and `xdg-open`, `nmcli`, `bluetoothctl`, `pkexec`). +/// currently installed on `PATH` (`wl-copy`/`wl-paste` on Wayland, `xclip` on +/// X11, and `xdg-open`). pub fn missing_tools() -> Vec<&'static str> { let mut missing = Vec::new(); if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) { @@ -247,45 +478,215 @@ pub fn missing_tools() -> Vec<&'static str> { } } } - if std::env::var_os("DISPLAY").is_some_and(|v| !v.is_empty()) { - for tool in ["xclip", "xdotool"] { - if !has_command(tool) { - missing.push(tool); - } + if std::env::var_os("DISPLAY").is_some_and(|v| !v.is_empty()) && !has_command("xclip") { + missing.push("xclip"); + } + if !has_command("xdg-open") { + missing.push("xdg-open"); + } + missing +} + +fn session_dbus_has_owner(bus_name: &str) -> bool { + super::non_tokio(|| { + let Ok(conn) = zbus::blocking::Connection::session() else { + return false; + }; + super::dbus_name_has_owner(&conn, bus_name) + }) +} + +fn has_libxtst() -> bool { + const CANDIDATES: &[&str] = &[ + "/usr/lib/x86_64-linux-gnu/libXtst.so.6", + "/lib/x86_64-linux-gnu/libXtst.so.6", + "/usr/lib64/libXtst.so.6", + "/usr/lib/libXtst.so.6", + "/usr/lib/aarch64-linux-gnu/libXtst.so.6", + ]; + CANDIDATES.iter().any(|p| Path::new(p).exists()) || has_command("xdotool") +} + +fn has_v4l2loopback() -> bool { + if Path::new("/sys/module/v4l2loopback").exists() { + return true; + } + for bin in ["modinfo", "/sbin/modinfo", "/usr/sbin/modinfo"] { + if Command::new(bin) + .arg("v4l2loopback") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + return true; } } - for tool in ["xdg-open", "nmcli", "bluetoothctl", "pkexec"] { - if !has_command(tool) { - missing.push(tool); + false +} + +/// Linux-specific checks for features that need something installed or +/// allowed first: `(id, title, ok, detail, fix)`. These don't stop the phone +/// from connecting; the Doctor lists them after the connection checks. +pub fn linux_checks() -> Vec<(String, String, bool, String, Option<&'static str>)> { + use crate::i18n::tr; + let mut out = Vec::new(); + let mut check = |id: &str, ok: bool, ready: String, needs: String, how: String| { + out.push(( + id.to_owned(), + if ok { ready } else { needs }, + ok, + if ok { String::new() } else { how }, + None, + )); + }; + + // GNOME hides tray icons unless the AppIndicator extension is on. + let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + if desktop.to_ascii_uppercase().contains("GNOME") { + check( + "linux-appindicator", + session_dbus_has_owner("org.kde.StatusNotifierWatcher"), + tr!("Doctor", "NectarLink is in the top bar"), + tr!("Doctor", "NectarLink can't show its icon in the top bar"), + tr!( + "Doctor", + "GNOME shows app icons there only with the AppIndicator extension. Install it (gnome-shell-extension-appindicator) and turn it on in Extensions." + ), + ); + } + + let uinput_writable = OpenOptions::new().write(true).open("/dev/uinput").is_ok(); + let portal_available = session_dbus_has_owner("org.freedesktop.portal.Desktop"); + let x11_xtst = std::env::var_os("DISPLAY").is_some_and(|v| !v.is_empty()) && has_libxtst(); + check( + "linux-remote-input", + uinput_writable || portal_available || x11_xtst, + tr!("Doctor", "Your phone can control this PC"), + tr!("Doctor", "Your phone can't control this PC yet"), + tr!( + "Doctor", + "The touchpad and keyboard on your phone need the desktop portal (xdg-desktop-portal) or access to /dev/uinput." + ), + ); + + check( + "linux-fuse", + Path::new("/dev/fuse").exists() && (has_command("fusermount3") || has_command("fusermount")), + tr!("Doctor", "Phone storage can open in your file manager"), + tr!("Doctor", "Phone storage needs one more step"), + tr!("Doctor", "Install fuse3 to browse your phone's files in your file manager."), + ); + + check( + "linux-audio", + has_command("pactl") || has_command("wpctl") || has_command("pw-play") || has_command("aplay"), + tr!("Doctor", "Sound is ready"), + tr!("Doctor", "Calls and alerts can't play sound yet"), + tr!("Doctor", "Install pipewire-pulse (or pulseaudio-utils) for call audio and Find my PC."), + ); + + // Sharing this PC's screen on Wayland goes through the ScreenCast portal + // and GStreamer's PipeWire source; on X11 it reads the screen directly. + if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) { + check( + "linux-screencast", + portal_available && crate::pc_mirror::pipewire_capture_installed(), + tr!("Doctor", "Your phone can show this PC's screen"), + tr!("Doctor", "Showing this PC's screen needs one more step"), + tr!( + "Doctor", + "Install gstreamer1.0-pipewire and gstreamer1.0-tools, and the desktop portal (xdg-desktop-portal) if it's missing." + ), + ); + } + + check( + "linux-v4l2loopback", + has_v4l2loopback(), + tr!("Doctor", "Phone as webcam is ready"), + tr!("Doctor", "Phone as webcam needs one more step"), + tr!("Doctor", "Install v4l2loopback-dkms, then restart, so apps can use your phone as a camera."), + ); + + out +} + +pub fn build_ufw_fix_script(node_port: u16) -> String { + let mut ports = vec![DEFAULT_PORT]; + if node_port != DEFAULT_PORT && node_port != MDNS_PORT && node_port > 0 { + ports.push(node_port); + } + let port_cmds: Vec = ports + .iter() + .map(|p| { + format!( + "ufw allow from \"$cidr\" to any port {p} proto tcp && ufw allow from \"$cidr\" to any port {p} proto udp" + ) + }) + .collect(); + let joined = port_cmds.join(" && "); + format!( + "for cidr in 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 fe80::/10; do \ + {joined} && \ + ufw allow from \"$cidr\" to any port {MDNS_PORT} proto udp || exit 1; \ + done" + ) +} + +pub fn build_firewalld_fix_script(node_port: u16) -> String { + let mut ports = vec![DEFAULT_PORT]; + if node_port != DEFAULT_PORT && node_port != MDNS_PORT && node_port > 0 { + ports.push(node_port); + } + let mut cmds = Vec::new(); + for cidr in ["192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"] { + for &p in &ports { + cmds.push(format!( + "firewall-cmd --permanent --add-rich-rule='rule family=\"ipv4\" source address=\"{cidr}\" port port=\"{p}\" protocol=\"tcp\" accept'" + )); + cmds.push(format!( + "firewall-cmd --permanent --add-rich-rule='rule family=\"ipv4\" source address=\"{cidr}\" port port=\"{p}\" protocol=\"udp\" accept'" + )); } + cmds.push(format!( + "firewall-cmd --permanent --add-rich-rule='rule family=\"ipv4\" source address=\"{cidr}\" port port=\"{MDNS_PORT}\" protocol=\"udp\" accept'" + )); } - missing + for &p in &ports { + cmds.push(format!( + "firewall-cmd --permanent --add-rich-rule='rule family=\"ipv6\" source address=\"fe80::/10\" port port=\"{p}\" protocol=\"tcp\" accept'" + )); + cmds.push(format!( + "firewall-cmd --permanent --add-rich-rule='rule family=\"ipv6\" source address=\"fe80::/10\" port port=\"{p}\" protocol=\"udp\" accept'" + )); + } + cmds.push(format!( + "firewall-cmd --permanent --add-rich-rule='rule family=\"ipv6\" source address=\"fe80::/10\" port port=\"{MDNS_PORT}\" protocol=\"udp\" accept'" + )); + cmds.push("firewall-cmd --reload".into()); + cmds.join(" && ") } pub fn fix_firewall(_exe: &Path) -> Result { + let port = active_node_port(); if is_service_active("ufw") || has_command("ufw") { - let status = super::elevated::run_elevated( - Path::new("sh"), - &[ - "-c", - "for cidr in 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 fe80::/10; do \ - ufw allow from \"$cidr\" to any port 7718 proto tcp && \ - ufw allow from \"$cidr\" to any port 7718 proto udp && \ - ufw allow from \"$cidr\" to any port 5353 proto udp || exit 1; \ - done", - ], - ); - return Ok(matches!(status, Ok(0))); + let script = build_ufw_fix_script(port); + let status = super::elevated::run_elevated(Path::new("sh"), &["-c", &script]); + if matches!(status, Ok(0)) { + record_firewall_verified(port); + return Ok(true); + } + return Ok(false); } if is_service_active("firewalld") || has_command("firewall-cmd") { - let status = super::elevated::run_elevated( - Path::new("sh"), - &[ - "-c", - "firewall-cmd --permanent --add-port=7718/tcp --add-port=7718/udp --add-port=5353/udp && firewall-cmd --reload", - ], - ); - return Ok(matches!(status, Ok(0))); + let script = build_firewalld_fix_script(port); + let status = super::elevated::run_elevated(Path::new("sh"), &["-c", &script]); + if matches!(status, Ok(0)) { + record_firewall_verified(port); + return Ok(true); + } + return Ok(false); } Ok(false) } @@ -299,6 +700,16 @@ pub fn open_firewall_settings() -> bool { .is_ok() } +pub fn open_network_settings() -> bool { + Command::new("nm-connection-editor") + .spawn() + .or_else(|_| Command::new("gnome-control-center").arg("network").spawn()) + .or_else(|_| Command::new("gnome-control-center").arg("wifi").spawn()) + .or_else(|_| Command::new("plasma-nm").spawn()) + .or_else(|_| Command::new("systemsettings").arg("kcm_networkmanagement").spawn()) + .is_ok() +} + #[cfg(test)] mod tests { use super::*; @@ -378,7 +789,7 @@ To Action From } #[test] - fn parses_firewalld_running_and_not_running() { + fn parses_firewalld_running_ports_and_scoped_rich_rules() { assert_eq!( parse_firewalld_output("not running\n", ""), Firewall { on: false, allowed: true, blocked: false } @@ -391,5 +802,52 @@ To Action From parse_firewalld_output("running\n", "5353/udp 7718/tcp 7718/udp\n"), Firewall { on: true, allowed: true, blocked: false } ); + + let rich = "rule family=\"ipv4\" source address=\"192.168.0.0/16\" port port=\"49152\" protocol=\"tcp\" accept\n"; + assert_eq!( + parse_firewalld_output_for_port("running\n", "", rich, 49152), + Firewall { on: true, allowed: true, blocked: false } + ); + } + + #[test] + fn builds_scoped_ufw_and_firewalld_scripts_with_dynamic_port() { + let ufw_script = build_ufw_fix_script(45678); + assert!(ufw_script.contains("port 7718 proto tcp")); + assert!(ufw_script.contains("port 45678 proto tcp")); + assert!(ufw_script.contains("port 5353 proto udp")); + assert!(ufw_script.contains("fe80::/10")); + + let fwd_script = build_firewalld_fix_script(45678); + assert!(fwd_script.contains("--add-rich-rule='rule family=\"ipv4\" source address=\"192.168.0.0/16\" port port=\"7718\" protocol=\"tcp\" accept'")); + assert!(fwd_script.contains("port=\"45678\"")); + assert!(fwd_script.contains("family=\"ipv6\" source address=\"fe80::/10\"")); + assert!(fwd_script.ends_with("firewall-cmd --reload")); + } + + #[test] + fn parses_nmcli_networks_and_marks_public_zone_untrusted() { + let dev_show = "\ +GENERAL.CONNECTION:Cafe\\:Free +GENERAL.ZONE:public + +GENERAL.CONNECTION:Home WiFi +GENERAL.ZONE:home +"; + let zones = parse_nmcli_device_zones(dev_show); + let active = "\ +wlan0:Cafe\\:Free:802-11-wireless:activated +eth0:Home WiFi:802-3-ethernet:activated +lo:lo:loopback:activated +tun0:wg0:tun:activated +"; + let nets = parse_nmcli_active_networks(active, &zones); + assert_eq!( + nets, + vec![ + Network { name: "Cafe:Free".into(), trusted: false }, + Network { name: "Home WiFi".into(), trusted: true }, + ] + ); } } diff --git a/desktop/app/src/linux/elevated.rs b/desktop/app/src/linux/elevated.rs index 4b05a724..223037bf 100644 --- a/desktop/app/src/linux/elevated.rs +++ b/desktop/app/src/linux/elevated.rs @@ -2,19 +2,39 @@ //! Running a process with root privileges on Linux via `pkexec` (Polkit). use std::{ + fs, path::Path, process::{Child, Command, Stdio}, sync::Mutex, + time::Duration, }; -/// Why starting the elevated process failed. -#[derive(Debug)] +/// Why starting or running the elevated process failed. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { /// The user dismissed or declined the Polkit prompt (`pkexec` exit status 126 or 127). Declined, Other(String), } +impl Error { + #[allow(non_snake_case)] + pub fn Failed(msg: String) -> Self { + Self::Other(msg) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Declined => write!(f, "authentication declined"), + Self::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for Error {} + /// A running elevated child process. #[derive(Debug)] pub struct Process(Mutex); @@ -29,6 +49,38 @@ pub fn is_elevated() -> bool { unsafe { geteuid() == 0 } } +fn has_pkexec() -> bool { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + + let path_var = std::env::var_os("PATH").unwrap_or_default(); + let extra = [Path::new("/usr/bin"), Path::new("/bin")]; + std::env::split_paths(&path_var).chain(extra.into_iter().map(Path::to_path_buf)).any(|dir| { + let candidate = dir.join("pkexec"); + fs::metadata(&candidate).is_ok_and(|m| { + if !m.is_file() { + return false; + } + #[cfg(unix)] + { + m.mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } + }) + }) +} + +pub fn classify_exit_code(code: Option, ran_via_pkexec: bool) -> Result { + match code { + Some(126 | 127) if ran_via_pkexec => Err(Error::Declined), + Some(c) => Ok(c), + None => Ok(1), + } +} + fn split_params(params: &str) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); @@ -53,8 +105,13 @@ fn split_params(params: &str) -> Vec { /// Starts `program` with `params` under `pkexec` (or directly when already `root`), /// without waiting for it to finish. pub fn start(program: &Path, params: &str) -> Result { + let elevated = is_elevated(); + if !elevated && !has_pkexec() { + return Err(Error::Failed("pkexec not found".into())); + } + let parsed = split_params(params); - let mut cmd = if is_elevated() { + let mut cmd = if elevated { let mut c = Command::new(program); c.args(&parsed); c @@ -64,15 +121,34 @@ pub fn start(program: &Path, params: &str) -> Result { c }; cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); - match cmd.spawn() { - Ok(child) => Ok(Process(Mutex::new(child))), - Err(e) => Err(Error::Other(e.to_string())), + let mut child = cmd.spawn().map_err(|e| Error::Failed(e.to_string()))?; + + if !elevated { + for _ in 0..10 { + match child.try_wait() { + Ok(Some(status)) => { + classify_exit_code(status.code(), true)?; + break; + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(e) => return Err(Error::Failed(e.to_string())), + } + } } + + Ok(Process(Mutex::new(child))) } -/// Runs `program` with `args` under `pkexec` and waits for its exit code. -pub fn run_elevated(program: &Path, args: &[&str]) -> Result { - let mut cmd = if is_elevated() { +/// Runs `program` with `args` under `pkexec` (or directly when already `root`) +/// and waits for its exit code, returning `Err(Error::Declined)` when `pkexec` +/// exits with `126` or `127`. +pub fn run(program: &Path, args: &[&str]) -> Result { + let elevated = is_elevated(); + if !elevated && !has_pkexec() { + return Err(Error::Failed("pkexec not found".into())); + } + + let mut cmd = if elevated { let mut c = Command::new(program); c.args(args); c @@ -81,8 +157,13 @@ pub fn run_elevated(program: &Path, args: &[&str]) -> Result { c.arg(program).args(args); c }; - let status = cmd.status().map_err(|e| e.to_string())?; - Ok(status.code().unwrap_or(1)) + let status = cmd.status().map_err(|e| Error::Failed(e.to_string()))?; + classify_exit_code(status.code(), !elevated) +} + +/// Runs `program` with `args` under `pkexec` and waits for its exit code. +pub fn run_elevated(program: &Path, args: &[&str]) -> Result { + run(program, args).map_err(|e| e.to_string()) } impl Process { @@ -106,4 +187,13 @@ mod tests { let args = split_params("--install-to \"/opt/NectarLink App\" --quiet"); assert_eq!(args, vec!["--install-to", "/opt/NectarLink App", "--quiet"]); } + + #[test] + fn classifies_pkexec_dismissal_and_denial_codes_as_declined() { + assert_eq!(classify_exit_code(Some(126), true), Err(Error::Declined)); + assert_eq!(classify_exit_code(Some(127), true), Err(Error::Declined)); + assert_eq!(classify_exit_code(Some(0), true), Ok(0)); + assert_eq!(classify_exit_code(Some(1), true), Ok(1)); + assert_eq!(classify_exit_code(Some(127), false), Ok(127)); + } } diff --git a/desktop/app/src/linux/jumplist.rs b/desktop/app/src/linux/jumplist.rs index 734ad808..511eaa1e 100644 --- a/desktop/app/src/linux/jumplist.rs +++ b/desktop/app/src/linux/jumplist.rs @@ -34,6 +34,14 @@ fn hicolor_icon_path() -> Option { Some(base.join("icons/hicolor/256x256/apps/nectarlink.png")) } +fn ensure_desktop_icon() -> PathBuf { + let icon_path = hicolor_icon_path().unwrap_or_else(|| PathBuf::from("nectarlink")); + if icon_path.is_absolute() && !icon_path.exists() { + let _ = super::icon::write_png(&icon_path, 256); + } + icon_path +} + /// Register the application `.desktop` entry and 256x256 PNG icon in the user's XDG data dir. /// /// Only installs into the user's real `~/.local/share/applications` when running as the @@ -48,25 +56,14 @@ pub fn register(custom_data_dir: Option<&Path>) { let Some(apps_dir) = applications_dir() else { return; }; - let icon_path = hicolor_icon_path().unwrap_or_else(|| PathBuf::from("nectarlink")); - if icon_path.is_absolute() && !icon_path.exists() { - let _ = super::icon::write_png(&icon_path, 128); - } + let icon_path = ensure_desktop_icon(); if let Err(e) = write_desktop_file_to(&apps_dir, &exe, &icon_path, custom_data_dir) { tracing::debug!(error = %e, "couldn't update the desktop launcher actions"); } } -/// Write `nectarlink.desktop` into `dir` pointing at `exe` and `icon_path`. -/// Extracted so unit tests can verify `.desktop` generation inside a temp directory. -pub fn write_desktop_file_to( - dir: &Path, - exe: &Path, - icon_path: &Path, - custom_data_dir: Option<&Path>, -) -> std::io::Result { - std::fs::create_dir_all(dir)?; - let desktop_path = dir.join("nectarlink.desktop"); +/// Render the `.desktop` entry content pointing at `exe` and `icon_path`. +pub fn build_desktop_file(exe: &Path, icon_path: &Path, custom_data_dir: Option<&Path>) -> String { let exe_str = super::autostart::escape_desktop_exec_arg(&exe.to_string_lossy()); let icon_str: String = icon_path.to_string_lossy().chars().filter(|&c| c != '\n' && c != '\r' && !c.is_control()).collect(); @@ -82,9 +79,9 @@ pub fn write_desktop_file_to( Exec=\"{exe_str}\" %U\n\ Icon={icon_str}\n\ Terminal=false\n\ - Categories=Utility;FileTransfer;\n\ - MimeType=x-scheme-handler/nectarlink;\n\ - StartupWMClass=NectarLink\n\ + Categories=Network;FileTransfer;Telephony;\n\ + MimeType=x-scheme-handler/nectarlink;x-scheme-handler/tel;x-scheme-handler/sms;x-scheme-handler/mms;x-scheme-handler/geo;\n\ + StartupWMClass=nectarlink\n\ Actions={};\n", actions_list.join(";") ); @@ -97,6 +94,20 @@ pub fn write_desktop_file_to( Exec=\"{exe_str}\" {args}\n" )); } + content +} + +/// Write `nectarlink.desktop` into `dir` pointing at `exe` and `icon_path`. +/// Extracted so unit tests can verify `.desktop` generation inside a temp directory. +pub fn write_desktop_file_to( + dir: &Path, + exe: &Path, + icon_path: &Path, + custom_data_dir: Option<&Path>, +) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let desktop_path = dir.join("nectarlink.desktop"); + let content = build_desktop_file(exe, icon_path, custom_data_dir); #[cfg(unix)] { @@ -180,7 +191,10 @@ mod tests { let text = std::fs::read_to_string(&desktop_path).expect("read desktop file"); assert!(text.contains("[Desktop Entry]")); - assert!(text.contains("MimeType=x-scheme-handler/nectarlink;")); + assert!(text.contains( + "MimeType=x-scheme-handler/nectarlink;x-scheme-handler/tel;x-scheme-handler/sms;x-scheme-handler/mms;x-scheme-handler/geo;" + )); + assert!(text.contains("StartupWMClass=nectarlink")); assert!(text.contains("Actions=send-files;send-clipboard;find-phone;take-photo;open-timeline;")); assert!(text.contains(&format!("Icon={}", icon.display()))); diff --git a/desktop/app/src/linux/mod.rs b/desktop/app/src/linux/mod.rs index f1538cb6..9037fe0e 100644 --- a/desktop/app/src/linux/mod.rs +++ b/desktop/app/src/linux/mod.rs @@ -39,6 +39,81 @@ pub mod wol; use std::{fs, io::Read, process::Command}; +/// How long a signalled quit may take before the process ends anyway, so +/// logging out never waits on the app. +const SIGNAL_QUIT_GRACE: std::time::Duration = std::time::Duration::from_secs(15); + +/// Quits the app cleanly on SIGTERM (logout, shutdown, `kill`), SIGINT and +/// SIGHUP, the way Quit does, so phone storage is unmounted and the phone +/// sees the PC leave. A second signal, or a quit that takes longer than +/// [`SIGNAL_QUIT_GRACE`], ends the process at once. +/// +/// The handler only writes a byte to a pipe (all a signal handler may safely +/// do); a thread reads it and asks the app to quit. The signal mask is left +/// alone, so processes the app starts still get their signals. +pub fn quit_on_signals(quit: fn()) { + use std::sync::atomic::{AtomicI32, Ordering}; + static WRITE_END: AtomicI32 = AtomicI32::new(-1); + + extern "C" fn on_signal(_: libc::c_int) { + let fd = WRITE_END.load(Ordering::Relaxed); + if fd >= 0 { + let byte = 1u8; + // SAFETY: write(2) is async-signal-safe; the pipe stays open for the process's life. + let _ = unsafe { libc::write(fd, (&raw const byte).cast(), 1) }; + } + } + + let mut fds = [0 as libc::c_int; 2]; + // SAFETY: pipe2 fills two descriptors on success. + if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } != 0 { + return; + } + let (read_end, write_end) = (fds[0], fds[1]); + let spawned = std::thread::Builder::new().name("signals".into()).spawn(move || { + let mut signals = 0; + loop { + let mut byte = 0u8; + // SAFETY: reads one byte into a local buffer from our own pipe. + let n = unsafe { libc::read(read_end, (&raw mut byte).cast(), 1) }; + if n < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { + continue; + } + if n != 1 { + return; + } + signals += 1; + if signals > 1 { + crate::storage::shutdown(); + std::process::exit(130); + } + tracing::info!("quitting on a signal"); + quit(); + let _ = std::thread::Builder::new().name("signal-quit-grace".into()).spawn(|| { + std::thread::sleep(SIGNAL_QUIT_GRACE); + tracing::warn!("quit took too long; ending"); + crate::storage::shutdown(); + std::process::exit(0); + }); + } + }); + if spawned.is_err() { + return; + } + WRITE_END.store(write_end, Ordering::Relaxed); + for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] { + // SAFETY: installs a handler that only calls write(2); the struct is + // zeroed, then the handler, flags and an empty mask are set. + unsafe { + let mut action: libc::sigaction = std::mem::zeroed(); + action.sa_sigaction = on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t; + action.sa_flags = libc::SA_RESTART; + libc::sigemptyset(&mut action.sa_mask); + libc::sigaction(signal, &action, std::ptr::null_mut()); + } + } +} + /// A stable machine identifier from `/etc/machine-id` or `/var/lib/dbus/machine-id`. pub fn machine_guid() -> Option { for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] { @@ -165,8 +240,139 @@ pub(crate) fn parse_system_dark_sources( true } +/// Runs `f` outside any active Tokio runtime context so `zbus::blocking` calls +/// (which use `zbus`'s internal Tokio `Runtime::block_on` when compiled with +/// the `tokio` feature) never panic with "Cannot start a runtime from within a +/// runtime" when invoked from a `nectarlink-core` worker thread. +pub(crate) fn non_tokio(f: impl FnOnce() -> T + Send) -> T { + if tokio::runtime::Handle::try_current().is_ok() { + std::thread::scope(|s| match s.spawn(f).join() { + Ok(v) => v, + Err(payload) => std::panic::resume_unwind(payload), + }) + } else { + f() + } +} + +/// Returns `true` if `bus_name` currently has an active owner on `conn` without +/// triggering a 25-second D-Bus service activation timeout. +pub(crate) fn dbus_name_has_owner(conn: &zbus::blocking::Connection, bus_name: &str) -> bool { + let Ok(reply) = conn.call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "NameHasOwner", + &(bus_name,), + ) else { + return false; + }; + reply.body().deserialize::().unwrap_or(false) +} + +/// Runs a short-lived helper command with `PR_SET_PDEATHSIG(SIGTERM)` and a +/// strict wall-clock timeout, killing and reaping the child if it does not +/// finish within `timeout`. +pub(crate) fn run_cmd_timeout( + bin: &str, + args: &[&str], + timeout: std::time::Duration, +) -> Option { + use std::os::unix::process::CommandExt; + use std::process::Stdio; + + let mut cmd = Command::new(bin); + cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + // SAFETY: `prctl(PR_SET_PDEATHSIG, SIGTERM)` is async-signal-safe. + unsafe { + cmd.pre_exec(|| { + let _ = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); + Ok(()) + }); + } + let mut child = cmd.spawn().ok()?; + let mut stdout_pipe = child.stdout.take(); + let mut stderr_pipe = child.stderr.take(); + let out_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut s) = stdout_pipe.take() { + let _ = s.read_to_end(&mut buf); + } + buf + }); + let err_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut s) = stderr_pipe.take() { + let _ = s.read_to_end(&mut buf); + } + buf + }); + + let deadline = std::time::Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + let stdout = out_handle.join().unwrap_or_default(); + let stderr = err_handle.join().unwrap_or_default(); + return Some(std::process::Output { status, stdout, stderr }); + } + Ok(None) => { + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(std::time::Duration::from_millis(15)); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } +} + +/// Queries the XDG Desktop Portal (`org.freedesktop.portal.Settings`) for an +/// `org.freedesktop.appearance` `u32` setting (`color-scheme` or `reduced-motion`). +fn read_portal_appearance_u32(key: &str) -> Option { + non_tokio(|| { + let conn = zbus::blocking::Connection::session().ok()?; + if !dbus_name_has_owner(&conn, "org.freedesktop.portal.Desktop") { + return None; + } + let msg = conn + .call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.portal.Settings"), + "ReadOne", + &("org.freedesktop.appearance", key), + ) + .ok()?; + let body = msg.body(); + if let Ok(val) = body.deserialize::() { + if let Ok(n) = u32::try_from(&val) { + return Some(n); + } + if let zbus::zvariant::Value::Value(inner) = &*val + && let Ok(n) = u32::try_from(&**inner) + { + return Some(n); + } + } + None + }) +} + /// Whether the desktop prefers a dark color scheme. pub fn system_dark() -> bool { + // XDG Desktop Portal `color-scheme`: 1 = prefer dark, 2 = prefer light, 0 = no preference. + match read_portal_appearance_u32("color-scheme") { + Some(1) => return true, + Some(2) => return false, + _ => {} + } let gtk_theme = std::env::var("GTK_THEME").ok(); let gsettings = Command::new("gsettings") .args(["get", "org.gnome.desktop.interface", "color-scheme"]) @@ -180,6 +386,10 @@ pub fn system_dark() -> bool { /// Whether desktop animations are turned off. pub fn reduce_motion() -> bool { + // XDG Desktop Portal `reduced-motion`: 1 = reduced motion enabled. + if read_portal_appearance_u32("reduced-motion") == Some(1) { + return true; + } if let Ok(output) = Command::new("gsettings").args(["get", "org.gnome.desktop.interface", "enable-animations"]).output() && output.status.success() @@ -194,22 +404,30 @@ pub fn reduce_motion() -> bool { /// Returns the usable desktop work area `(x, y, width, height)` in physical pixels. pub fn work_area() -> Option<(i32, i32, i32, i32)> { - if let Ok(output) = Command::new("xprop").args(["-root", "_NET_WORKAREA"]).output() + let (mut x, mut y, mut w, mut h) = (0, 0, 0, 0); + if crate::bridge::native::ffi::linux_work_area(&mut x, &mut y, &mut w, &mut h) && w > 0 && h > 0 { + return Some((x, y, w, h)); + } + if crate::bridge::native::ffi::linux_screen_geometry(&mut x, &mut y, &mut w, &mut h) && w > 0 && h > 0 { + return Some((x, y, w, h)); + } + if std::env::var_os("DISPLAY").is_some() + && let Ok(output) = Command::new("xprop").args(["-root", "_NET_WORKAREA"]).output() && output.status.success() { let text = String::from_utf8_lossy(&output.stdout); if let Some((_, rhs)) = text.split_once('=') { let nums: Vec = rhs.split(',').filter_map(|p| p.trim().parse::().ok()).take(4).collect(); - if let [x, y, w, h] = nums.as_slice() - && *w > 0 - && *h > 0 + if let [rx, ry, rw, rh] = nums.as_slice() + && *rw > 0 + && *rh > 0 { - return Some((*x, *y, *w, *h)); + return Some((*rx, *ry, *rw, *rh)); } } } - Some((0, 0, 1920, 1080)) + if cfg!(test) { Some((0, 0, 1920, 1080)) } else { None } } /// Fills `buf` with cryptographically strong random bytes from `/dev/urandom`. diff --git a/desktop/app/src/linux/net.rs b/desktop/app/src/linux/net.rs index 9c300ace..c6453198 100644 --- a/desktop/app/src/linux/net.rs +++ b/desktop/app/src/linux/net.rs @@ -1,10 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! Watching for IP address and network route changes on Linux via a Netlink //! `NETLINK_ROUTE` socket (with a `/proc/net/route` fallback), debounced so a -//! single network transition triggers one callback. +//! single network transition triggers a leading callback and coalesces trailing +//! events (such as `RTM_NEWADDR` arriving shortly after `RTM_NEWLINK`) without +//! dropping them. use std::{ fs, + os::fd::{AsRawFd, FromRawFd, OwnedFd}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -18,8 +21,6 @@ const AF_NETLINK: i32 = 16; const SOCK_RAW: i32 = 3; const SOCK_CLOEXEC: i32 = 0o2000000; const NETLINK_ROUTE: i32 = 0; -const SOL_SOCKET: i32 = 1; -const SO_RCVTIMEO: i32 = 20; const RTMGRP_LINK: u32 = 1; const RTMGRP_IPV4_IFADDR: u32 = 0x10; const RTMGRP_IPV4_ROUTE: u32 = 0x40; @@ -33,26 +34,95 @@ struct SockAddrNl { nl_groups: u32, } -#[repr(C)] -struct Timeval { - tv_sec: i64, - tv_usec: i64, -} - -unsafe extern "C" { - fn socket(domain: i32, ty: i32, protocol: i32) -> i32; - fn setsockopt(fd: i32, level: i32, optname: i32, optval: *const std::ffi::c_void, optlen: u32) -> i32; - fn bind(fd: i32, addr: *const std::ffi::c_void, addrlen: u32) -> i32; - fn recv(fd: i32, buf: *mut std::ffi::c_void, len: usize, flags: i32) -> isize; - fn close(fd: i32) -> i32; +struct StopHandle { + flag: Arc, + wake_fd: Option>, } -static STOP_FLAG: Mutex>> = Mutex::new(None); +static STOP_STATE: Mutex> = Mutex::new(None); fn proc_network_snapshot() -> String { let route = fs::read_to_string("/proc/net/route").unwrap_or_default(); let inet6 = fs::read_to_string("/proc/net/if_inet6").unwrap_or_default(); - format!("{route}\n---\n{inet6}") + let fib_trie = fs::read_to_string("/proc/net/arp").unwrap_or_default(); + format!("{route}\n---\n{inet6}\n---\n{fib_trie}") +} + +fn create_wake_eventfd() -> Option> { + // SAFETY: `eventfd` with `EFD_CLOEXEC | EFD_NONBLOCK` has no preconditions. + let fd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) }; + if fd < 0 { + None + } else { + // SAFETY: `fd` is a freshly created file descriptor owned by us. + Some(Arc::new(unsafe { OwnedFd::from_raw_fd(fd) })) + } +} + +fn drain_socket(fd: i32, buf: &mut [u8]) { + loop { + // SAFETY: `buf` is a valid mutable slice and `fd` is an open socket. + let n = unsafe { libc::recv(fd, buf.as_mut_ptr().cast(), buf.len(), libc::MSG_DONTWAIT) }; + if n <= 0 { + break; + } + } +} + +fn watch_netlink_loop(fd: i32, wake_fd: i32, stop_flag: &AtomicBool, on_change: &dyn Fn()) { + let mut buf = [0u8; 4096]; + let mut last_emit: Option = None; + + while !stop_flag.load(Ordering::Relaxed) { + let mut pfds = [ + libc::pollfd { fd, events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: wake_fd, events: libc::POLLIN, revents: 0 }, + ]; + let nfds: libc::nfds_t = if wake_fd >= 0 { 2 } else { 1 }; + // SAFETY: `pfds` is a valid stack array of `nfds` pollfd structs. + let rc = unsafe { libc::poll(pfds.as_mut_ptr(), nfds, 1000) }; + if stop_flag.load(Ordering::Relaxed) || (wake_fd >= 0 && (pfds[1].revents & libc::POLLIN) != 0) { + break; + } + if rc <= 0 || (pfds[0].revents & libc::POLLIN) == 0 { + continue; + } + + // SAFETY: `buf` is a live stack buffer and `fd` is our open socket. + let n = unsafe { libc::recv(fd, buf.as_mut_ptr().cast(), buf.len(), libc::MSG_DONTWAIT) }; + if n <= 0 || stop_flag.load(Ordering::Relaxed) { + continue; + } + drain_socket(fd, &mut buf); + + if let Some(prev) = last_emit { + let elapsed = prev.elapsed(); + if elapsed < DEBOUNCE { + let remaining = DEBOUNCE - elapsed; + let timeout_ms = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; + if wake_fd >= 0 { + let mut wake_pfd = [libc::pollfd { fd: wake_fd, events: libc::POLLIN, revents: 0 }]; + // SAFETY: `wake_pfd` is a valid 1-element pollfd array. + let _ = unsafe { libc::poll(wake_pfd.as_mut_ptr(), 1, timeout_ms) }; + if stop_flag.load(Ordering::Relaxed) || (wake_pfd[0].revents & libc::POLLIN) != 0 { + break; + } + } else { + std::thread::sleep(remaining); + if stop_flag.load(Ordering::Relaxed) { + break; + } + } + drain_socket(fd, &mut buf); + } + } + + if stop_flag.load(Ordering::Relaxed) { + break; + } + last_emit = Some(Instant::now()); + on_change(); + } } /// Calls `callback` whenever an IP address or route is added or removed on the PC. @@ -60,12 +130,16 @@ fn proc_network_snapshot() -> String { pub fn watch(callback: impl Fn() + Send + Sync + 'static) { stop(); let stop_flag = Arc::new(AtomicBool::new(false)); - *STOP_FLAG.lock().unwrap_or_else(|e| e.into_inner()) = Some(stop_flag.clone()); + let wake_fd = create_wake_eventfd(); + *STOP_STATE.lock().unwrap_or_else(|e| e.into_inner()) = + Some(StopHandle { flag: stop_flag.clone(), wake_fd: wake_fd.clone() }); let _ = std::thread::Builder::new().name("net-watch".into()).spawn(move || { + let raw_wake_fd = wake_fd.as_ref().map_or(-1, |f| f.as_raw_fd()); + // Try opening an AF_NETLINK route socket subscribed to link + IPv4/IPv6 address groups. - // SAFETY: creating a standard non-privileged NETLINK_ROUTE socket with a receive timeout. - let fd = unsafe { socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE) }; + // SAFETY: creating a standard non-privileged NETLINK_ROUTE socket. + let fd = unsafe { libc::socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE) }; if fd >= 0 { let bound = unsafe { let addr = SockAddrNl { @@ -74,46 +148,32 @@ pub fn watch(callback: impl Fn() + Send + Sync + 'static) { nl_pid: 0, nl_groups: RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR | RTMGRP_IPV4_ROUTE, }; - let tv = Timeval { tv_sec: 1, tv_usec: 0 }; - let _ = setsockopt( - fd, - SOL_SOCKET, - SO_RCVTIMEO, - (&raw const tv).cast(), - std::mem::size_of::() as u32, - ); - bind(fd, (&raw const addr).cast(), std::mem::size_of::() as u32) == 0 + libc::bind(fd, (&raw const addr).cast(), std::mem::size_of::() as u32) == 0 }; if bound { - let mut buf = [0u8; 4096]; - let mut last_fired: Option = None; - while !stop_flag.load(Ordering::Relaxed) { - // SAFETY: `buf` is a live stack buffer and `fd` is our open socket. - let n = unsafe { recv(fd, buf.as_mut_ptr().cast(), buf.len(), 0) }; - if n > 0 && !stop_flag.load(Ordering::Relaxed) { - let now = Instant::now(); - if last_fired.is_none_or(|prev| now.duration_since(prev) >= DEBOUNCE) { - last_fired = Some(now); - callback(); - } - } - } + watch_netlink_loop(fd, raw_wake_fd, &stop_flag, &callback); // SAFETY: closing our own socket fd. unsafe { - let _ = close(fd); + let _ = libc::close(fd); } return; } // SAFETY: closing our own socket fd when bind failed. unsafe { - let _ = close(fd); + let _ = libc::close(fd); } } // Fallback: poll `/proc/net/route` and `/proc/net/if_inet6`. let mut last_snapshot = proc_network_snapshot(); while !stop_flag.load(Ordering::Relaxed) { - std::thread::sleep(Duration::from_secs(2)); + if raw_wake_fd >= 0 { + let mut wake_pfd = [libc::pollfd { fd: raw_wake_fd, events: libc::POLLIN, revents: 0 }]; + // SAFETY: `wake_pfd` is a valid 1-element pollfd array. + let _ = unsafe { libc::poll(wake_pfd.as_mut_ptr(), 1, 2000) }; + } else { + std::thread::sleep(Duration::from_secs(2)); + } if stop_flag.load(Ordering::Relaxed) { break; } @@ -133,18 +193,88 @@ pub fn watch_address_changes(on_change: impl Fn() + Send + Sync + 'static) { /// Stops watching for network address changes. pub fn stop() { - if let Some(flag) = STOP_FLAG.lock().unwrap_or_else(|e| e.into_inner()).take() { - flag.store(true, Ordering::Relaxed); + if let Some(handle) = STOP_STATE.lock().unwrap_or_else(|e| e.into_inner()).take() { + handle.flag.store(true, Ordering::Relaxed); + if let Some(wake) = handle.wake_fd { + let val: u64 = 1; + // SAFETY: writing an 8-byte counter increment to a valid eventfd. + unsafe { + let _ = libc::write(wake.as_raw_fd(), (&raw const val).cast(), std::mem::size_of::()); + } + } } } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::AtomicUsize; #[test] fn starts_and_stops_net_watcher_cleanly() { watch(|| {}); stop(); } + + #[test] + fn trailing_event_within_debounce_window_is_coalesced_and_emitted_not_dropped() { + let mut fds = [0i32; 2]; + // SAFETY: creating an AF_UNIX datagram socketpair for testing `watch_netlink_loop`. + let rc = unsafe { + libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0, fds.as_mut_ptr()) + }; + assert_eq!(rc, 0); + let read_fd = fds[0]; + let write_fd = fds[1]; + + let wake = create_wake_eventfd().expect("eventfd"); + let wake_raw = wake.as_raw_fd(); + let stop_flag = Arc::new(AtomicBool::new(false)); + let stop_clone = stop_flag.clone(); + let count = Arc::new(AtomicUsize::new(0)); + let count_clone = count.clone(); + + let worker = std::thread::spawn(move || { + watch_netlink_loop(read_fd, wake_raw, &stop_clone, &move || { + count_clone.fetch_add(1, Ordering::SeqCst); + }); + }); + + let msg1 = b"RTM_NEWLINK"; + let msg2 = b"RTM_NEWADDR"; + let msg3 = b"RTM_NEWROUTE"; + unsafe { + let _ = libc::send(write_fd, msg1.as_ptr().cast(), msg1.len(), 0); + } + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(count.load(Ordering::SeqCst), 1, "leading event should fire immediately"); + + // Send two trailing events inside the 500ms window after `msg1`. + unsafe { + let _ = libc::send(write_fd, msg2.as_ptr().cast(), msg2.len(), 0); + } + std::thread::sleep(Duration::from_millis(50)); + unsafe { + let _ = libc::send(write_fd, msg3.as_ptr().cast(), msg3.len(), 0); + } + + // Wait for the remaining 500ms debounce window to complete and emit the trailing callback. + std::thread::sleep(Duration::from_millis(450)); + assert_eq!( + count.load(Ordering::SeqCst), + 2, + "trailing RTM_NEWADDR/RTM_NEWROUTE within 500ms must fire after debounce instead of being dropped" + ); + + stop_flag.store(true, Ordering::SeqCst); + let one: u64 = 1; + unsafe { + let _ = libc::write(wake.as_raw_fd(), (&raw const one).cast(), std::mem::size_of::()); + } + let _ = worker.join(); + unsafe { + let _ = libc::close(read_fd); + let _ = libc::close(write_fd); + } + } } diff --git a/desktop/app/src/linux/shell.rs b/desktop/app/src/linux/shell.rs index 7f52a63d..95d88eff 100644 --- a/desktop/app/src/linux/shell.rs +++ b/desktop/app/src/linux/shell.rs @@ -1,42 +1,116 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! Opening URLs, files, folders, and power actions (lock, sleep) on Linux via -//! `xdg-open`, D-Bus (`org.freedesktop.FileManager1`), `loginctl`, and `systemctl`. +//! XDG Desktop Portals, `org.freedesktop.FileManager1`, `xdg-open`, `loginctl`, and `systemctl`. use std::{ + collections::HashMap, fs, + io::Write, path::{Path, PathBuf}, process::{Command, Stdio}, time::{SystemTime, UNIX_EPOCH}, }; +const ALLOWED_URL_SCHEMES: &[&str] = &["http", "https", "mailto", "tel", "sms", "mms", "geo", "nectarlink"]; + +/// Spawns `cmd` with null stdio and reaps the child in a background thread so +/// terminated subprocesses never remain as `` zombies. +fn spawn_and_reap(mut cmd: Command) -> std::io::Result<()> { + let mut child = cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn()?; + let _ = std::thread::Builder::new().name("child-reaper".into()).spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + fn spawn_detached(program: &str, args: &[&str]) -> Result<(), String> { - Command::new(program) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map(|_| ()) - .map_err(|e| format!("{program}: {e}")) + let mut cmd = Command::new(program); + cmd.args(args); + spawn_and_reap(cmd).map_err(|e| format!("{program}: {e}")) } -/// Opens an `http://` or `https://` URL (or local settings URI) with `xdg-open`. +/// Validates that `url` uses an allowed scheme (`http`, `https`, `mailto`, `tel`, +/// `sms`, `mms`, `geo`, `nectarlink`), does not begin with `-`, and contains no +/// control or whitespace characters. +fn validate_url(url: &str) -> Result<&str, String> { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err("empty URL".into()); + } + if trimmed.starts_with('-') { + return Err("URL must not start with '-'".into()); + } + if trimmed.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("URL contains invalid whitespace or control characters".into()); + } + let Some((scheme, rest)) = trimmed.split_once(':') else { + return Err("missing URL scheme".into()); + }; + let scheme_lower = scheme.to_ascii_lowercase(); + if !ALLOWED_URL_SCHEMES.contains(&scheme_lower.as_str()) { + return Err(format!("unsupported URL scheme: {scheme}")); + } + if rest.is_empty() { + return Err("empty URL target".into()); + } + if (scheme_lower == "http" || scheme_lower == "https") && (!rest.starts_with("//") || rest.len() <= 2) { + return Err("http/https URL must include a host".into()); + } + Ok(trimmed) +} + +/// Opens a validated URL (`http`, `https`, `mailto`, `tel`, `sms`, `mms`, `geo`, +/// `nectarlink`) via `org.freedesktop.portal.OpenURI` over D-Bus first so the +/// URL is not exposed in process command-line arguments, falling back to +/// `gio open` / `xdg-open`. pub fn open_url(url: &str) -> Result<(), String> { - let lower = url.get(..8).map(str::to_ascii_lowercase).unwrap_or_default(); - if lower.starts_with("http://") || lower.starts_with("https://") { - return spawn_detached("xdg-open", &[url]); + let trimmed = url.trim(); + let validated = validate_url(trimmed)?; + + if !cfg!(test) + && super::non_tokio(|| { + let Ok(conn) = zbus::blocking::Connection::session() else { + return false; + }; + if !super::dbus_name_has_owner(&conn, "org.freedesktop.portal.Desktop") { + return false; + } + let options: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new(); + conn.call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.portal.OpenURI"), + "OpenURI", + &("", validated, &options), + ) + .is_ok() + }) + { + return Ok(()); } - // Map Windows settings deep-links used by Doctor / Bluetooth UI to desktop equivalents or no-op. - if url.starts_with("ms-settings:") || url.eq_ignore_ascii_case("firewall.cpl") { - return spawn_detached("xdg-open", &[url]).or(Ok(())); + + if Command::new("gio") + .args(["open", "--", validated]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + return Ok(()); } - Err("only http and https links can be opened".into()) + + spawn_detached("xdg-open", &[validated]) } /// Opens the default mail client for an email address (`mailto:`). pub fn open_mailto(email: &str) -> Result<(), String> { let trimmed = email.trim(); - if trimmed.is_empty() || !trimmed.contains('@') || trimmed.chars().any(char::is_whitespace) { + if trimmed.is_empty() + || trimmed.starts_with('-') + || !trimmed.contains('@') + || trimmed.chars().any(|c| c.is_whitespace() || c.is_control()) + { return Err("invalid email address".into()); } let uri = if trimmed.get(..7).is_some_and(|p| p.eq_ignore_ascii_case("mailto:")) { @@ -44,7 +118,7 @@ pub fn open_mailto(email: &str) -> Result<(), String> { } else { format!("mailto:{trimmed}") }; - spawn_detached("xdg-open", &[&uri]) + open_url(&uri) } /// Opens a street address in OpenStreetMap in the default browser. @@ -72,31 +146,148 @@ pub fn is_url_scheme_registered(scheme: &str) -> bool { .is_some_and(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty()) } -/// Opens a file with the user's default application (`xdg-open`). +/// Percent-encodes a filesystem path for use in `file://` URIs and FreeDesktop +/// `.trashinfo` `Path=` entries (preserving `/` and unreserved URI bytes). +pub(crate) fn percent_encode_path(path: &Path) -> String { + #[cfg(unix)] + let bytes: &[u8] = std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str()); + #[cfg(not(unix))] + let owned = path.to_string_lossy().into_owned().into_bytes(); + #[cfg(not(unix))] + let bytes: &[u8] = &owned; + + let mut out = String::with_capacity(bytes.len()); + for &b in bytes { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { + out.push(b as char); + } + _ => { + out.push_str(&format!("%{b:02X}")); + } + } + } + out +} + +/// Formats `path` as a properly percent-encoded `file://` URI. +pub(crate) fn format_file_uri(path: &Path) -> String { + let encoded = percent_encode_path(path); + if encoded.starts_with('/') { format!("file://{encoded}") } else { format!("file:///{encoded}") } +} + +/// Opens a file with the user's default application via +/// `org.freedesktop.portal.OpenURI.OpenFile` (passing a file descriptor so the +/// path is not exposed on a subprocess command line), falling back to `xdg-open`. pub fn open_file(path: &Path) -> Result<(), String> { - spawn_detached("xdg-open", &[&path.to_string_lossy()]) + let path_str = path.to_string_lossy(); + if path_str.is_empty() || path_str.starts_with('-') { + return Err("invalid file path".into()); + } + + if !cfg!(test) + && let Ok(file) = fs::File::open(path) + && super::non_tokio(|| { + let Ok(conn) = zbus::blocking::Connection::session() else { + return false; + }; + if !super::dbus_name_has_owner(&conn, "org.freedesktop.portal.Desktop") { + return false; + } + let options: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new(); + conn.call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.portal.OpenURI"), + "OpenFile", + &("", zbus::zvariant::Fd::from(&file), &options), + ) + .is_ok() + }) + { + return Ok(()); + } + + spawn_detached("xdg-open", &[&path_str]) } -/// Opens a directory in the file manager (`xdg-open`). +/// Opens a directory in the file manager via `org.freedesktop.FileManager1.ShowFolders` +/// or `org.freedesktop.portal.OpenURI.OpenDirectory` first, falling back to `xdg-open`. pub fn open_folder(path: &Path) -> Result<(), String> { - spawn_detached("xdg-open", &[&path.to_string_lossy()]) + let path_str = path.to_string_lossy(); + if path_str.is_empty() || path_str.starts_with('-') { + return Err("invalid folder path".into()); + } + + let abs = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let folder_uri = format_file_uri(&abs); + + if !cfg!(test) + && super::non_tokio(|| { + let Ok(conn) = zbus::blocking::Connection::session() else { + return false; + }; + if super::dbus_name_has_owner(&conn, "org.freedesktop.FileManager1") + && conn + .call_method( + Some("org.freedesktop.FileManager1"), + "/org/freedesktop/FileManager1", + Some("org.freedesktop.FileManager1"), + "ShowFolders", + &(vec![folder_uri.as_str()], ""), + ) + .is_ok() + { + return true; + } + + if super::dbus_name_has_owner(&conn, "org.freedesktop.portal.Desktop") + && let Ok(dir_file) = fs::File::open(&abs) + { + let options: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new(); + if conn + .call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.portal.OpenURI"), + "OpenDirectory", + &("", zbus::zvariant::Fd::from(&dir_file), &options), + ) + .is_ok() + { + return true; + } + } + false + }) + { + return Ok(()); + } + + spawn_detached("xdg-open", &[&path_str]) } /// Reveals and selects `path` in the file manager via `org.freedesktop.FileManager1.ShowItems`, -/// falling back to opening the parent directory with `xdg-open`. +/// falling back to opening the parent directory with [`open_folder`]. pub fn select_in_explorer(path: &Path) -> Result<(), String> { let abs = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); - let file_uri = format!("file://{}", abs.to_string_lossy()); - if let Ok(conn) = zbus::blocking::Connection::session() - && conn - .call_method( - Some("org.freedesktop.FileManager1"), - "/org/freedesktop/FileManager1", - Some("org.freedesktop.FileManager1"), - "ShowItems", - &(vec![file_uri.as_str()], ""), - ) - .is_ok() + let file_uri = format_file_uri(&abs); + if !cfg!(test) + && super::non_tokio(|| { + let Ok(conn) = zbus::blocking::Connection::session() else { + return false; + }; + super::dbus_name_has_owner(&conn, "org.freedesktop.FileManager1") + && conn + .call_method( + Some("org.freedesktop.FileManager1"), + "/org/freedesktop/FileManager1", + Some("org.freedesktop.FileManager1"), + "ShowItems", + &(vec![file_uri.as_str()], ""), + ) + .is_ok() + }) { return Ok(()); } @@ -104,28 +295,12 @@ pub fn select_in_explorer(path: &Path) -> Result<(), String> { open_folder(folder) } -/// Runs `program` with `arguments`. Special-cases Windows `"explorer.exe"` calls from shared UI code. +/// Runs `program` with `arguments` (split on spaces, `"` groups). pub fn run(program: &Path, arguments: &str) -> Result<(), String> { - let prog_str = program.to_string_lossy(); - if prog_str.eq_ignore_ascii_case("explorer.exe") || prog_str.eq_ignore_ascii_case("explorer") { - let trimmed = arguments.trim(); - if let Some(rest) = trimmed.strip_prefix("/select,") { - let target = rest.trim().trim_matches('"'); - return select_in_explorer(Path::new(target)); - } - let target = trimmed.trim_matches('"'); - return open_folder(Path::new(target)); - } - let args = split_args(arguments); - Command::new(program) - .args(&args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map(|_| ()) - .map_err(|e| e.to_string()) + let mut cmd = Command::new(program); + cmd.args(&args); + spawn_and_reap(cmd).map_err(|e| e.to_string()) } fn split_args(arguments: &str) -> Vec { @@ -179,8 +354,152 @@ pub fn sleep_system() -> Result<(), String> { sleep() } -/// Launches an interactive or full-screen screenshot tool on Linux, falling back to `Print`. +fn decode_file_uri(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let path_part = + rest.strip_prefix("localhost/").map(|s| format!("/{s}")).unwrap_or_else(|| rest.to_owned()); + let bytes = path_part.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hi = (bytes[i + 1] as char).to_digit(16)?; + let lo = (bytes[i + 2] as char).to_digit(16)?; + out.push(((hi << 4) | lo) as u8); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Some(PathBuf::from(std::ffi::OsString::from_vec(out))) + } + #[cfg(not(unix))] + { + String::from_utf8(out).ok().map(PathBuf::from) + } +} + +fn screenshot_via_portal(save_path: &Path) -> bool { + let Ok(conn) = zbus::blocking::Connection::session() else { + return false; + }; + let Ok(rule) = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .interface("org.freedesktop.portal.Request") + .and_then(|b| b.member("Response")) + .map(|b| b.build()) + else { + return false; + }; + let Ok(iter) = zbus::blocking::MessageIterator::for_match_rule(rule, &conn, None) else { + return false; + }; + + let mut options: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new(); + options.insert("interactive", zbus::zvariant::Value::Bool(false)); + + let Ok(reply) = conn.call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.portal.Screenshot"), + "Screenshot", + &("", &options), + ) else { + return false; + }; + + let expected_handle = + reply.body().deserialize::().ok().map(|p| p.to_string()); + + for msg in iter.take(16) { + let Ok(msg) = msg else { + break; + }; + if let Some(ref handle) = expected_handle + && msg.header().path().map(|p| p.as_str()) != Some(handle.as_str()) + { + continue; + } + if let Ok((code, results)) = + msg.body().deserialize::<(u32, HashMap)>() + { + if code != 0 { + return false; + } + if let Some(uri_val) = results.get("uri") + && let Ok(uri_str) = <&str>::try_from(uri_val) + && let Some(src_path) = decode_file_uri(uri_str) + && src_path.is_file() + { + if src_path == save_path { + return true; + } + if fs::copy(&src_path, save_path).is_ok() { + let _ = fs::remove_file(&src_path); + return true; + } + } + return false; + } + } + false +} + +/// Captures a screenshot to `save_path` using `org.freedesktop.portal.Screenshot`, +/// `grim`, `gnome-screenshot`, `spectacle`, or ImageMagick `import`, and verifies +/// that `save_path` exists and is non-empty. +pub fn take_screenshot_to(save_path: &Path) -> Result<(), String> { + if let Some(parent) = save_path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let check_saved = || save_path.exists() && save_path.metadata().is_ok_and(|m| m.len() > 0); + + if screenshot_via_portal(save_path) && check_saved() { + return Ok(()); + } + + let dest = save_path.to_string_lossy(); + let cli_candidates: &[(&str, &[&str])] = &[ + ("grim", &[&dest]), + ("gnome-screenshot", &["-f", &dest]), + ("spectacle", &["-b", "-n", "-o", &dest]), + ("import", &["-window", "root", &dest]), + ]; + + for (prog, args) in cli_candidates { + if Command::new(prog) + .args(*args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + && check_saved() + { + return Ok(()); + } + } + + Err("failed to capture screenshot".into()) +} + +/// Captures a screenshot to the user's Pictures directory (or temporary directory), +/// falling back to interactive screenshot utilities or the `Print` key. pub fn take_screenshot() -> Result<(), String> { + let dir = dirs::picture_dir().or_else(dirs::home_dir).unwrap_or_else(std::env::temp_dir); + let stamp = format_deletion_date(SystemTime::now()).replace(':', "-"); + let save_path = dir.join(format!("Screenshot_{stamp}.png")); + if take_screenshot_to(&save_path).is_ok() { + return Ok(()); + } + let candidates: &[(&str, &[&str])] = &[ ("spectacle", &[]), ("gnome-screenshot", &["-i"]), @@ -196,8 +515,35 @@ pub fn take_screenshot() -> Result<(), String> { Ok(()) } +/// Strips FreeDesktop `.desktop` `Exec=` field codes (`%f`, `%F`, `%u`, `%U`, +/// `%i`, `%c`, `%k`, etc.) even when embedded in an argument (e.g. `--url=%u`), +/// and unescapes `%%` to `%`. +fn strip_desktop_field_codes(token: &str) -> String { + let mut out = String::with_capacity(token.len()); + let mut chars = token.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '%' { + match chars.peek().copied() { + Some('%') => { + chars.next(); + out.push('%'); + } + Some('f' | 'F' | 'u' | 'U' | 'i' | 'c' | 'k' | 'd' | 'D' | 'n' | 'N' | 'v' | 'm') => { + chars.next(); + } + Some(_) | None => { + out.push('%'); + } + } + } else { + out.push(ch); + } + } + out +} + /// Parses the `[Desktop Entry]` `Exec=` command from a `.desktop` file and strips -/// freedesktop field codes (`%f`, `%F`, `%u`, `%U`, `%i`, `%c`, `%k`, etc.). +/// freedesktop field codes (`%f`, `%F`, `%u`, `%U`, `%i`, `%c`, `%k`, `%%`). pub(crate) fn parse_desktop_exec(contents: &str) -> Option<(String, Vec)> { let mut in_desktop_entry = false; for raw_line in contents.lines() { @@ -216,10 +562,7 @@ pub(crate) fn parse_desktop_exec(contents: &str) -> Option<(String, Vec) let tokens = split_args(rest.trim()); let mut filtered: Vec = Vec::new(); for tok in tokens { - if tok.starts_with('%') && tok.len() == 2 && tok != "%%" { - continue; - } - let cleaned = tok.replace("%%", "%"); + let cleaned = strip_desktop_field_codes(&tok); if !cleaned.is_empty() { filtered.push(cleaned); } @@ -267,14 +610,14 @@ pub fn launch_app(path: &Path) -> Result<(), String> { let resolved = if path.is_file() { Some(path.to_path_buf()) } else { find_desktop_file(trimmed) }; if let Some(ref desktop_path) = resolved { - // 1. Try `gio launch ` + // 1. Try `gio launch ` and check its exit status. if Command::new("gio") .args(["launch", &desktop_path.to_string_lossy()]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .spawn() - .is_ok() + .status() + .is_ok_and(|s| s.success()) { return Ok(()); } @@ -299,11 +642,7 @@ pub fn launch_app(path: &Path) -> Result<(), String> { return Err(format!("could not launch desktop entry {trimmed}")); } - if path.is_file() - && let Ok(child) = - Command::new(path).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() - { - drop(child); + if path.is_file() && spawn_and_reap(Command::new(path)).is_ok() { return Ok(()); } @@ -312,14 +651,9 @@ pub fn launch_app(path: &Path) -> Result<(), String> { /// Alias for [`launch_app`]. pub fn launch_executable(path: &Path, args: &[&str]) -> Result<(), String> { - Command::new(path) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map(|_| ()) - .map_err(|e| e.to_string()) + let mut cmd = Command::new(path); + cmd.args(args); + spawn_and_reap(cmd).map_err(|e| e.to_string()) } /// Runs a shell command (`sh -c `) detached in the background. @@ -331,18 +665,102 @@ pub fn run_command(command: &str) -> Result<(), String> { spawn_detached("sh", &["-c", trimmed]) } -/// Moves `path` to the FreeDesktop Trash (`~/.local/share/Trash/files/` + `.trashinfo`), -/// or falls back to `gio trash`. +/// Formats `now` as local `YYYY-MM-DDTHH:MM:SS` via `libc::localtime_r` as +/// required by the FreeDesktop.org Trash 1.0 specification. +pub(crate) fn format_deletion_date(now: SystemTime) -> String { + let secs = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let time_t = secs as libc::time_t; + // SAFETY: `libc::tm` is a plain C struct of integers and a pointer; zero-initialization + // is valid before passing `&mut tm` to `localtime_r`. + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + // SAFETY: `time_t` and `tm` are valid stack pointers; `localtime_r` is thread-safe. + let res = unsafe { libc::localtime_r(&time_t, &mut tm) }; + if res.is_null() { + return "1970-01-01T00:00:00".to_owned(); + } + let year = tm.tm_year + 1900; + let month = (tm.tm_mon + 1).clamp(1, 12); + let day = tm.tm_mday.clamp(1, 31); + let hour = tm.tm_hour.clamp(0, 23); + let min = tm.tm_min.clamp(0, 59); + let sec = tm.tm_sec.clamp(0, 60); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}") +} + +/// Formats the contents of a FreeDesktop.org `.trashinfo` file with a +/// percent-encoded `Path=` and `DeletionDate=`. +pub(crate) fn format_trashinfo(original_path: &Path, deletion_date: &str) -> String { + format!("[Trash Info]\nPath={}\nDeletionDate={}\n", percent_encode_path(original_path), deletion_date) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else if file_type.is_symlink() { + let link_target = fs::read_link(&src_path)?; + #[cfg(unix)] + std::os::unix::fs::symlink(&link_target, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn move_across_filesystems(src: &Path, dst: &Path) -> std::io::Result<()> { + let meta = fs::symlink_metadata(src)?; + if meta.is_dir() { + if let Err(e) = copy_dir_recursive(src, dst) { + let _ = fs::remove_dir_all(dst); + return Err(e); + } + fs::remove_dir_all(src) + } else if meta.file_type().is_symlink() { + let link_target = fs::read_link(src)?; + #[cfg(unix)] + std::os::unix::fs::symlink(&link_target, dst)?; + if let Err(e) = fs::remove_file(src) { + let _ = fs::remove_file(dst); + return Err(e); + } + Ok(()) + } else { + if let Err(e) = fs::copy(src, dst) { + let _ = fs::remove_file(dst); + return Err(e); + } + if let Err(e) = fs::remove_file(src) { + let _ = fs::remove_file(dst); + return Err(e); + } + Ok(()) + } +} + +/// Moves `path` to the FreeDesktop Trash (`~/.local/share/Trash/files/` + `.trashinfo`). pub fn recycle_file(path: &Path) -> Result<(), String> { let data_dir = dirs::data_local_dir().ok_or_else(|| "no XDG_DATA_HOME directory".to_owned())?; recycle_file_in(&data_dir.join("Trash"), path) } fn recycle_file_in(trash_dir: &Path, path: &Path) -> Result<(), String> { - if !path.exists() { + if path.symlink_metadata().is_err() { return Err(format!("{} does not exist", path.display())); } - let abs = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let abs = match (path.parent(), path.file_name()) { + (Some(parent), Some(name)) => { + let canon_parent = fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()); + canon_parent.join(name) + } + _ => fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), + }; + let files_dir = trash_dir.join("files"); let info_dir = trash_dir.join("info"); fs::create_dir_all(&files_dir).map_err(|e| e.to_string())?; @@ -351,22 +769,54 @@ fn recycle_file_in(trash_dir: &Path, path: &Path) -> Result<(), String> { let base_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("trashed_item"); let mut chosen_name = base_name.to_owned(); let mut counter = 2u32; - while files_dir.join(&chosen_name).exists() || info_dir.join(format!("{chosen_name}.trashinfo")).exists() - { - chosen_name = format!("{base_name}.{counter}"); - counter += 1; - } - let dest_file: PathBuf = files_dir.join(&chosen_name); - let dest_info: PathBuf = info_dir.join(format!("{chosen_name}.trashinfo")); - let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); - let info_contents = - format!("[Trash Info]\nPath={}\nDeletionDate=1970-01-01T00:00:{:02}\n", abs.display(), secs % 60); - fs::write(&dest_info, info_contents).map_err(|e| e.to_string())?; - if let Err(e) = fs::rename(path, &dest_file) { + let (dest_file, dest_info, mut info_file) = loop { + let candidate_file = files_dir.join(&chosen_name); + let candidate_info = info_dir.join(format!("{chosen_name}.trashinfo")); + if candidate_file.symlink_metadata().is_ok() { + chosen_name = format!("{base_name}.{counter}"); + counter += 1; + continue; + } + + let mut opts = fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + + match opts.open(&candidate_info) { + Ok(f) => break (candidate_file, candidate_info, f), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + chosen_name = format!("{base_name}.{counter}"); + counter += 1; + } + Err(e) => return Err(e.to_string()), + } + }; + + let deletion_date = format_deletion_date(SystemTime::now()); + let info_contents = format_trashinfo(&abs, &deletion_date); + if let Err(e) = info_file.write_all(info_contents.as_bytes()).and_then(|()| info_file.flush()) { + drop(info_file); let _ = fs::remove_file(&dest_info); return Err(e.to_string()); } + drop(info_file); + + if let Err(e) = fs::rename(path, &dest_file) { + if e.raw_os_error() == Some(libc::EXDEV) { + if let Err(copy_err) = move_across_filesystems(path, &dest_file) { + let _ = fs::remove_file(&dest_info); + return Err(copy_err.to_string()); + } + } else { + let _ = fs::remove_file(&dest_info); + return Err(e.to_string()); + } + } Ok(()) } @@ -378,26 +828,52 @@ mod tests { fn refuses_non_web_urls() { assert!(open_url("file:///etc/passwd").is_err()); assert!(open_url("javascript:alert(1)").is_err()); + assert!(open_url("-http://example.com").is_err()); + assert!(open_url("http://").is_err()); } #[test] fn validates_mailto_and_maps() { assert!(open_mailto("").is_err()); assert!(open_mailto("not-an-email").is_err()); + assert!(open_mailto("-user@example.com").is_err()); assert!(open_maps(" ").is_err()); } + #[test] + fn formats_file_uri_with_percent_encoding() { + assert_eq!( + format_file_uri(Path::new("/home/user/My Documents/report #1 (final).pdf")), + "file:///home/user/My%20Documents/report%20%231%20%28final%29.pdf" + ); + assert_eq!(format_file_uri(Path::new("/tmp/100%_done?.txt")), "file:///tmp/100%25_done%3F.txt"); + } + #[test] fn moves_file_to_freedesktop_trash() { let dir = tempfile::tempdir().unwrap(); let trash = dir.path().join("Trash"); - let file = dir.path().join("note.txt"); + let file = dir.path().join("my note #1.txt"); fs::write(&file, b"hello").unwrap(); recycle_file_in(&trash, &file).unwrap(); assert!(!file.exists()); - assert!(trash.join("files").join("note.txt").is_file()); - assert!(trash.join("info").join("note.txt.trashinfo").is_file()); + assert!(trash.join("files").join("my note #1.txt").is_file()); + let info_path = trash.join("info").join("my note #1.txt.trashinfo"); + assert!(info_path.is_file()); + + let info_text = fs::read_to_string(&info_path).unwrap(); + assert!(info_text.starts_with("[Trash Info]\nPath="), "{info_text}"); + assert!(info_text.contains("my%20note%20%231.txt\n"), "{info_text}"); + assert!(!info_text.contains("DeletionDate=1970-01-01"), "{info_text}"); + assert!(info_text.contains("DeletionDate=20"), "{info_text}"); + + let formatted = + format_trashinfo(Path::new("/home/alice/My Files/test 100%.txt"), "2026-10-22T23:05:12"); + assert_eq!( + formatted, + "[Trash Info]\nPath=/home/alice/My%20Files/test%20100%25.txt\nDeletionDate=2026-10-22T23:05:12\n" + ); } #[test] @@ -406,7 +882,7 @@ mod tests { [Desktop Entry] Type=Application Name=Firefox Web Browser -Exec=/usr/lib/firefox/firefox --new-window %u +Exec=/usr/lib/firefox/firefox --new-window --url=%u --scale=100%% %F Icon=firefox [Desktop Action new-private-window] @@ -415,7 +891,7 @@ Exec=/usr/lib/firefox/firefox --private-window %u "; let (prog, args) = parse_desktop_exec(desktop).expect("should parse Exec="); assert_eq!(prog, "/usr/lib/firefox/firefox"); - assert_eq!(args, vec!["--new-window"]); + assert_eq!(args, vec!["--new-window", "--url=", "--scale=100%"]); } #[test] diff --git a/desktop/app/src/linux/shortcut.rs b/desktop/app/src/linux/shortcut.rs index 9031deda..e3440819 100644 --- a/desktop/app/src/linux/shortcut.rs +++ b/desktop/app/src/linux/shortcut.rs @@ -32,12 +32,8 @@ fn sanitize_single_line(s: &str) -> String { s.chars().filter(|&c| c != '\n' && c != '\r' && !c.is_control()).collect() } -fn sanitize_shell_path(s: &str) -> String { - s.chars() - .filter(|&c| { - c != '\n' && c != '\r' && c != '"' && c != '$' && c != '`' && c != '\\' && !c.is_control() - }) - .collect() +fn escape_posix_sh_single_quoted(target: &str) -> String { + sanitize_single_line(target).replace('\'', "'\\''") } fn sanitize_exec_args(s: &str) -> String { @@ -70,7 +66,7 @@ fn write_file_nofollow(path: &Path, content: &str, mode: u32) -> Result<()> { .mode(mode) .open(path)?; file.write_all(content.as_bytes())?; - let _ = file.set_permissions(std::fs::Permissions::from_mode(mode)); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?; Ok(()) } #[cfg(not(unix))] @@ -188,6 +184,15 @@ pub fn sync_file_manager_menus(send_to_dir: &Path) -> Result<()> { write_file_manager_integrations(&data_home, &entries) } +/// Remove all NectarLink file-manager context menu integrations (Nautilus scripts, +/// KDE Dolphin KIO ServiceMenus, and Nemo/Thunar File Manager Actions). +pub fn remove_all_file_manager_menus() -> Result<()> { + let Some(data_home) = xdg_data_home() else { + return Ok(()); + }; + write_file_manager_integrations(&data_home, &[]) +} + fn collect_shortcuts(send_to_dir: &Path) -> Result> { let mut out = Vec::new(); let Ok(read_dir) = std::fs::read_dir(send_to_dir) else { @@ -249,9 +254,11 @@ pub fn write_file_manager_integrations( let kio_dir = root_data_dir.join("kio/servicemenus"); let actions_dir = root_data_dir.join("file-manager/actions"); - std::fs::create_dir_all(&nautilus_dir)?; - std::fs::create_dir_all(&kio_dir)?; - std::fs::create_dir_all(&actions_dir)?; + if !shortcuts.is_empty() { + std::fs::create_dir_all(&nautilus_dir)?; + std::fs::create_dir_all(&kio_dir)?; + std::fs::create_dir_all(&actions_dir)?; + } let mut desired_nautilus = std::collections::HashSet::new(); let mut desired_desktop = std::collections::HashSet::new(); @@ -264,7 +271,7 @@ pub fn write_file_manager_integrations( desired_nautilus.insert(label.clone()); desired_desktop.insert(desktop_filename.clone()); - let shell_target = sanitize_shell_path(&sc.target.to_string_lossy()); + let shell_target = escape_posix_sh_single_quoted(&sc.target.to_string_lossy()); let exec_target = super::autostart::escape_desktop_exec_arg(&sc.target.to_string_lossy()); let args = sanitize_exec_args(&sc.arguments); let comment = sanitize_single_line(&sc.description); @@ -273,18 +280,18 @@ pub fn write_file_manager_integrations( let nautilus_path = nautilus_dir.join(&label); let script = format!( "#!/usr/bin/env sh\n\ - # Generated by NectarLink — {comment}\n\ + # Generated by NectarLink: {comment}\n\ if [ -n \"${{NAUTILUS_SCRIPT_SELECTED_FILE_PATHS:-}}\" ]; then\n\ IFS='\n'\n\ set -f\n\ - exec \"{shell_target}\" {args} $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS\n\ + exec '{shell_target}' {args} $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS\n\ else\n\ - exec \"{shell_target}\" {args} \"$@\"\n\ + exec '{shell_target}' {args} \"$@\"\n\ fi\n", ); write_file_nofollow(&nautilus_path, &script, 0o755)?; - // 2. KDE Dolphin (KIO ServiceMenu) + // 2. KDE Dolphin (KIO ServiceMenu): must be 0o755 so Plasma 6 KFileItemActions executes it let kio_path = kio_dir.join(&desktop_filename); let kio_content = format!( "[Desktop Entry]\n\ @@ -298,7 +305,7 @@ pub fn write_file_manager_integrations( Icon=nectarlink\n\ Exec=\"{exec_target}\" {args} %F\n" ); - write_file_nofollow(&kio_path, &kio_content, 0o644)?; + write_file_nofollow(&kio_path, &kio_content, 0o755)?; // 3. Nemo / Thunar (DES-EMA File Manager Action) let action_path = actions_dir.join(&desktop_filename); @@ -331,13 +338,13 @@ pub fn write_file_manager_integrations( } } - // Prune stale KDE ServiceMenus and File Manager Actions prefixed with `nectarlink-send-` + // Prune stale KDE ServiceMenus and File Manager Actions prefixed with `nectarlink-` for dir in [&kio_dir, &actions_dir] { if let Ok(rd) = std::fs::read_dir(dir) { for entry in rd.flatten() { let p = entry.path(); if let Some(name) = p.file_name().and_then(|s| s.to_str()) - && name.starts_with("nectarlink-send-") + && name.starts_with("nectarlink-") && name.ends_with(".desktop") && !desired_desktop.contains(name) { @@ -379,7 +386,7 @@ mod tests { let lnk1 = send_to.join("Pixel 9.lnk"); let sc1 = Shortcut { - target: PathBuf::from("/usr/bin/nectarlink-desktop"), + target: PathBuf::from("/opt/user's bin/nectarlink-desktop"), arguments: "--send-to pixel9".to_string(), description: "Send to Pixel 9".to_string(), }; @@ -408,12 +415,17 @@ mod tests { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&nautilus_p1).unwrap().permissions().mode(); - assert_ne!(mode & 0o111, 0, "Nautilus script must be executable"); + let nautilus_mode = std::fs::metadata(&nautilus_p1).unwrap().permissions().mode(); + assert_ne!(nautilus_mode & 0o111, 0, "Nautilus script must be executable"); + let kio_mode = std::fs::metadata(&kio_p1).unwrap().permissions().mode(); + assert_eq!(kio_mode & 0o777, 0o755, "KDE ServiceMenu .desktop must be 0o755"); } + let nautilus_text = std::fs::read_to_string(&nautilus_p1).unwrap(); + assert!(nautilus_text.contains("exec '/opt/user'\\''s bin/nectarlink-desktop' --send-to pixel9")); + let kio_text = std::fs::read_to_string(&kio_p1).unwrap(); - assert!(kio_text.contains("Exec=\"/usr/bin/nectarlink-desktop\" --send-to pixel9 %F")); + assert!(kio_text.contains("Exec=\"/opt/user's bin/nectarlink-desktop\" --send-to pixel9 %F")); // Now prune Galaxy S25 and verify its files are removed while Pixel 9 remains. write_file_manager_integrations(tmp.path(), &[(lnk1, sc1)]).expect("prune integrations"); @@ -421,5 +433,11 @@ mod tests { assert!(!nautilus_p2.exists()); assert!(kio_p1.exists()); assert!(!kio_p2.exists()); + + // Removing all entries prunes everything. + write_file_manager_integrations(tmp.path(), &[]).expect("remove all integrations"); + assert!(!nautilus_p1.exists()); + assert!(!kio_p1.exists()); + assert!(!act_p1.exists()); } } diff --git a/desktop/app/src/linux/single_instance.rs b/desktop/app/src/linux/single_instance.rs index 948c4a6f..a5a679fc 100644 --- a/desktop/app/src/linux/single_instance.rs +++ b/desktop/app/src/linux/single_instance.rs @@ -11,10 +11,12 @@ use std::{ }, path::{Path, PathBuf}, sync::Mutex, + time::Duration, }; const LOCK_FILE: &str = "instance.lock"; const SOCKET_FILE: &str = "instance.sock"; +const MAX_SUN_PATH_LEN: usize = 100; const LOCK_EX: i32 = 2; const LOCK_NB: i32 = 4; @@ -45,14 +47,40 @@ fn lock_path(data_dir: &Path) -> PathBuf { data_dir.join(LOCK_FILE) } +fn fnv1a_64(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, &b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)) +} + fn socket_path(data_dir: &Path) -> PathBuf { - data_dir.join(SOCKET_FILE) + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::{DirBuilderExt, MetadataExt}; + + let direct = data_dir.join(SOCKET_FILE); + if direct.as_os_str().as_bytes().len() <= MAX_SUN_PATH_LEN { + return direct; + } + let canonical = fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf()); + let hash = fnv1a_64(canonical.as_os_str().as_bytes()); + let uid = unsafe { libc::geteuid() }; + let base = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|p| p.as_os_str().as_bytes().len() <= 64) + .unwrap_or_else(std::env::temp_dir); + let dir = base.join(format!("nl-{uid}-{hash:016x}")); + let _ = fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir); + if let Ok(meta) = fs::symlink_metadata(&dir) + && !meta.file_type().is_symlink() + && meta.uid() == uid + { + return dir.join(SOCKET_FILE); + } + direct } /// Acquires the single-instance lock for `data_dir`, or reports that another /// instance already holds it. pub fn acquire(data_dir: &Path) -> io::Result { - use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; + use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; let _ = fs::DirBuilder::new().recursive(true).mode(0o700).create(data_dir); let lock_file = OpenOptions::new() .read(true) @@ -81,6 +109,9 @@ pub fn acquire(data_dir: &Path) -> io::Result { let _ = fs::remove_file(&sock_path); } let listener = UnixListener::bind(&sock_path).ok(); + if listener.is_some() { + let _ = fs::set_permissions(&sock_path, fs::Permissions::from_mode(0o600)); + } Ok(Instance::Primary(PrimaryInstance { _lock_file: lock_file, @@ -89,11 +120,22 @@ pub fn acquire(data_dir: &Path) -> io::Result { })) } -/// Signals the running primary instance for `data_dir` to wake up and drain queued requests. +/// Signals the running primary instance for `data_dir` to wake up and drain queued requests, +/// forwarding any Wayland/X11 activation token (`XDG_ACTIVATION_TOKEN` / `DESKTOP_STARTUP_ID`). pub fn wake(data_dir: &Path) { let sock_path = socket_path(data_dir); if let Ok(mut stream) = UnixStream::connect(sock_path) { - let _ = stream.write_all(b"WAKE\n"); + let _ = stream.set_write_timeout(Some(Duration::from_millis(250))); + let token = std::env::var("XDG_ACTIVATION_TOKEN") + .ok() + .filter(|s| !s.trim().is_empty()) + .or_else(|| std::env::var("DESKTOP_STARTUP_ID").ok().filter(|s| !s.trim().is_empty())); + if let Some(tok) = token { + let clean: String = tok.chars().filter(|&c| c != '\n' && c != '\r').take(256).collect(); + let _ = writeln!(stream, "WAKE {clean}"); + } else { + let _ = stream.write_all(b"WAKE\n"); + } let _ = stream.flush(); } } @@ -108,13 +150,34 @@ impl PrimaryInstance { for stream in listener.incoming() { match stream { Ok(mut conn) => { - let mut buf = [0u8; 16]; - let _ = conn.read(&mut buf); + let _ = conn.set_read_timeout(Some(Duration::from_millis(250))); + let mut buf = [0u8; 320]; + if let Ok(n) = conn.read(&mut buf) + && n > 0 + && let Ok(line) = std::str::from_utf8(&buf[..n]) + { + let trimmed = line.trim(); + if let Some(token) = trimmed.strip_prefix("WAKE ") { + let token = token.trim(); + if !token.is_empty() { + crate::bridge::native::ffi::linux_set_activation_token(token); + } + } + } on_activate(); } Err(e) => { + if matches!( + e.kind(), + io::ErrorKind::Interrupted + | io::ErrorKind::WouldBlock + | io::ErrorKind::TimedOut + | io::ErrorKind::ConnectionAborted + ) { + continue; + } tracing::debug!(error = %e, "single-instance socket accept failed"); - break; + std::thread::sleep(Duration::from_millis(50)); } } } diff --git a/desktop/app/src/linux/toast.rs b/desktop/app/src/linux/toast.rs index b1b7b02e..e870f93d 100644 --- a/desktop/app/src/linux/toast.rs +++ b/desktop/app/src/linux/toast.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::fmt; use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock, mpsc}; use std::time::Duration; pub const AUMID: &str = "Nectarlink.Desktop"; @@ -42,8 +42,10 @@ impl fmt::Debug for Toast { f.debug_struct("Toast") .field("device", &self.device) .field("key", &self.key) - .field("title", &self.title) - .field("body", &self.body) + .field("title", &"") + .field("title_len", &self.title.len()) + .field("body", &"") + .field("body_len", &self.body.len()) .field("attribution", &self.attribution) .field("icon", &self.icon) .field("image", &self.image) @@ -70,21 +72,42 @@ pub enum MonitorSignal { NotificationClosed { id: u32, reason: u32 }, } -#[derive(Clone, Debug)] +#[derive(Clone)] struct ToastSnapshot { title: String, body: String, attribution: String, icon_path: String, + image_path: Option, actions: Vec<(String, String)>, reply: Option<(String, String)>, silent: bool, call: bool, } +impl fmt::Debug for ToastSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ToastSnapshot") + .field("title", &"") + .field("title_len", &self.title.len()) + .field("body", &"") + .field("body_len", &self.body.len()) + .field("attribution", &self.attribution) + .field("icon_path", &self.icon_path) + .field("image_path", &self.image_path) + .field("actions", &self.actions.len()) + .field("reply", &self.reply.is_some()) + .field("silent", &self.silent) + .field("call", &self.call) + .finish() + } +} + +type EventCallback = Arc; + struct Toaster { icon: PathBuf, - on_event: Option>, + on_event: Option, active_ids: HashMap<(String, String), u32>, id_to_target: HashMap, reply_actions: HashMap, @@ -93,7 +116,7 @@ struct Toaster { } impl Toaster { - fn new(icon: PathBuf, on_event: Option>) -> Self { + fn new(icon: PathBuf, on_event: Option) -> Self { Self { icon, on_event, @@ -187,10 +210,172 @@ impl Toaster { } } +enum WorkerCommand { + Show(Box), + UpdateProgress { device: String, key: String, progress: Progress }, + Remove { device: String, key: String }, + RemoveDevice { device: String }, +} + static TOASTER: OnceLock> = OnceLock::new(); +static WORKER_TX: OnceLock> = OnceLock::new(); static MONITOR_STARTED: OnceLock<()> = OnceLock::new(); +static DBUS_SESSION: OnceLock>> = OnceLock::new(); + +/// Returns the shared D-Bus session connection so `Notify`, `CloseNotification`, +/// and `monitor_loop()` all share the same unique bus name (`:1.x`). +fn dbus_session() -> Option { + let mutex = DBUS_SESSION.get_or_init(|| Mutex::new(None)); + let mut guard = mutex.lock().ok()?; + if guard.is_none() { + *guard = zbus::blocking::Connection::session().ok(); + } + guard.clone() +} + +fn reset_dbus_session() -> Option { + let mutex = DBUS_SESSION.get_or_init(|| Mutex::new(None)); + let mut guard = mutex.lock().ok()?; + *guard = zbus::blocking::Connection::session().ok(); + // A new connection may reach a different notification server. + *CAPABILITIES.lock().unwrap_or_else(|e| e.into_inner()) = None; + guard.clone() +} + +/// What the notification server says it can show (`GetCapabilities`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Capabilities { + /// Bold, italics and entities in the body; without it, markup shows as text. + pub body_markup: bool, + /// Buttons, including the default action when the notification is clicked. + pub actions: bool, + /// A reply field in the notification (KDE Plasma). + pub inline_reply: bool, +} + +impl Capabilities { + pub(crate) fn from_list(list: &[String]) -> Self { + let has = |name: &str| list.iter().any(|c| c == name); + Capabilities { + body_markup: has("body-markup"), + actions: has("actions"), + inline_reply: has("inline-reply"), + } + } +} +static CAPABILITIES: Mutex> = Mutex::new(None); + +/// The server's capabilities, asked once per connection. When there is no +/// server to ask, plain text and no buttons. +fn capabilities() -> Capabilities { + if let Some(caps) = *CAPABILITIES.lock().unwrap_or_else(|e| e.into_inner()) { + return caps; + } + let Some(list) = with_session_connection(|conn| { + let msg = conn.call_method( + Some("org.freedesktop.Notifications"), + "/org/freedesktop/Notifications", + Some("org.freedesktop.Notifications"), + "GetCapabilities", + &(), + )?; + msg.body().deserialize::>() + }) else { + return Capabilities::default(); + }; + let caps = Capabilities::from_list(&list); + *CAPABILITIES.lock().unwrap_or_else(|e| e.into_inner()) = Some(caps); + caps +} + +fn with_session_connection(mut op: F) -> Option +where + T: Send, + F: FnMut(&zbus::blocking::Connection) -> zbus::Result + Send, +{ + super::non_tokio(|| { + let conn = dbus_session()?; + if !super::dbus_name_has_owner(&conn, "org.freedesktop.Notifications") { + return None; + } + match op(&conn) { + Ok(val) => Some(val), + Err(zbus::Error::InputOutput(_) | zbus::Error::Handshake(_)) => { + let retry_conn = reset_dbus_session()?; + if !super::dbus_name_has_owner(&retry_conn, "org.freedesktop.Notifications") { + return None; + } + op(&retry_conn).ok() + } + Err(_) => None, + } + }) +} + +/// Checks whether desktop notifications are currently enabled (not inhibited via +/// `org.freedesktop.Notifications` `Inhibited` property and not disabled in GNOME's +/// `org.gnome.desktop.notifications` `show-banners`). pub fn enabled() -> bool { + if cfg!(test) { + return true; + } + // Asked each time the window comes to the front, on the GUI thread: keep + // the answer for a while instead of asking D-Bus and gsettings every time. + static CACHED: Mutex> = Mutex::new(None); + const KEEP: Duration = Duration::from_secs(30); + if let Some((at, value)) = *CACHED.lock().unwrap_or_else(|e| e.into_inner()) + && at.elapsed() < KEEP + { + return value; + } + let value = query_enabled(); + *CACHED.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), value)); + value +} + +fn query_enabled() -> bool { + if let Some(inhibited) = with_session_connection(|conn| { + let msg = conn.call_method( + Some("org.freedesktop.Notifications"), + "/org/freedesktop/Notifications", + Some("org.freedesktop.DBus.Properties"), + "Get", + &("org.freedesktop.Notifications", "Inhibited"), + )?; + let body = msg.body(); + if let Ok(val) = body.deserialize::() + && let Ok(b) = bool::try_from(val) + { + return Ok(b); + } + if let Ok((val,)) = body.deserialize::<(zbus::zvariant::OwnedValue,)>() + && let Ok(b) = bool::try_from(val) + { + return Ok(b); + } + Ok(false) + }) && inhibited + { + return false; + } + + let is_gnome = std::env::var("XDG_CURRENT_DESKTOP") + .or_else(|_| std::env::var("DESKTOP_SESSION")) + .map(|d| d.to_ascii_lowercase().contains("gnome")) + .unwrap_or(false); + if is_gnome + && let Ok(output) = std::process::Command::new("gsettings") + .args(["get", "org.gnome.desktop.notifications", "show-banners"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + && output.status.success() + && String::from_utf8_lossy(&output.stdout).trim() == "false" + { + return false; + } + true } @@ -219,7 +404,7 @@ where }); } - let callback: Box = Box::new(on_event); + let callback: EventCallback = Arc::new(on_event); if let Some(mutex) = TOASTER.get() { if let Ok(mut guard) = mutex.lock() { guard.icon = icon; @@ -230,56 +415,100 @@ where } if !cfg!(test) { + WORKER_TX.get_or_init(|| { + let (tx, rx) = mpsc::channel(); + let _ = std::thread::Builder::new().name("toasts-worker".into()).spawn(move || worker_loop(rx)); + tx + }); MONITOR_STARTED.get_or_init(|| { - let _ = std::thread::Builder::new().name("toasts".into()).spawn(monitor_loop); + let _ = std::thread::Builder::new().name("toasts-monitor".into()).spawn(monitor_loop); }); } crate::pc_notifications::start_listener(); } +fn dispatch(cmd: WorkerCommand) { + if !cfg!(test) + && let Some(tx) = WORKER_TX.get() + { + match tx.send(cmd) { + Ok(()) => return, + Err(e) => { + execute_command(e.0); + return; + } + } + } + execute_command(cmd); +} + +fn worker_loop(rx: mpsc::Receiver) { + while let Ok(cmd) = rx.recv() { + execute_command(cmd); + } +} + +fn execute_command(cmd: WorkerCommand) { + match cmd { + WorkerCommand::Show(toast) => execute_show(*toast), + WorkerCommand::UpdateProgress { device, key, progress } => { + execute_update_progress(&device, &key, progress); + } + WorkerCommand::Remove { device, key } => execute_remove(&device, &key), + WorkerCommand::RemoveDevice { device } => execute_remove_device(&device), + } +} + pub fn show(toast: Toast) { if toast.device.trim().is_empty() || toast.key.trim().is_empty() { return; } + dispatch(WorkerCommand::Show(Box::new(toast))); +} - let (replaces_id, default_icon) = { - let Some(mutex) = TOASTER.get() else { - let icon_str = toast - .icon - .as_ref() - .or(toast.image.as_ref()) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - let title = format_summary(&toast.title, &toast.attribution); - let body = format_body(&toast.body, &toast.attribution, toast.progress.as_ref()); - let _ = send_notification( - 0, - &icon_str, - &title, - &body, - &toast.actions, - toast.reply.as_ref(), - toast.progress.as_ref(), - toast.silent, - toast.call, - ); - return; - }; - let Ok(guard) = mutex.lock() else { - return; - }; - let existing = guard.active_ids.get(&(toast.device.clone(), toast.key.clone())).copied().unwrap_or(0); - (existing, guard.icon.clone()) +fn execute_show(toast: Toast) { + let image_str = toast.image.as_ref().map(|p| p.to_string_lossy().to_string()); + let title = format_summary(&toast.title, &toast.attribution); + let markup = capabilities().body_markup; + let body = format_body(&toast.title, &toast.body, &toast.attribution, toast.progress.as_ref(), markup); + + let Some(mutex) = TOASTER.get() else { + let icon_str = toast + .icon + .as_ref() + .or(toast.image.as_ref()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let _ = send_notification( + 0, + &icon_str, + image_str.as_deref(), + &title, + &body, + &toast.actions, + toast.reply.as_ref(), + toast.progress.as_ref(), + toast.silent, + toast.call, + ); + return; }; - let chosen_icon = toast.icon.clone().or(toast.image.clone()).unwrap_or(default_icon); + // Hold the TOASTER lock across `send_notification` and `record_shown` on the worker + // thread so a fast `ActionInvoked` or `NotificationClosed` signal in `monitor_loop()` + // can never race ahead of `record_shown`. + let Ok(mut guard) = mutex.lock() else { + return; + }; + let replaces_id = guard.active_ids.get(&(toast.device.clone(), toast.key.clone())).copied().unwrap_or(0); + let chosen_icon = + toast.icon.clone().or_else(|| toast.image.clone()).unwrap_or_else(|| guard.icon.clone()); let icon_str = chosen_icon.to_string_lossy().to_string(); - let title = format_summary(&toast.title, &toast.attribution); - let body = format_body(&toast.body, &toast.attribution, toast.progress.as_ref()); let notified_id = send_notification( replaces_id, &icon_str, + image_str.as_deref(), &title, &body, &toast.actions, @@ -289,55 +518,55 @@ pub fn show(toast: Toast) { toast.call, ); - if let Some(mutex) = TOASTER.get() - && let Ok(mut guard) = mutex.lock() - { - let id = notified_id.unwrap_or_else(|| { - if replaces_id != 0 { - replaces_id - } else { - guard.next_fallback_id += 1; - guard.next_fallback_id - } - }); - let snapshot = ToastSnapshot { - title: toast.title, - body: toast.body, - attribution: toast.attribution, - icon_path: icon_str, - actions: toast.actions, - reply: toast.reply, - silent: toast.silent, - call: toast.call, - }; - guard.record_shown(toast.device, toast.key, id, snapshot); - } + let id = notified_id.unwrap_or_else(|| { + if replaces_id != 0 { + replaces_id + } else { + guard.next_fallback_id += 1; + guard.next_fallback_id + } + }); + let snapshot = ToastSnapshot { + title: toast.title, + body: toast.body, + attribution: toast.attribution, + icon_path: icon_str, + image_path: image_str, + actions: toast.actions, + reply: toast.reply, + silent: toast.silent, + call: toast.call, + }; + guard.record_shown(toast.device, toast.key, id, snapshot); } pub fn update_progress(device: &str, key: &str, progress: Progress) { + dispatch(WorkerCommand::UpdateProgress { device: device.to_string(), key: key.to_string(), progress }); +} + +fn execute_update_progress(device: &str, key: &str, progress: Progress) { let Some(mutex) = TOASTER.get() else { return; }; - let (replaces_id, snapshot) = { - let Ok(guard) = mutex.lock() else { - return; - }; - let target = (device.to_string(), key.to_string()); - let Some(&id) = guard.active_ids.get(&target) else { - return; - }; - let Some(snap) = guard.snapshots.get(&target).cloned() else { - return; - }; - (id, snap) + let Ok(mut guard) = mutex.lock() else { + return; + }; + let target = (device.to_string(), key.to_string()); + let Some(&replaces_id) = guard.active_ids.get(&target) else { + return; + }; + let Some(snapshot) = guard.snapshots.get(&target).cloned() else { + return; }; let summary = format_summary(&snapshot.title, &snapshot.attribution); - let body = format_body(&snapshot.body, &snapshot.attribution, Some(&progress)); + let markup = capabilities().body_markup; + let body = format_body(&snapshot.title, &snapshot.body, &snapshot.attribution, Some(&progress), markup); let new_id = send_notification( replaces_id, &snapshot.icon_path, + snapshot.image_path.as_deref(), &summary, &body, &snapshot.actions, @@ -349,13 +578,16 @@ pub fn update_progress(device: &str, key: &str, progress: Progress) { if let Some(id) = new_id && id != replaces_id - && let Ok(mut guard) = mutex.lock() { guard.record_shown(device.to_string(), key.to_string(), id, snapshot); } } pub fn remove(device: &str, key: &str) { + dispatch(WorkerCommand::Remove { device: device.to_string(), key: key.to_string() }); +} + +fn execute_remove(device: &str, key: &str) { let Some(mutex) = TOASTER.get() else { return; }; @@ -366,11 +598,15 @@ pub fn remove(device: &str, key: &str) { guard.take_by_target(device, key) }; if let Some(id) = id { - close_notification(id); + close_dbus_notification(id); } } pub fn remove_device(device: &str) { + dispatch(WorkerCommand::RemoveDevice { device: device.to_string() }); +} + +fn execute_remove_device(device: &str) { let Some(mutex) = TOASTER.get() else { return; }; @@ -381,11 +617,15 @@ pub fn remove_device(device: &str) { guard.take_by_device(device) }; for id in ids { - close_notification(id); + close_dbus_notification(id); } } pub fn unregister() { + execute_unregister(); +} + +fn execute_unregister() { let Some(mutex) = TOASTER.get() else { return; }; @@ -396,10 +636,27 @@ pub fn unregister() { guard.clear_all() }; for id in ids { - close_notification(id); + close_dbus_notification(id); + } +} + +/// Escapes XML/Pango entities (`&`, `<`, `>`) so notification body text cannot +/// break Pango markup parsing in Freedesktop notification daemons. +pub fn escape_notification_markup(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + c => out.push(c), + } } + out } +/// The summary line: the title, or where the notification came from when +/// there's no title (then [`format_body`] doesn't repeat it). fn format_summary(title: &str, attribution: &str) -> String { if !title.trim().is_empty() { title.trim().to_string() @@ -410,21 +667,30 @@ fn format_summary(title: &str, attribution: &str) -> String { } } -fn format_body(body: &str, attribution: &str, progress: Option<&Progress>) -> String { +/// The body: the text, a progress line, and where it came from, in italics +/// when the server shows markup and as plain text when it doesn't. +fn format_body( + title: &str, + body: &str, + attribution: &str, + progress: Option<&Progress>, + markup: bool, +) -> String { + let text = |s: &str| if markup { escape_notification_markup(s.trim()) } else { s.trim().to_owned() }; let mut parts = Vec::new(); if !body.trim().is_empty() { - parts.push(body.trim().to_string()); + parts.push(text(body)); } if let Some(prog) = progress { let mut prog_line = String::new(); if !prog.status.trim().is_empty() { - prog_line.push_str(prog.status.trim()); + prog_line.push_str(&text(&prog.status)); } if !prog.label.trim().is_empty() { if !prog_line.is_empty() { - prog_line.push_str(" — "); + prog_line.push_str(" · "); } - prog_line.push_str(prog.label.trim()); + prog_line.push_str(&text(&prog.label)); } else if let Some(val) = prog.value { let pct = (val.clamp(0.0, 1.0) * 100.0).round() as i32; if !prog_line.is_empty() { @@ -437,8 +703,9 @@ fn format_body(body: &str, attribution: &str, progress: Option<&Progress>) -> St parts.push(prog_line); } } - if !attribution.trim().is_empty() { - parts.push(attribution.trim().to_string()); + // Without a title the attribution is already the summary. + if !attribution.trim().is_empty() && !title.trim().is_empty() { + parts.push(if markup { format!("{}", text(attribution)) } else { text(attribution) }); } parts.join("\n") } @@ -447,20 +714,37 @@ fn compute_progress_percent(progress: &Progress) -> i32 { progress.value.map(|v| (v.clamp(0.0, 1.0) * 100.0).round() as i32).unwrap_or(0) } -fn build_actions_list(actions: &[(String, String)], reply: Option<&(String, String)>) -> Vec { +/// The `actions` argument of `Notify`: pairs of action key and label. Only +/// what the server can show: no buttons without `actions`, and a reply field +/// only with `inline-reply` (elsewhere clicking the notification opens the +/// conversation in the app, where the reply goes). +fn build_actions_list( + actions: &[(String, String)], + reply: Option<&(String, String)>, + caps: Capabilities, +) -> Vec { + if !caps.actions { + return Vec::new(); + } let mut items = Vec::with_capacity(2 + actions.len() * 2 + 2); items.push("default".to_string()); - items.push("Open".to_string()); + items.push(crate::i18n::tr!("Notifications", "Open")); for (id, label) in actions { if !id.trim().is_empty() { items.push(id.clone()); items.push(label.clone()); } } - if let Some((_action_id, placeholder)) = reply { - let label = if placeholder.trim().is_empty() { "Reply" } else { placeholder.trim() }; + if caps.inline_reply + && let Some((_action_id, placeholder)) = reply + { + let label = if placeholder.trim().is_empty() { + crate::i18n::tr!("Notifications", "Reply") + } else { + placeholder.trim().to_owned() + }; items.push("inline-reply".to_string()); - items.push(label.to_string()); + items.push(label); } items } @@ -469,18 +753,23 @@ fn build_hints_map( progress: Option<&Progress>, silent: bool, call: bool, + image_path: Option<&str>, ) -> HashMap<&'static str, zbus::zvariant::Value<'static>> { let mut hints = HashMap::new(); - let urgency: u8 = if call { - 2 - } else if silent { - 0 + hints.insert("desktop-entry", zbus::zvariant::Value::from("nectarlink")); + if call { + hints.insert("category", zbus::zvariant::Value::from("call.incoming")); + hints.insert("urgency", zbus::zvariant::Value::U8(2)); + hints.insert("resident", zbus::zvariant::Value::Bool(true)); } else { - 1 - }; - hints.insert("urgency", zbus::zvariant::Value::U8(urgency)); - if silent && !call { - hints.insert("suppress-sound", zbus::zvariant::Value::Bool(true)); + let urgency: u8 = if silent { 0 } else { 1 }; + hints.insert("urgency", zbus::zvariant::Value::U8(urgency)); + if silent { + hints.insert("suppress-sound", zbus::zvariant::Value::Bool(true)); + } + } + if let Some(img) = image_path.filter(|s| !s.is_empty()) { + hints.insert("image-path", zbus::zvariant::Value::from(img.to_owned())); } if let Some(prog) = progress { let pct = compute_progress_percent(prog); @@ -489,35 +778,11 @@ fn build_hints_map( hints } -static DBUS_SESSION: OnceLock>> = OnceLock::new(); - -fn with_session_connection(mut op: F) -> Option -where - F: FnMut(&zbus::blocking::Connection) -> zbus::Result, -{ - let mutex = DBUS_SESSION.get_or_init(|| Mutex::new(None)); - let mut guard = mutex.lock().ok()?; - if guard.is_none() { - *guard = zbus::blocking::Connection::session().ok(); - } - if let Some(conn) = guard.as_ref() { - match op(conn) { - Ok(val) => return Some(val), - Err(_) => { - *guard = zbus::blocking::Connection::session().ok(); - if let Some(retry_conn) = guard.as_ref() { - return op(retry_conn).ok(); - } - } - } - } - None -} - #[allow(clippy::too_many_arguments)] fn send_notification( replaces_id: u32, icon_path: &str, + image_path: Option<&str>, summary: &str, body: &str, actions: &[(String, String)], @@ -530,8 +795,8 @@ fn send_notification( return None; } - let actions_list = build_actions_list(actions, reply); - let hints_map = build_hints_map(progress, silent, call); + let actions_list = build_actions_list(actions, reply, capabilities()); + let hints_map = build_hints_map(progress, silent, call, image_path); let expire_timeout: i32 = if call { 0 } else { -1 }; with_session_connection(|conn| { @@ -547,7 +812,7 @@ fn send_notification( }) } -fn close_notification(id: u32) { +fn close_dbus_notification(id: u32) { if cfg!(test) || id == 0 { return; } @@ -565,7 +830,10 @@ fn close_notification(id: u32) { fn monitor_loop() { loop { - let Ok(conn) = zbus::blocking::Connection::session() else { + // Use the SAME shared D-Bus session connection as `send_notification()` so + // unicast `ActionInvoked`, `NotificationReplied`, and `NotificationClosed` + // signals sent to our caller unique name (`:1.x`) are received here. + let Some(conn) = dbus_session() else { std::thread::sleep(Duration::from_secs(5)); continue; }; @@ -578,24 +846,27 @@ fn monitor_loop() { return; }; let Ok(iter) = zbus::blocking::MessageIterator::for_match_rule(rule, &conn, None) else { + let _ = reset_dbus_session(); std::thread::sleep(Duration::from_secs(5)); continue; }; for msg in iter { let Ok(msg) = msg else { + let _ = reset_dbus_session(); break; }; if let Some(signal) = parse_dbus_signal(&msg) { - let event = TOASTER.get().and_then(|mutex| { - let mut guard = mutex.lock().ok()?; - guard.handle_signal(signal) - }); - if let Some(event) = event - && let Some(mutex) = TOASTER.get() - && let Ok(guard) = mutex.lock() - && let Some(cb) = &guard.on_event - { + let (event, callback) = TOASTER + .get() + .and_then(|mutex| { + let mut guard = mutex.lock().ok()?; + let ev = guard.handle_signal(signal); + let cb = guard.on_event.clone(); + Some((ev, cb)) + }) + .unwrap_or((None, None)); + if let (Some(event), Some(cb)) = (event, callback) { cb(event); } } @@ -639,17 +910,94 @@ mod tests { fn builds_actions_and_hints_without_gvariant_text_mangling() { let actions = vec![("mark_read".to_string(), "Mark 'read' [1]".to_string())]; let reply = ("direct_reply".to_string(), "Reply \"now\"".to_string()); - let list = build_actions_list(&actions, Some(&reply)); + let kde = Capabilities { body_markup: true, actions: true, inline_reply: true }; + let list = build_actions_list(&actions, Some(&reply), kde); assert_eq!( list, vec!["default", "Open", "mark_read", "Mark 'read' [1]", "inline-reply", "Reply \"now\""] ); + let gnome = Capabilities { body_markup: true, actions: true, inline_reply: false }; + assert_eq!( + build_actions_list(&actions, Some(&reply), gnome), + vec!["default", "Open", "mark_read", "Mark 'read' [1]"], + "no reply field where the server can't show one" + ); + assert!(build_actions_list(&actions, Some(&reply), Capabilities::default()).is_empty()); + assert_eq!( + Capabilities::from_list(&["actions".into(), "body".into(), "body-markup".into()]), + Capabilities { body_markup: true, actions: true, inline_reply: false } + ); + let prog = Progress { status: "Transferring".to_string(), value: Some(0.42), label: String::new() }; - let hints = build_hints_map(Some(&prog), true, false); + let hints = build_hints_map(Some(&prog), true, false, Some("/tmp/photo.jpg")); + assert_eq!(hints.get("desktop-entry"), Some(&zbus::zvariant::Value::from("nectarlink"))); assert_eq!(hints.get("urgency"), Some(&zbus::zvariant::Value::U8(0))); assert_eq!(hints.get("suppress-sound"), Some(&zbus::zvariant::Value::Bool(true))); assert_eq!(hints.get("value"), Some(&zbus::zvariant::Value::I32(42))); + assert_eq!(hints.get("image-path"), Some(&zbus::zvariant::Value::from("/tmp/photo.jpg"))); + + let call_hints = build_hints_map(None, false, true, None); + assert_eq!(call_hints.get("desktop-entry"), Some(&zbus::zvariant::Value::from("nectarlink"))); + assert_eq!(call_hints.get("category"), Some(&zbus::zvariant::Value::from("call.incoming"))); + assert_eq!(call_hints.get("urgency"), Some(&zbus::zvariant::Value::U8(2))); + assert_eq!(call_hints.get("resident"), Some(&zbus::zvariant::Value::Bool(true))); + } + + #[test] + fn escapes_notification_markup_and_formats_attribution_in_italics() { + assert_eq!(escape_notification_markup("5 < 10 & 20 > 3"), "5 < 10 & 20 > 3"); + let formatted = format_body("Sam", "Meet at ", "Messages & Co · Pixel 9", None, true); + assert_eq!(formatted, "Meet at <Cafe & Bar>\nMessages & Co · Pixel 9"); + let plain = format_body("Sam", "Meet at ", "Messages & Co · Pixel 9", None, false); + assert_eq!( + plain, "Meet at \nMessages & Co · Pixel 9", + "no markup for servers without it" + ); + let untitled = format_body("", "Battery low", "Pixel 9", None, true); + assert_eq!(untitled, "Battery low", "the attribution is the summary then, not repeated"); + let prog = Progress { status: "Sending".into(), value: None, label: "photo.jpg".into() }; + assert_eq!(format_body("Files", "", "", Some(&prog), false), "Sending · photo.jpg"); + } + + #[test] + fn toast_and_snapshot_debug_redact_title_and_body() { + let toast = Toast { + device: "phone-1".to_string(), + key: "notif-secret".to_string(), + title: "Secret Sender Name".to_string(), + body: "Super confidential message body 12345".to_string(), + attribution: "Signal · Pixel".to_string(), + icon: None, + image: None, + actions: vec![("reply".to_string(), "Reply".to_string())], + reply: None, + silent: false, + progress: None, + call: false, + }; + let debug_str = format!("{toast:?}"); + assert!(!debug_str.contains("Secret Sender Name"), "raw title leaked: {debug_str}"); + assert!(!debug_str.contains("Super confidential message body"), "raw body leaked: {debug_str}"); + assert!(debug_str.contains(""), "{debug_str}"); + assert!(debug_str.contains("title_len"), "{debug_str}"); + assert!(debug_str.contains("body_len"), "{debug_str}"); + + let snapshot = ToastSnapshot { + title: "Private Title".to_string(), + body: "Private Body Content".to_string(), + attribution: "Signal · Pixel".to_string(), + icon_path: "/tmp/icon.png".to_string(), + image_path: None, + actions: Vec::new(), + reply: None, + silent: false, + call: false, + }; + let snap_debug = format!("{snapshot:?}"); + assert!(!snap_debug.contains("Private Title"), "raw snapshot title leaked: {snap_debug}"); + assert!(!snap_debug.contains("Private Body Content"), "raw snapshot body leaked: {snap_debug}"); + assert!(snap_debug.contains(""), "{snap_debug}"); } #[test] @@ -705,6 +1053,7 @@ mod tests { body: "Hi there".to_string(), attribution: "Messages · Pixel 9".to_string(), icon_path: "/tmp/icon.png".to_string(), + image_path: None, actions: vec![("archive".to_string(), "Archive".to_string())], reply: Some(("direct_reply".to_string(), "Type a message".to_string())), silent: false, diff --git a/desktop/app/src/linux/tray.rs b/desktop/app/src/linux/tray.rs index d84203fd..132b7662 100644 --- a/desktop/app/src/linux/tray.rs +++ b/desktop/app/src/linux/tray.rs @@ -130,9 +130,388 @@ fn run_command_stdout(program: &str, args: &[&str]) -> Option { (!text.is_empty()).then_some(text) } -/// Queries the currently active external window via `kdotool` (KDE Wayland) -/// or `xdotool` (X11/XWayland), ignoring NectarLink's own windows. +/// Returns `true` when a system tray host (Qt `QSystemTrayIcon` or D-Bus +/// `org.kde.StatusNotifierWatcher` / `org.freedesktop.Notifications`) +/// is available in the current desktop session. +pub fn is_tray_available() -> bool { + if !has_desktop_session() { + return false; + } + if !cfg!(test) && crate::bridge::native::ffi::linux_tray_is_available() { + return true; + } + if let Ok(conn) = zbus::blocking::Connection::session() + && let Ok(dbus) = zbus::blocking::fdo::DBusProxy::new(&conn) + { + for name in [ + "org.kde.StatusNotifierWatcher", + "org.freedesktop.StatusNotifierWatcher", + "org.freedesktop.Notifications", + ] { + if let Ok(bus_name) = zbus::names::BusName::try_from(name) + && dbus.name_has_owner(bus_name).unwrap_or(false) + { + return true; + } + } + } + false +} + +fn is_numeric_window_id(hwnd: isize) -> bool { + if hwnd <= 0 { + return false; + } + if let Ok(map) = window_id_map().lock() + && let Some(raw) = map.get(&hwnd) + { + let trimmed = raw.trim(); + return trimmed.starts_with("0x") + || trimmed.starts_with("0X") + || trimmed.bytes().all(|b| b.is_ascii_digit()); + } + true +} + +type XErrorHandler = Option libc::c_int>; + +static X11_ERROR_LOCK: Mutex<()> = Mutex::new(()); +static X11_HAD_ERROR: AtomicBool = AtomicBool::new(false); + +unsafe extern "C" fn x11_ignore_bad_access(_dpy: *mut libc::c_void, _err: *mut libc::c_void) -> libc::c_int { + X11_HAD_ERROR.store(true, Ordering::Release); + 0 +} + +#[repr(C)] +struct XWindowAttributes { + x: libc::c_int, + y: libc::c_int, + width: libc::c_int, + height: libc::c_int, + border_width: libc::c_int, + depth: libc::c_int, + visual: *mut libc::c_void, + root: libc::c_ulong, + class: libc::c_int, + bit_gravity: libc::c_int, + win_gravity: libc::c_int, + backing_store: libc::c_int, + backing_planes: libc::c_ulong, + backing_pixel: libc::c_ulong, + save_under: libc::c_int, + colormap: libc::c_ulong, + map_installed: libc::c_int, + map_state: libc::c_int, + all_event_masks: libc::c_long, + your_event_mask: libc::c_long, + do_not_propagate_mask: libc::c_long, + override_redirect: libc::c_int, + screen: *mut libc::c_void, + _pad: [u8; 64], +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct XClientMessageEvent { + event_type: libc::c_int, + serial: libc::c_ulong, + send_event: libc::c_int, + display: *mut libc::c_void, + window: libc::c_ulong, + message_type: libc::c_ulong, + format: libc::c_int, + data_l: [libc::c_long; 5], +} + +struct X11Lib { + x_open_display: unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void, + x_close_display: unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int, + x_default_root_window: unsafe extern "C" fn(*mut libc::c_void) -> libc::c_ulong, + x_intern_atom: unsafe extern "C" fn(*mut libc::c_void, *const libc::c_char, libc::c_int) -> libc::c_ulong, + x_get_window_property: unsafe extern "C" fn( + *mut libc::c_void, + libc::c_ulong, + libc::c_ulong, + libc::c_long, + libc::c_long, + libc::c_int, + libc::c_ulong, + *mut libc::c_ulong, + *mut libc::c_int, + *mut libc::c_ulong, + *mut libc::c_ulong, + *mut *mut libc::c_uchar, + ) -> libc::c_int, + x_free: unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int, + x_fetch_name: + unsafe extern "C" fn(*mut libc::c_void, libc::c_ulong, *mut *mut libc::c_char) -> libc::c_int, + x_get_window_attributes: + unsafe extern "C" fn(*mut libc::c_void, libc::c_ulong, *mut XWindowAttributes) -> libc::c_int, + x_send_event: unsafe extern "C" fn( + *mut libc::c_void, + libc::c_ulong, + libc::c_int, + libc::c_long, + *mut XEvent, + ) -> libc::c_int, + x_set_input_focus: + unsafe extern "C" fn(*mut libc::c_void, libc::c_ulong, libc::c_int, libc::c_ulong) -> libc::c_int, + x_raise_window: unsafe extern "C" fn(*mut libc::c_void, libc::c_ulong) -> libc::c_int, + x_sync: unsafe extern "C" fn(*mut libc::c_void, libc::c_int) -> libc::c_int, + x_set_error_handler: unsafe extern "C" fn(XErrorHandler) -> XErrorHandler, +} + +static X11_LIB: OnceLock> = OnceLock::new(); + +unsafe fn load_x11_sym(lib: *mut libc::c_void, name: &std::ffi::CStr) -> Option { + let ptr = unsafe { libc::dlsym(lib, name.as_ptr()) }; + if ptr.is_null() { None } else { Some(unsafe { std::mem::transmute_copy(&ptr) }) } +} + +fn x11_lib() -> Option<&'static X11Lib> { + X11_LIB + .get_or_init(|| unsafe { + let lib = libc::dlopen(c"libX11.so.6".as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL); + if lib.is_null() { + return None; + } + Some(X11Lib { + x_open_display: load_x11_sym(lib, c"XOpenDisplay")?, + x_close_display: load_x11_sym(lib, c"XCloseDisplay")?, + x_default_root_window: load_x11_sym(lib, c"XDefaultRootWindow")?, + x_intern_atom: load_x11_sym(lib, c"XInternAtom")?, + x_get_window_property: load_x11_sym(lib, c"XGetWindowProperty")?, + x_free: load_x11_sym(lib, c"XFree")?, + x_fetch_name: load_x11_sym(lib, c"XFetchName")?, + x_get_window_attributes: load_x11_sym(lib, c"XGetWindowAttributes")?, + x_send_event: load_x11_sym(lib, c"XSendEvent")?, + x_set_input_focus: load_x11_sym(lib, c"XSetInputFocus")?, + x_raise_window: load_x11_sym(lib, c"XRaiseWindow")?, + x_sync: load_x11_sym(lib, c"XSync")?, + x_set_error_handler: load_x11_sym(lib, c"XSetErrorHandler")?, + }) + }) + .as_ref() +} + +/// Temporarily installs `x11_ignore_bad_access` around an X11 operation, flushes +/// errors with `XSync`, and immediately restores the previous error handler so +/// Qt's process-wide X11 error handler is never clobbered. +unsafe fn with_x11_error_guard( + x_set_error_handler: unsafe extern "C" fn(XErrorHandler) -> XErrorHandler, + x_sync: unsafe extern "C" fn(*mut libc::c_void, libc::c_int) -> libc::c_int, + dpy: *mut libc::c_void, + op: impl FnOnce() -> R, +) -> (R, bool) { + let _guard = X11_ERROR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + X11_HAD_ERROR.store(false, Ordering::Release); + let prev = unsafe { x_set_error_handler(Some(x11_ignore_bad_access)) }; + let out = op(); + let _ = unsafe { x_sync(dpy, 0) }; + let _ = unsafe { x_set_error_handler(prev) }; + let had_err = X11_HAD_ERROR.load(Ordering::Acquire); + (out, had_err) +} + +fn x11_window_title(x11: &X11Lib, dpy: *mut libc::c_void, win: libc::c_ulong) -> Option { + unsafe { + let net_wm_name = (x11.x_intern_atom)(dpy, c"_NET_WM_NAME".as_ptr(), 0); + if net_wm_name != 0 { + let mut actual_type: libc::c_ulong = 0; + let mut actual_format: libc::c_int = 0; + let mut nitems: libc::c_ulong = 0; + let mut bytes_after: libc::c_ulong = 0; + let mut prop: *mut libc::c_uchar = std::ptr::null_mut(); + let status = (x11.x_get_window_property)( + dpy, + win, + net_wm_name, + 0, + 1024, + 0, + 0, // AnyPropertyType + &mut actual_type, + &mut actual_format, + &mut nitems, + &mut bytes_after, + &mut prop, + ); + if status == 0 && !prop.is_null() { + let text = if actual_format == 8 && nitems > 0 { + let slice = std::slice::from_raw_parts(prop, nitems as usize); + let end = slice.iter().position(|&b| b == 0).unwrap_or(slice.len()); + Some(String::from_utf8_lossy(&slice[..end]).trim().to_owned()) + } else { + None + }; + let _ = (x11.x_free)(prop.cast()); + if let Some(t) = text.filter(|s| !s.is_empty()) { + return Some(t); + } + } + } + + let mut name_ptr: *mut libc::c_char = std::ptr::null_mut(); + if (x11.x_fetch_name)(dpy, win, &mut name_ptr) != 0 && !name_ptr.is_null() { + let title = std::ffi::CStr::from_ptr(name_ptr).to_string_lossy().trim().to_owned(); + let _ = (x11.x_free)(name_ptr.cast()); + if !title.is_empty() { + return Some(title); + } + } + None + } +} + +fn x11_query_active_window() -> Option<(isize, String)> { + let x11 = x11_lib()?; + unsafe { + let dpy = (x11.x_open_display)(std::ptr::null()); + if dpy.is_null() { + return None; + } + let root = (x11.x_default_root_window)(dpy); + let net_active = (x11.x_intern_atom)(dpy, c"_NET_ACTIVE_WINDOW".as_ptr(), 0); + let (res, had_err) = with_x11_error_guard(x11.x_set_error_handler, x11.x_sync, dpy, || { + if net_active == 0 { + return None; + } + let mut actual_type: libc::c_ulong = 0; + let mut actual_format: libc::c_int = 0; + let mut nitems: libc::c_ulong = 0; + let mut bytes_after: libc::c_ulong = 0; + let mut prop: *mut libc::c_uchar = std::ptr::null_mut(); + let status = (x11.x_get_window_property)( + dpy, + root, + net_active, + 0, + 1, + 0, + 0, // AnyPropertyType + &mut actual_type, + &mut actual_format, + &mut nitems, + &mut bytes_after, + &mut prop, + ); + if status != 0 || prop.is_null() { + return None; + } + let win = if actual_format == 32 && nitems >= 1 { *(prop as *const libc::c_ulong) } else { 0 }; + let _ = (x11.x_free)(prop.cast()); + if win == 0 { + return None; + } + let title = x11_window_title(x11, dpy, win)?; + Some((win, title)) + }); + let _ = (x11.x_close_display)(dpy); + if had_err { + return None; + } + let (win, title) = res?; + if is_nectarlink_title(&title) { + return None; + } + let hwnd = win as isize; + if hwnd == 0 { + return None; + } + if let Ok(mut map) = window_id_map().lock() { + map.insert(hwnd, format!("0x{win:x}")); + } + Some((hwnd, title)) + } +} + +fn x11_is_window_alive_and_visible(hwnd: isize) -> bool { + if !is_numeric_window_id(hwnd) { + return false; + } + let Some(x11) = x11_lib() else { + return false; + }; + unsafe { + let dpy = (x11.x_open_display)(std::ptr::null()); + if dpy.is_null() { + return false; + } + const IS_VIEWABLE: libc::c_int = 2; + let (viewable, had_err) = with_x11_error_guard(x11.x_set_error_handler, x11.x_sync, dpy, || { + let mut attrs: XWindowAttributes = std::mem::zeroed(); + let status = (x11.x_get_window_attributes)(dpy, hwnd as libc::c_ulong, &mut attrs); + status != 0 && attrs.map_state == IS_VIEWABLE + }); + let _ = (x11.x_close_display)(dpy); + viewable && !had_err + } +} + +fn x11_focus_window(hwnd: isize) -> bool { + if !is_numeric_window_id(hwnd) { + return false; + } + let Some(x11) = x11_lib() else { + return false; + }; + unsafe { + let dpy = (x11.x_open_display)(std::ptr::null()); + if dpy.is_null() { + return false; + } + let root = (x11.x_default_root_window)(dpy); + let net_active = (x11.x_intern_atom)(dpy, c"_NET_ACTIVE_WINDOW".as_ptr(), 0); + const IS_VIEWABLE: libc::c_int = 2; + const CLIENT_MESSAGE: libc::c_int = 33; + const REVERT_TO_POINTER_ROOT: libc::c_int = 1; + const SUBSTRUCTURE_REDIRECT_MASK: libc::c_long = 1 << 20; + const SUBSTRUCTURE_NOTIFY_MASK: libc::c_long = 1 << 19; + + let (ok, had_err) = with_x11_error_guard(x11.x_set_error_handler, x11.x_sync, dpy, || { + let win = hwnd as libc::c_ulong; + let mut attrs: XWindowAttributes = std::mem::zeroed(); + if (x11.x_get_window_attributes)(dpy, win, &mut attrs) == 0 || attrs.map_state != IS_VIEWABLE { + return false; + } + if net_active != 0 { + let mut ev: XEvent = std::mem::zeroed(); + ev.xclient = XClientMessageEvent { + event_type: CLIENT_MESSAGE, + serial: 0, + send_event: 1, + display: dpy, + window: win, + message_type: net_active, + format: 32, + data_l: [2, 0, 0, 0, 0], + }; + let _ = (x11.x_send_event)( + dpy, + root, + 0, + SUBSTRUCTURE_REDIRECT_MASK | SUBSTRUCTURE_NOTIFY_MASK, + &mut ev, + ); + } + let _ = (x11.x_raise_window)(dpy, win); + let _ = (x11.x_set_input_focus)(dpy, win, REVERT_TO_POINTER_ROOT, 0); + true + }); + let _ = (x11.x_close_display)(dpy); + ok && !had_err + } +} + +/// Queries the currently active external window via X11 `_NET_ACTIVE_WINDOW` +/// (in-process), `kdotool` (KDE Wayland), or `xdotool`, ignoring NectarLink's own windows. pub fn query_active_window() -> Option<(isize, String)> { + if has_x11_display() + && let Some((hwnd, title)) = x11_query_active_window() + { + return Some((hwnd, title)); + } if has_wayland_display() && let Some(raw_id) = run_command_stdout("kdotool", &["getactivewindow"]) && let Some(hwnd) = parse_window_handle(&raw_id) @@ -162,14 +541,16 @@ pub fn record_foreground_window() { } /// Captures the last active external foreground window `(hwnd, title)` at the -/// moment a Continuity Camera request begins. +/// moment a Continuity Camera request begins. Returns `None` honestly when no +/// live external window can be inspected. pub fn snapshot_target_window() -> Option<(isize, String)> { if let Some((hwnd, title)) = query_active_window() { let mut guard = LAST_EXTERNAL_WINDOW.lock().unwrap_or_else(|e| e.into_inner()); *guard = Some((hwnd, title.clone())); return Some((hwnd, title)); } - LAST_EXTERNAL_WINDOW.lock().unwrap_or_else(|e| e.into_inner()).clone() + let cached = LAST_EXTERNAL_WINDOW.lock().unwrap_or_else(|e| e.into_inner()).clone()?; + if is_window_alive_and_visible(cached.0) { Some(cached) } else { None } } /// Returns true if `hwnd` still refers to an existing, visible top-level window. @@ -177,6 +558,9 @@ pub fn is_window_alive_and_visible(hwnd: isize) -> bool { if hwnd == 0 { return false; } + if has_x11_display() && x11_is_window_alive_and_visible(hwnd) { + return true; + } let raw_id = raw_window_id(hwnd); if has_wayland_display() && let Some(title) = run_command_stdout("kdotool", &["getwindowname", &raw_id]) @@ -190,19 +574,18 @@ pub fn is_window_alive_and_visible(hwnd: isize) -> bool { { return true; } - LAST_EXTERNAL_WINDOW - .lock() - .unwrap_or_else(|e| e.into_inner()) - .as_ref() - .is_some_and(|(recorded_hwnd, _)| *recorded_hwnd == hwnd) + false } /// Restores and brings `hwnd` to the foreground so a paste shortcut can be -/// synthesized into it. Returns false if the window is gone. +/// synthesized into it. Returns false if the window is gone or cannot be focused. pub fn focus_window(hwnd: isize) -> bool { if !is_window_alive_and_visible(hwnd) { return false; } + if has_x11_display() && x11_focus_window(hwnd) { + return true; + } let raw_id = raw_window_id(hwnd); if has_wayland_display() && Command::new("kdotool") @@ -237,11 +620,7 @@ pub fn focus_window(hwnd: isize) -> bool { return true; } } - LAST_EXTERNAL_WINDOW - .lock() - .unwrap_or_else(|e| e.into_inner()) - .as_ref() - .is_some_and(|(recorded_hwnd, _)| *recorded_hwnd == hwnd) + false } /// If the window manager prevented `focus_window` from making `hwnd` the @@ -461,183 +840,415 @@ struct XKeyEvent { union XEvent { event_type: libc::c_int, xkey: XKeyEvent, + xclient: XClientMessageEvent, pad: [libc::c_long; 24], } -unsafe extern "C" fn ignore_x11_error(_dpy: *mut libc::c_void, _err: *mut libc::c_void) -> libc::c_int { - 0 -} - -fn spawn_x11_hotkey_thread(stop: Arc, handler: Arc) -> Option> { +fn run_x11_hotkey_loop(stop: &AtomicBool, handler: &SharedHandler) { if !has_x11_display() { - return None; + return; } - std::thread::Builder::new() - .name("tray-x11-hotkeys".into()) - .spawn(move || { - // SAFETY: Dynamically loads libX11.so.6 and resolves standard Xlib symbols. - unsafe { - let lib = libc::dlopen(c"libX11.so.6".as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL); - if lib.is_null() { - tracing::debug!("libX11.so.6 not available for global hotkeys"); + // SAFETY: Dynamically loads libX11.so.6 and resolves standard Xlib symbols. + unsafe { + let lib = libc::dlopen(c"libX11.so.6".as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL); + if lib.is_null() { + tracing::debug!("libX11.so.6 not available for global hotkeys"); + return; + } + + type FnXOpenDisplay = unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void; + type FnXCloseDisplay = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; + type FnXDefaultRootWindow = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_ulong; + type FnXKeysymToKeycode = unsafe extern "C" fn(*mut libc::c_void, libc::c_ulong) -> libc::c_uchar; + type FnXGrabKey = unsafe extern "C" fn( + *mut libc::c_void, + libc::c_int, + libc::c_uint, + libc::c_ulong, + libc::c_int, + libc::c_int, + libc::c_int, + ) -> libc::c_int; + type FnXUngrabKey = + unsafe extern "C" fn(*mut libc::c_void, libc::c_int, libc::c_uint, libc::c_ulong) -> libc::c_int; + type FnXPending = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; + type FnXNextEvent = unsafe extern "C" fn(*mut libc::c_void, *mut XEvent) -> libc::c_int; + type FnXSync = unsafe extern "C" fn(*mut libc::c_void, libc::c_int) -> libc::c_int; + type FnXConnectionNumber = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; + type FnXSetErrorHandler = unsafe extern "C" fn(XErrorHandler) -> XErrorHandler; + + macro_rules! sym { + ($name:literal, $ty:ty) => {{ + let ptr = libc::dlsym(lib, $name.as_ptr()); + if ptr.is_null() { return; } + std::mem::transmute::<*mut libc::c_void, $ty>(ptr) + }}; + } - type FnXOpenDisplay = unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void; - type FnXCloseDisplay = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; - type FnXDefaultRootWindow = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_ulong; - type FnXKeysymToKeycode = - unsafe extern "C" fn(*mut libc::c_void, libc::c_ulong) -> libc::c_uchar; - type FnXGrabKey = unsafe extern "C" fn( - *mut libc::c_void, - libc::c_int, - libc::c_uint, - libc::c_ulong, - libc::c_int, - libc::c_int, - libc::c_int, - ) -> libc::c_int; - type FnXUngrabKey = unsafe extern "C" fn( - *mut libc::c_void, - libc::c_int, - libc::c_uint, - libc::c_ulong, - ) -> libc::c_int; - type FnXPending = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; - type FnXNextEvent = unsafe extern "C" fn(*mut libc::c_void, *mut XEvent) -> libc::c_int; - type FnXSync = unsafe extern "C" fn(*mut libc::c_void, libc::c_int) -> libc::c_int; - type FnXConnectionNumber = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int; - type XErrorHandler = - Option libc::c_int>; - type FnXSetErrorHandler = unsafe extern "C" fn(XErrorHandler) -> XErrorHandler; - - macro_rules! sym { - ($name:literal, $ty:ty) => {{ - let ptr = libc::dlsym(lib, $name.as_ptr()); - if ptr.is_null() { - return; + let x_open_display = sym!(c"XOpenDisplay", FnXOpenDisplay); + let x_close_display = sym!(c"XCloseDisplay", FnXCloseDisplay); + let x_default_root_window = sym!(c"XDefaultRootWindow", FnXDefaultRootWindow); + let x_keysym_to_keycode = sym!(c"XKeysymToKeycode", FnXKeysymToKeycode); + let x_grab_key = sym!(c"XGrabKey", FnXGrabKey); + let x_ungrab_key = sym!(c"XUngrabKey", FnXUngrabKey); + let x_pending = sym!(c"XPending", FnXPending); + let x_next_event = sym!(c"XNextEvent", FnXNextEvent); + let x_sync = sym!(c"XSync", FnXSync); + let x_connection_number = sym!(c"XConnectionNumber", FnXConnectionNumber); + let x_set_error_handler = sym!(c"XSetErrorHandler", FnXSetErrorHandler); + + let dpy = x_open_display(std::ptr::null()); + if dpy.is_null() { + tracing::debug!("XOpenDisplay failed for global hotkeys"); + return; + } + let root = x_default_root_window(dpy); + let fd = x_connection_number(dpy); + + const KEY_PRESS: libc::c_int = 2; + const GRAB_MODE_ASYNC: libc::c_int = 1; + const LOCK_COMBOS: [u32; 4] = [0, X11_LOCK_MASK, X11_MOD2_MASK, X11_LOCK_MASK | X11_MOD2_MASK]; + + let mut applied_gen: u64 = 0; + // (x11_clean_mods, keycode, TrayEvent) + let mut grabbed: Vec<(u32, u32, TrayEvent)> = Vec::new(); + let mut last_fired: Option<(TrayEvent, Instant)> = None; + + while !stop.load(Ordering::Acquire) { + let current_gen = HOTKEY_GENERATION.load(Ordering::Acquire); + if current_gen != applied_gen { + let (photo, scan, palette, shelf) = + HOTKEY_SPECS.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let targets = [ + (photo.as_str(), TrayEvent::TakePhoto), + (scan.as_str(), TrayEvent::ScanDocument), + (palette.as_str(), TrayEvent::CommandPalette), + (shelf.as_str(), TrayEvent::Shelf), + ]; + + let _ = with_x11_error_guard(x_set_error_handler, x_sync, dpy, || { + for &(mods, keycode, _) in &grabbed { + for extra in LOCK_COMBOS { + let _ = x_ungrab_key(dpy, keycode as libc::c_int, mods | extra, root); } - std::mem::transmute::<*mut libc::c_void, $ty>(ptr) - }}; - } - - let x_open_display = sym!(c"XOpenDisplay", FnXOpenDisplay); - let x_close_display = sym!(c"XCloseDisplay", FnXCloseDisplay); - let x_default_root_window = sym!(c"XDefaultRootWindow", FnXDefaultRootWindow); - let x_keysym_to_keycode = sym!(c"XKeysymToKeycode", FnXKeysymToKeycode); - let x_grab_key = sym!(c"XGrabKey", FnXGrabKey); - let x_ungrab_key = sym!(c"XUngrabKey", FnXUngrabKey); - let x_pending = sym!(c"XPending", FnXPending); - let x_next_event = sym!(c"XNextEvent", FnXNextEvent); - let x_sync = sym!(c"XSync", FnXSync); - let x_connection_number = sym!(c"XConnectionNumber", FnXConnectionNumber); - let x_set_error_handler = sym!(c"XSetErrorHandler", FnXSetErrorHandler); - - let _ = x_set_error_handler(Some(ignore_x11_error)); - let dpy = x_open_display(std::ptr::null()); - if dpy.is_null() { - tracing::debug!("XOpenDisplay failed for global hotkeys"); - return; - } - let root = x_default_root_window(dpy); - let fd = x_connection_number(dpy); - - const KEY_PRESS: libc::c_int = 2; - const GRAB_MODE_ASYNC: libc::c_int = 1; - const LOCK_COMBOS: [u32; 4] = - [0, X11_LOCK_MASK, X11_MOD2_MASK, X11_LOCK_MASK | X11_MOD2_MASK]; - - let mut applied_gen: u64 = 0; - // (x11_clean_mods, keycode, TrayEvent) - let mut grabbed: Vec<(u32, u32, TrayEvent)> = Vec::new(); - let mut last_fired: Option<(TrayEvent, Instant)> = None; - - while !stop.load(Ordering::Acquire) { - let current_gen = HOTKEY_GENERATION.load(Ordering::Acquire); - if current_gen != applied_gen { - for &(mods, keycode, _) in &grabbed { - for extra in LOCK_COMBOS { - let _ = x_ungrab_key(dpy, keycode as libc::c_int, mods | extra, root); - } + } + grabbed.clear(); + + for (spec, ev) in targets { + let Some((mods, vk)) = parse_hotkey(spec) else { continue }; + let Some(keysym) = vk_to_x11_keysym(vk) else { continue }; + let keycode = u32::from(x_keysym_to_keycode(dpy, keysym)); + if keycode == 0 { + continue; + } + let x11_mods = mods_to_x11_mask(mods); + if x11_mods == 0 { + continue; } - grabbed.clear(); - - let (photo, scan, palette, shelf) = - HOTKEY_SPECS.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let targets = [ - (photo.as_str(), TrayEvent::TakePhoto), - (scan.as_str(), TrayEvent::ScanDocument), - (palette.as_str(), TrayEvent::CommandPalette), - (shelf.as_str(), TrayEvent::Shelf), - ]; - - for (spec, ev) in targets { - let Some((mods, vk)) = parse_hotkey(spec) else { continue }; - let Some(keysym) = vk_to_x11_keysym(vk) else { continue }; - let keycode = u32::from(x_keysym_to_keycode(dpy, keysym)); - if keycode == 0 { - continue; - } - let x11_mods = mods_to_x11_mask(mods); - if x11_mods == 0 { - continue; - } - for extra in LOCK_COMBOS { - let _ = x_grab_key( - dpy, - keycode as libc::c_int, - x11_mods | extra, - root, - 1, // owner_events = True - GRAB_MODE_ASYNC, - GRAB_MODE_ASYNC, - ); - } - grabbed.push((x11_mods, keycode, ev)); + for extra in LOCK_COMBOS { + let _ = x_grab_key( + dpy, + keycode as libc::c_int, + x11_mods | extra, + root, + 1, // owner_events = True + GRAB_MODE_ASYNC, + GRAB_MODE_ASYNC, + ); } - let _ = x_sync(dpy, 0); - applied_gen = current_gen; + grabbed.push((x11_mods, keycode, ev)); } + }); + applied_gen = current_gen; + } - while x_pending(dpy) > 0 { - let mut event: XEvent = std::mem::zeroed(); - let _ = x_next_event(dpy, &mut event); - if event.event_type == KEY_PRESS { - let clean_mods = event.xkey.state & X11_CLEAN_MOD_MASK; - let keycode = event.xkey.keycode; - if let Some(&(_, _, tray_ev)) = - grabbed.iter().find(|&&(m, kc, _)| m == clean_mods && kc == keycode) - { - let now = Instant::now(); - let debounced = last_fired.is_some_and(|(prev_ev, prev_t)| { - prev_ev == tray_ev - && now.saturating_duration_since(prev_t) < Duration::from_millis(220) - }); - if !debounced { - last_fired = Some((tray_ev, now)); - record_foreground_window(); - handler.emit(tray_ev); - } - } + while x_pending(dpy) > 0 { + let mut event: XEvent = std::mem::zeroed(); + let _ = x_next_event(dpy, &mut event); + if event.event_type == KEY_PRESS { + let clean_mods = event.xkey.state & X11_CLEAN_MOD_MASK; + let keycode = event.xkey.keycode; + if let Some(&(_, _, tray_ev)) = + grabbed.iter().find(|&&(m, kc, _)| m == clean_mods && kc == keycode) + { + let now = Instant::now(); + let debounced = last_fired.is_some_and(|(prev_ev, prev_t)| { + prev_ev == tray_ev + && now.saturating_duration_since(prev_t) < Duration::from_millis(220) + }); + if !debounced { + last_fired = Some((tray_ev, now)); + record_foreground_window(); + handler.emit(tray_ev); } } - - let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 }; - let _ = libc::poll(&mut pfd, 1, 150); } + } - for &(mods, keycode, _) in &grabbed { - for extra in LOCK_COMBOS { - let _ = x_ungrab_key(dpy, keycode as libc::c_int, mods | extra, root); - } + let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 }; + let _ = libc::poll(&mut pfd, 1, 150); + } + + let _ = with_x11_error_guard(x_set_error_handler, x_sync, dpy, || { + for &(mods, keycode, _) in &grabbed { + for extra in LOCK_COMBOS { + let _ = x_ungrab_key(dpy, keycode as libc::c_int, mods | extra, root); } - let _ = x_sync(dpy, 0); - let _ = x_close_display(dpy); } - }) + }); + let _ = x_close_display(dpy); + } +} + +fn spawn_x11_hotkey_thread(stop: Arc, handler: Arc) -> Option> { + if !has_x11_display() { + return None; + } + std::thread::Builder::new() + .name("tray-x11-hotkeys".into()) + .spawn(move || run_x11_hotkey_loop(&stop, &handler)) .ok() } +/// Maps an `org.freedesktop.portal.GlobalShortcuts` shortcut identifier to [`TrayEvent`]. +pub(crate) fn parse_portal_shortcut_id(id: &str) -> Option { + match id.trim() { + "take-photo" => Some(TrayEvent::TakePhoto), + "scan-document" => Some(TrayEvent::ScanDocument), + "command-palette" => Some(TrayEvent::CommandPalette), + "shelf" => Some(TrayEvent::Shelf), + _ => None, + } +} + +/// Converts a NectarLink hotkey spec (e.g. `"Ctrl+Alt+C"`) into an XDG GlobalShortcuts +/// `preferred_trigger` string (e.g. `"CTRL+ALT+c"`). +pub(crate) fn hotkey_to_portal_trigger(spec: &str) -> Option { + let (mods, vk) = parse_hotkey(spec)?; + let mut parts: Vec = Vec::new(); + if (mods & MOD_CONTROL) != 0 { + parts.push("CTRL".into()); + } + if (mods & MOD_ALT) != 0 { + parts.push("ALT".into()); + } + if (mods & MOD_SHIFT) != 0 { + parts.push("SHIFT".into()); + } + if (mods & MOD_WIN) != 0 { + parts.push("LOGO".into()); + } + let key_name: String = match vk { + 0x20 => "space".into(), + 0x09 => "Tab".into(), + 0x0D => "Return".into(), + 0x1B => "Escape".into(), + 0x2D => "Insert".into(), + 0x2E => "Delete".into(), + 0x24 => "Home".into(), + 0x23 => "End".into(), + 0x21 => "Page_Up".into(), + 0x22 => "Page_Down".into(), + 0x25 => "Left".into(), + 0x26 => "Up".into(), + 0x27 => "Right".into(), + 0x28 => "Down".into(), + 0xBB => "equal".into(), + 0xBD => "minus".into(), + 0xBC => "comma".into(), + 0xBE => "period".into(), + 0x30..=0x39 => char::from(vk as u8).to_string(), + 0x41..=0x5A => char::from((vk as u8).to_ascii_lowercase()).to_string(), + 0x70..=0x7B => format!("F{}", vk - 0x70 + 1), + _ => return None, + }; + parts.push(key_name); + Some(parts.join("+")) +} + +fn bind_portal_global_shortcuts( + conn: &zbus::blocking::Connection, + session_path: zbus::zvariant::ObjectPath<'_>, + bind_seq: &mut u64, +) -> bool { + use zbus::zvariant::Value; + + let (photo, scan, palette, shelf) = HOTKEY_SPECS.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let specs = [ + ("take-photo", "Take photo with phone", photo.as_str()), + ("scan-document", "Scan document with phone", scan.as_str()), + ("command-palette", "Open Command Palette", palette.as_str()), + ("shelf", "Open File Shelf", shelf.as_str()), + ]; + + let mut shortcuts: Vec<(&str, HashMap<&str, Value<'_>>)> = Vec::new(); + let mut triggers: Vec = Vec::new(); + for (_, _, spec) in specs { + triggers.push(hotkey_to_portal_trigger(spec).unwrap_or_default()); + } + for ((id, desc, _), trigger) in specs.iter().zip(triggers.iter()) { + if trigger.is_empty() { + continue; + } + let mut meta: HashMap<&str, Value<'_>> = HashMap::new(); + meta.insert("description", Value::from(*desc)); + meta.insert("preferred_trigger", Value::from(trigger.as_str())); + shortcuts.push((*id, meta)); + } + if shortcuts.is_empty() { + return true; + } + + *bind_seq += 1; + let token = format!("nectarlink_gs_bind_{}", *bind_seq); + let mut options: HashMap<&str, Value<'_>> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + + conn.call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.portal.GlobalShortcuts"), + "BindShortcuts", + &(&session_path, shortcuts, "", options), + ) + .is_ok() +} + +fn try_run_wayland_portal_shortcuts(stop: &AtomicBool, handler: &SharedHandler) -> bool { + use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Value}; + + let Ok(conn) = zbus::blocking::Connection::session() else { + return false; + }; + if !super::dbus_name_has_owner(&conn, "org.freedesktop.portal.Desktop") { + return false; + } + let Ok(dbus) = zbus::blocking::fdo::DBusProxy::new(&conn) else { + return false; + }; + let Ok(activated_rule) = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .interface("org.freedesktop.portal.GlobalShortcuts") + .and_then(|b| b.member("Activated")) + .and_then(|b| b.path("/org/freedesktop/portal/desktop")) + .map(|b| b.build()) + else { + return false; + }; + if dbus.add_match_rule(activated_rule).is_err() { + return false; + } + if let Ok(resp_rule) = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .interface("org.freedesktop.portal.Request") + .and_then(|b| b.member("Response")) + .map(|b| b.build()) + { + let _ = dbus.add_match_rule(resp_rule); + } + + let pid = std::process::id(); + let req_token = format!("nectarlink_gs_req_{pid}"); + let session_token = format!("nectarlink_gs_sess_{pid}"); + let mut create_opts: HashMap<&str, Value<'_>> = HashMap::new(); + create_opts.insert("handle_token", Value::from(req_token.as_str())); + create_opts.insert("session_handle_token", Value::from(session_token.as_str())); + + let Ok(reply) = conn.call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.portal.GlobalShortcuts"), + "CreateSession", + &(create_opts,), + ) else { + return false; + }; + + let predicted_session_path = + conn.unique_name().map(|u| u.as_str().trim_start_matches(':').replace('.', "_")).and_then(|sender| { + ObjectPath::try_from(format!("/org/freedesktop/portal/desktop/session/{sender}/{session_token}")) + .ok() + .map(OwnedObjectPath::from) + }); + + let mut session_path: Option = reply + .body() + .deserialize::() + .ok() + .filter(|p| p.as_str().contains("/session/")) + .or(predicted_session_path); + + let mut applied_gen: u64 = 0; + let mut bind_seq: u64 = 0; + + if let Some(ref sp) = session_path { + let gen_now = HOTKEY_GENERATION.load(Ordering::Acquire); + let _ = bind_portal_global_shortcuts(&conn, sp.as_ref(), &mut bind_seq); + applied_gen = gen_now; + } + + for msg in zbus::blocking::MessageIterator::from(&conn) { + if stop.load(Ordering::Acquire) { + break; + } + let Ok(msg) = msg else { + break; + }; + let hdr = msg.header(); + let iface = hdr.interface().map(|i| i.as_str()); + let member = hdr.member().map(|m| m.as_str()); + + if iface == Some("org.freedesktop.portal.Request") + && member == Some("Response") + && let Ok((code, results)) = msg.body().deserialize::<(u32, HashMap)>() + { + if code != 0 && session_path.is_none() { + return false; + } + if let Some(val) = results.get("session_handle") { + if let Ok(s) = <&str>::try_from(val) + && let Ok(op) = ObjectPath::try_from(s) + { + session_path = Some(OwnedObjectPath::from(op)); + applied_gen = 0; + } else if let Ok(op) = >::try_from(&**val) { + session_path = Some(OwnedObjectPath::from(op)); + applied_gen = 0; + } + } + } + + let current_gen = HOTKEY_GENERATION.load(Ordering::Acquire); + if current_gen != applied_gen + && let Some(ref sp) = session_path + { + let _ = bind_portal_global_shortcuts(&conn, sp.as_ref(), &mut bind_seq); + applied_gen = current_gen; + } + + if iface == Some("org.freedesktop.portal.GlobalShortcuts") + && member == Some("Activated") + && let Ok((_sess, shortcut_id, _ts, _opts)) = + msg.body().deserialize::<(OwnedObjectPath, String, u64, HashMap)>() + && let Some(ev) = parse_portal_shortcut_id(&shortcut_id) + { + record_foreground_window(); + handler.emit(ev); + } + } + true +} + +fn spawn_wayland_global_shortcuts_listener(stop: Arc, handler: Arc) { + let _ = std::thread::Builder::new().name("tray-wayland-shortcuts".into()).spawn(move || { + if !try_run_wayland_portal_shortcuts(&stop, &handler) && !stop.load(Ordering::Acquire) { + run_x11_hotkey_loop(&stop, &handler); + } + }); +} + /// Returns true when an `org.freedesktop.portal.Settings.SettingChanged` /// signal (`namespace`, `key`) affects desktop appearance (`"color-scheme"`, /// `"contrast"`, or `"accent-color"`). @@ -668,19 +1279,14 @@ pub fn is_portal_appearance_changed_line(line: &str) -> bool { || trimmed.contains("accent-color")) } -type TrayEventHandler = Box; +type TrayEventHandler = Box; struct SharedHandler { inner: Mutex>, } -// SAFETY: Access to `inner` is serialized by its `Mutex`, and all invocations -// either run on the owning thread or forward thread-safe `TrayEvent` variants. -unsafe impl Send for SharedHandler {} -unsafe impl Sync for SharedHandler {} - impl SharedHandler { - fn new(handler: impl Fn(TrayEvent) + 'static) -> Arc { + fn new(handler: impl Fn(TrayEvent) + Send + Sync + 'static) -> Arc { Arc::new(Self { inner: Mutex::new(Some(Box::new(handler))) }) } @@ -809,7 +1415,11 @@ impl std::fmt::Debug for Tray { } impl Tray { - pub fn create(tooltip: &str, labels: MenuLabels, handler: impl Fn(TrayEvent) + 'static) -> Result { + pub fn create( + tooltip: &str, + labels: MenuLabels, + handler: impl Fn(TrayEvent) + Send + Sync + 'static, + ) -> Result { let icon_path = tray_icon_cache_path(); if let Err(e) = super::icon::write_png(&icon_path, 32) { tracing::debug!(error = %e, path = %icon_path.display(), "could not write tray icon PNG"); @@ -866,7 +1476,9 @@ impl Tray { spawn_portal_appearance_listener(Arc::clone(&stop), Arc::clone(&shared_handler)); } - if let Some(handle) = spawn_x11_hotkey_thread(Arc::clone(&stop), Arc::clone(&shared_handler)) { + if !cfg!(test) && has_wayland_display() { + spawn_wayland_global_shortcuts_listener(Arc::clone(&stop), Arc::clone(&shared_handler)); + } else if let Some(handle) = spawn_x11_hotkey_thread(Arc::clone(&stop), Arc::clone(&shared_handler)) { threads.push(handle); } @@ -1011,14 +1623,15 @@ mod tests { #[test] fn tray_lifecycle_and_window_tracking_work() { + assert!(!is_window_alive_and_visible(0)); + assert!(!focus_window(0)); + // Non-existent window handle must honestly report false rather than faking focus. { let mut guard = LAST_EXTERNAL_WINDOW.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some((0x4242, "LibreOffice Writer".into())); + *guard = Some((0x7FFF_4242, "Nonexistent Window".into())); } - assert!(is_window_alive_and_visible(0x4242)); - assert!(focus_window(0x4242)); - let snap = snapshot_target_window().expect("snapshot window"); - assert!(!snap.1.is_empty()); + assert!(!is_window_alive_and_visible(0x7FFF_4242)); + assert!(!focus_window(0x7FFF_4242)); let labels = MenuLabels { open: "Open".into(), @@ -1050,6 +1663,20 @@ mod tests { assert_eq!(parse_tray_helper_action("UNKNOWN"), None); } + #[test] + fn portal_shortcut_mapping_and_triggers_work() { + assert_eq!(parse_portal_shortcut_id("take-photo"), Some(TrayEvent::TakePhoto)); + assert_eq!(parse_portal_shortcut_id("scan-document"), Some(TrayEvent::ScanDocument)); + assert_eq!(parse_portal_shortcut_id("command-palette"), Some(TrayEvent::CommandPalette)); + assert_eq!(parse_portal_shortcut_id("shelf"), Some(TrayEvent::Shelf)); + assert_eq!(parse_portal_shortcut_id("unknown"), None); + + assert_eq!(hotkey_to_portal_trigger("Ctrl+Alt+C").as_deref(), Some("CTRL+ALT+c")); + assert_eq!(hotkey_to_portal_trigger("Ctrl+Alt+Space").as_deref(), Some("CTRL+ALT+space")); + assert_eq!(hotkey_to_portal_trigger("Super+Shift+F9").as_deref(), Some("SHIFT+LOGO+F9")); + assert!(hotkey_to_portal_trigger("none").is_none()); + } + #[test] fn x11_hotkey_mapping_covers_modifiers_and_virtual_keys() { let (mods, vk) = parse_hotkey("Ctrl+Alt+Space").expect("Ctrl+Alt+Space"); diff --git a/desktop/app/src/linux/widget.rs b/desktop/app/src/linux/widget.rs index 0e3d34d6..ec5fc2d5 100644 --- a/desktop/app/src/linux/widget.rs +++ b/desktop/app/src/linux/widget.rs @@ -36,11 +36,57 @@ pub fn state_snapshot_path(data_dir: &Path) -> PathBuf { data_dir.join("widget-state.json") } -/// Writes `state` to `/widget-state.json`. +/// Writes `state` atomically to `/widget-state.json` with `O_NOFOLLOW` and `0o600` permissions. pub fn write_snapshot_file(data_dir: &Path, state: &WidgetState) -> io::Result<()> { + fs::create_dir_all(data_dir)?; let path = state_snapshot_path(data_dir); let json = serde_json::to_vec_pretty(state).map_err(io::Error::other)?; - fs::write(path, json) + + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + if let Ok(meta) = fs::symlink_metadata(&path) + && meta.file_type().is_symlink() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "refusing to overwrite symlink widget-state.json", + )); + } + + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let tmp_path = data_dir.join(format!(".widget-state.json.tmp.{}.{seq}", std::process::id())); + let write_res = (|| -> io::Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .custom_flags(libc::O_NOFOLLOW) + .mode(0o600) + .open(&tmp_path)?; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(&json)?; + file.sync_all()?; + Ok(()) + })(); + if let Err(e) = write_res { + let _ = fs::remove_file(&tmp_path); + return Err(e); + } + if let Err(e) = fs::rename(&tmp_path, &path) { + let _ = fs::remove_file(&tmp_path); + return Err(e); + } + Ok(()) + } + #[cfg(not(unix))] + { + fs::write(path, json) + } } /// Reads `/widget-state.json`, or returns [`WidgetState::default`] if missing/corrupt. @@ -104,7 +150,7 @@ pub fn render_adaptive_card(state: &WidgetState) -> String { let battery_text = match state.battery_level { Some(pct) if state.battery_charging => format!("Battery: {pct}% (Charging)"), Some(pct) => format!("Battery: {pct}%"), - None => "Battery: —".to_owned(), + None => "Battery: unknown".to_owned(), }; let clip_text = if state.last_clip_preview.trim().is_empty() { @@ -151,34 +197,54 @@ pub fn render_adaptive_card(state: &WidgetState) -> String { "actions": [ { "type": "Action.Execute", - "title": "Send File", - "verb": "send_file" + "title": "Send Clipboard", + "verb": "send-clipboard" }, { "type": "Action.Execute", "title": "Find My Phone", - "verb": "find_phone" + "verb": "find-phone" + }, + { + "type": "Action.Execute", + "title": "Take Photo", + "verb": "take-photo" }, { "type": "Action.Execute", - "title": "Mirror Screen", - "verb": "mirror_screen" + "title": "Open Timeline", + "verb": "open-timeline" + }, + { + "type": "Action.Execute", + "title": "Open Shelf", + "verb": "open-shelf" } ] }); card.to_string() } -/// Dispatches an Adaptive Card `Action.Execute` verb (`"send_file"`, `"find_phone"`, `"mirror_screen"`, `"open"`) -/// to the running NectarLink instance (or launches NectarLink with `--task ` if not running). +/// Normalizes a widget `Action.Execute` verb into the canonical task action +/// recognized by `send_to::handle` / `bridge::app::handle_jump_task`. +pub(crate) fn normalize_action_verb(verb: &str) -> Result<&'static str, String> { + match verb.trim() { + "send-clipboard" | "send_clipboard" => Ok("send-clipboard"), + "find-phone" | "find_phone" => Ok("find-phone"), + "take-photo" | "take_photo" => Ok("take-photo"), + "open-timeline" | "open_timeline" => Ok("open-timeline"), + "open-shelf" | "open_shelf" | "shelf" => Ok("open-shelf"), + "send-files" | "send-file" | "send_file" => Ok("send-files"), + "open" | "" => Ok("open"), + other => Err(format!("unknown widget action verb: {other}")), + } +} + +/// Dispatches an Adaptive Card `Action.Execute` verb (`"send-clipboard"`, `"find-phone"`, +/// `"take-photo"`, `"open-timeline"`, `"open-shelf"`, `"open"`) to the running NectarLink +/// instance (or launches NectarLink with `--task ` if not running). pub fn handle_action_verb(verb: &str, data_dir: Option<&Path>) -> Result<(), String> { - let action = match verb.trim() { - "send_file" => "send_file", - "find_phone" => "find_phone", - "mirror_screen" => "mirror_screen", - "open" | "" => "open", - other => return Err(format!("unknown widget action verb: {other}")), - }; + let action = normalize_action_verb(verb)?; if let Some(dir) = data_dir { let req = if action == "open" { Request::Show } else { Request::Task { action: action.to_owned() } }; @@ -241,7 +307,12 @@ mod tests { assert_eq!(empty_json["type"], "AdaptiveCard"); assert_eq!(empty_json["version"], "1.5"); assert_eq!(empty_json["body"][0]["text"], "No phone paired"); - assert_eq!(empty_json["actions"].as_array().unwrap().len(), 3); + let actions = empty_json["actions"].as_array().unwrap(); + let verbs: Vec<&str> = actions.iter().filter_map(|a| a["verb"].as_str()).collect(); + assert_eq!(verbs, vec!["send-clipboard", "find-phone", "take-photo", "open-timeline", "open-shelf"]); + for v in &verbs { + assert_eq!(normalize_action_verb(v).unwrap(), *v); + } let connected = WidgetState { connected: true, @@ -270,5 +341,12 @@ mod tests { write_snapshot_file(dir.path(), &state).unwrap(); let loaded = read_snapshot_file(dir.path()); assert_eq!(loaded, state); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(state_snapshot_path(dir.path())).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } } } diff --git a/desktop/app/src/linux/wifi.rs b/desktop/app/src/linux/wifi.rs index 638a66c9..29e6d3b4 100644 --- a/desktop/app/src/linux/wifi.rs +++ b/desktop/app/src/linux/wifi.rs @@ -17,6 +17,46 @@ pub enum Reach { Connected, } +pub fn unescape_nmcli(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some(':') => out.push(':'), + Some('\\') => out.push('\\'), + Some(other) => out.push(other), + None => out.push('\\'), + } + } else { + out.push(ch); + } + } + out +} + +fn split_nmcli_fields(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut chars = line.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some(':') => current.push(':'), + Some('\\') => current.push('\\'), + Some(other) => current.push(other), + None => current.push('\\'), + } + } else if ch == ':' { + fields.push(std::mem::take(&mut current)); + } else { + current.push(ch); + } + } + fields.push(current); + fields +} + fn tidy(mut names: Vec) -> Vec { names.retain(|n| !n.trim().is_empty()); names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); @@ -32,7 +72,8 @@ fn parse_saved_profiles(stdout: &str) -> Vec { if let Some((kind, name)) = line.split_once(':') && (kind == "802-11-wireless" || kind == "wifi") { - let trimmed = name.trim(); + let unescaped = unescape_nmcli(name); + let trimmed = unescaped.trim(); if !trimmed.is_empty() { names.push(trimmed.to_string()); } @@ -51,6 +92,58 @@ pub fn saved_networks() -> Vec { parse_saved_profiles(&String::from_utf8_lossy(&out.stdout)) } +/// Parses `nmcli -t -f ACTIVE,SSID dev wifi` output, finding the active `yes:` line +/// and unescaping `\:` -> `:` and `\\` -> `\`. +pub fn parse_active_dev_wifi(stdout: &str) -> Option { + for line in stdout.lines() { + if let Some(raw_ssid) = line.trim().strip_prefix("yes:") { + let ssid = unescape_nmcli(raw_ssid); + let trimmed = ssid.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +/// Parses `nmcli -t -f NAME,TYPE connection show --active` output as a fallback. +pub fn parse_active_connection_show(stdout: &str) -> Option { + for line in stdout.lines() { + let fields = split_nmcli_fields(line.trim()); + if fields.len() >= 2 { + let kind = fields[fields.len() - 1].trim(); + if kind == "802-11-wireless" || kind == "wifi" { + let name = fields[..fields.len() - 1].join(":"); + let trimmed = name.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + } + None +} + +/// Returns the currently active 802.11 Wi-Fi SSID (or active Wi-Fi connection name). +pub fn current_profile() -> Option { + if let Ok(out) = Command::new("nmcli").args(["-t", "-f", "ACTIVE,SSID", "dev", "wifi"]).output() + && out.status.success() + && let Some(ssid) = parse_active_dev_wifi(&String::from_utf8_lossy(&out.stdout)) + { + return Some(ssid); + } + + let out = Command::new("nmcli") + .args(["-t", "-f", "NAME,TYPE", "connection", "show", "--active"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + parse_active_connection_show(&String::from_utf8_lossy(&out.stdout)) +} + pub fn scan() { let _ = Command::new("nmcli").args(["device", "wifi", "rescan"]).output(); } @@ -60,8 +153,9 @@ fn parse_in_range(stdout: &str) -> Vec { for line in stdout.lines() { // Format from `nmcli -t -f ACTIVE,SSID device wifi list`: // yes:MyHotspot - if let Some((active, ssid)) = line.split_once(':') { - let ssid = ssid.trim(); + if let Some((active, raw_ssid)) = line.split_once(':') { + let unescaped = unescape_nmcli(raw_ssid); + let ssid = unescaped.trim(); if !ssid.is_empty() { out.push(InRange { profile: ssid.to_string(), @@ -141,4 +235,14 @@ mod tests { assert_eq!(reach_in(&list, "Pixel 9a"), Reach::InRange); assert_eq!(reach_in(&list, "Office"), Reach::NotInRange); } + + #[test] + fn parses_active_dev_wifi_with_escaped_colons_and_backslashes() { + let output = "no:NeighborWiFi\nyes:Cafe\\:Guest\\\\5GHz\nno:OtherNet\n"; + assert_eq!(parse_active_dev_wifi(output), Some("Cafe:Guest\\5GHz".to_string())); + assert_eq!(parse_active_dev_wifi("no:NeighborWiFi\nyes:\n"), None); + + let fallback_output = "Wired connection 1:802-3-ethernet\nMy\\:Hotspot\\\\Phone:802-11-wireless\n"; + assert_eq!(parse_active_connection_show(fallback_output), Some("My:Hotspot\\Phone".to_string())); + } } diff --git a/desktop/app/src/linux/wol.rs b/desktop/app/src/linux/wol.rs index 42d0a94f..4d243336 100644 --- a/desktop/app/src/linux/wol.rs +++ b/desktop/app/src/linux/wol.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::fs; use std::net::Ipv4Addr; use std::path::Path; @@ -7,6 +7,26 @@ use std::process::Command; use nectarlink_core::PcWakeInfo; +const ARPHRD_ETHER: &str = "1"; +const SIOCETHTOOL: libc::c_ulong = 0x8946; +const ETHTOOL_GWOL: u32 = 0x0000_0005; +const WAKE_MAGIC: u32 = 1 << 5; + +#[repr(C)] +struct EthtoolWolInfo { + cmd: u32, + supported: u32, + wolopts: u32, + sopass: [u8; 6], +} + +#[repr(C)] +struct IfReq { + ifr_name: [u8; libc::IFNAMSIZ], + ifr_data: *mut libc::c_void, + _pad: [u8; 16], +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WakeSupport { Enabled, @@ -53,8 +73,21 @@ impl WakeStatus { } pub fn query_wake_status() -> WakeStatus { + let default_iface = default_route().map(|(iface, _)| iface); let mut adapters = enumerate_wake_adapters(); - adapters.sort_by_key(|a| (!a.wired, a.label.clone())); + adapters.sort_by_key(|a| { + ( + Some(a.label.as_str()) != default_iface.as_deref(), + !a.wired, + a.broadcasts.is_empty(), + match a.support { + WakeSupport::Enabled => 0u8, + WakeSupport::Unknown => 1, + WakeSupport::Disabled => 2, + }, + a.label.clone(), + ) + }); let mut macs = Vec::new(); let mut broadcast_set = BTreeSet::new(); @@ -70,6 +103,36 @@ pub fn query_wake_status() -> WakeStatus { WakeStatus { info: PcWakeInfo { macs, broadcasts: broadcast_set.into_iter().collect() }, adapters } } +pub fn is_virtual_interface_name(name: &str) -> bool { + let lower = name.trim().to_ascii_lowercase(); + lower == "lo" + || lower.starts_with("tun") + || lower.starts_with("tap") + || lower.starts_with("tailscale") + || lower.starts_with("wg") + || lower.starts_with("docker") + || lower.starts_with("veth") + || lower.starts_with("br-") + || lower.starts_with("virbr") + || lower.starts_with("nordlynx") + || lower.starts_with("zt") + || lower.starts_with("vmnet") + || lower.starts_with("vboxnet") +} + +fn is_physical_ethernet_interface(name: &str, path: &Path) -> bool { + if is_virtual_interface_name(name) { + return false; + } + if !path.join("device").exists() { + return false; + } + let Ok(if_type) = fs::read_to_string(path.join("type")) else { + return false; + }; + if_type.trim() == ARPHRD_ETHER +} + fn enumerate_wake_adapters() -> Vec { let mut result = Vec::new(); let Ok(entries) = fs::read_dir("/sys/class/net") else { @@ -79,27 +142,17 @@ fn enumerate_wake_adapters() -> Vec { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); - if name == "lo" - || name.starts_with("docker") - || name.starts_with("veth") - || name.starts_with("br-") - || name.starts_with("virbr") - || name.starts_with("tun") - || name.starts_with("tap") - || name.starts_with("wg") - { + let path = entry.path(); + if !is_physical_ethernet_interface(&name, &path) { continue; } - let path = entry.path(); let operstate = fs::read_to_string(path.join("operstate")).unwrap_or_default().trim().to_ascii_lowercase(); if operstate != "up" && operstate != "unknown" { continue; } let raw_mac = fs::read_to_string(path.join("address")).unwrap_or_default(); - let bytes: Vec = - raw_mac.trim().split(':').filter_map(|p| u8::from_str_radix(p, 16).ok()).collect(); - let Some(mac) = format_mac(&bytes) else { + let Some(mac) = normalize_mac(&raw_mac) else { continue; }; let wired = !path.join("wireless").exists() && !path.join("phy80211").exists(); @@ -136,7 +189,56 @@ pub fn parse_ethtool_wol(output: &str) -> Option { } } +fn probe_ethtool_ioctl(iface_name: &str) -> Option { + let name_bytes = iface_name.as_bytes(); + if name_bytes.is_empty() || name_bytes.len() >= libc::IFNAMSIZ || name_bytes.contains(&0) { + return None; + } + + // SAFETY: opening an unprivileged AF_INET datagram socket for SIOCETHTOOL. + let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; + if fd < 0 { + return None; + } + + let mut wol = EthtoolWolInfo { cmd: ETHTOOL_GWOL, supported: 0, wolopts: 0, sopass: [0u8; 6] }; + let mut ifr = IfReq { + ifr_name: [0u8; libc::IFNAMSIZ], + ifr_data: (&raw mut wol).cast::(), + _pad: [0u8; 16], + }; + ifr.ifr_name[..name_bytes.len()].copy_from_slice(name_bytes); + + // SAFETY: `ifr` points to our valid stack `EthtoolWolInfo` and `fd` is open. + let rc = unsafe { libc::ioctl(fd, SIOCETHTOOL as _, &mut ifr) }; + // SAFETY: closing the socket opened above. + unsafe { + let _ = libc::close(fd); + } + + if rc < 0 { + return None; + } + if (wol.wolopts & WAKE_MAGIC) != 0 { + Some(WakeSupport::Enabled) + } else if (wol.supported & WAKE_MAGIC) != 0 || wol.supported != 0 { + Some(WakeSupport::Disabled) + } else { + None + } +} + fn probe_adapter_wake_support(iface_name: &str, iface_sys_path: &Path) -> WakeSupport { + if let Some(support) = probe_ethtool_ioctl(iface_name) { + return support; + } + + let wakeup_path = iface_sys_path.join("device/power/wakeup"); + let sysfs_state = fs::read_to_string(wakeup_path).ok(); + if sysfs_state.as_deref().map(str::trim) == Some("enabled") { + return WakeSupport::Enabled; + } + if let Ok(output) = Command::new("ethtool").arg(iface_name).output() && output.status.success() { @@ -147,19 +249,170 @@ fn probe_adapter_wake_support(iface_name: &str, iface_sys_path: &Path) -> WakeSu None => {} } } - let wakeup_path = iface_sys_path.join("device/power/wakeup"); - if let Ok(state) = fs::read_to_string(wakeup_path) { - return match state.trim() { - "enabled" => WakeSupport::Enabled, - "disabled" => WakeSupport::Disabled, - _ => WakeSupport::Unknown, + + match sysfs_state.as_deref().map(str::trim) { + Some("enabled") => WakeSupport::Enabled, + Some("disabled") => WakeSupport::Disabled, + _ => WakeSupport::Unknown, + } +} + +/// Parses `/proc/net/route` to find the preferred default route (`Destination == 00000000`) +/// and its IPv4 gateway address, choosing the lowest-metric `RTF_UP` entry. +pub fn parse_proc_net_route(route_table: &str) -> Option<(String, Option)> { + const RTF_UP: u32 = 0x0001; + const RTF_GATEWAY: u32 = 0x0002; + + let mut best: Option<(u32, String, Option)> = None; + for line in route_table.lines().skip(1) { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 8 { + continue; + } + let iface = fields[0]; + if is_virtual_interface_name(iface) { + continue; + } + let Ok(dest) = u32::from_str_radix(fields[1], 16) else { + continue; }; + let Ok(gw_raw) = u32::from_str_radix(fields[2], 16) else { + continue; + }; + let Ok(flags) = u32::from_str_radix(fields[3], 16) else { + continue; + }; + let metric = fields[6].parse::().unwrap_or(u32::MAX); + let Ok(mask) = u32::from_str_radix(fields[7], 16) else { + continue; + }; + if dest != 0 || mask != 0 || (flags & RTF_UP) == 0 { + continue; + } + let gateway = if (flags & RTF_GATEWAY) != 0 && gw_raw != 0 { + Some(Ipv4Addr::from(gw_raw.to_ne_bytes())) + } else { + None + }; + if best.as_ref().is_none_or(|(best_metric, _, _)| metric < *best_metric) { + best = Some((metric, iface.to_string(), gateway)); + } } - WakeSupport::Unknown + best.map(|(_, iface, gw)| (iface, gw)) +} + +/// Returns the default-route interface name and optional gateway IPv4 from `/proc/net/route`. +pub fn default_route() -> Option<(String, Option)> { + let contents = fs::read_to_string("/proc/net/route").ok()?; + parse_proc_net_route(&contents) } -fn query_ipv4_broadcasts() -> std::collections::HashMap> { - let mut map: std::collections::HashMap> = std::collections::HashMap::new(); +/// Parses `/proc/net/arp` to resolve `target_ip` to a normalized lowercase MAC (`aa:bb:cc:dd:ee:ff`). +pub fn parse_proc_net_arp(arp_table: &str, target_ip: Ipv4Addr) -> Option { + for line in arp_table.lines().skip(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 6 { + continue; + } + let Ok(ip) = cols[0].parse::() else { + continue; + }; + if ip != target_ip { + continue; + } + // Flags == 0x0 means incomplete ARP entry. + if cols[2].eq_ignore_ascii_case("0x0") { + continue; + } + if let Some(mac) = normalize_mac(cols[3]) { + return Some(mac); + } + } + None +} + +/// Parses `ip neigh` output as a fallback when `/proc/net/arp` does not contain `target_ip`. +pub fn parse_ip_neigh(output: &str, target_ip: Ipv4Addr) -> Option { + for line in output.lines() { + let tokens: Vec<&str> = line.split_whitespace().collect(); + let Some(first) = tokens.first() else { + continue; + }; + let Ok(ip) = first.parse::() else { + continue; + }; + if ip != target_ip { + continue; + } + for window in tokens.windows(2) { + if window[0] == "lladdr" + && let Some(mac) = normalize_mac(window[1]) + { + return Some(mac); + } + } + } + None +} + +/// Resolves the default gateway's MAC address in-process from `/proc/net/route` + `/proc/net/arp`, +/// falling back to `ip neigh` only if `/proc/net/arp` misses it. +pub fn query_gateway_mac() -> Option { + let (_, Some(gw_ip)) = default_route()? else { + return None; + }; + if let Ok(arp) = fs::read_to_string("/proc/net/arp") + && let Some(mac) = parse_proc_net_arp(&arp, gw_ip) + { + return Some(mac); + } + let out = Command::new("ip").args(["neigh", "show", &gw_ip.to_string()]).output().ok()?; + if !out.status.success() { + return None; + } + parse_ip_neigh(&String::from_utf8_lossy(&out.stdout), gw_ip) +} + +fn query_ipv4_broadcasts() -> HashMap> { + let mut map: HashMap> = HashMap::new(); + + // First query in-process via `getifaddrs`. + let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut(); + // SAFETY: `getifaddrs` allocates and populates a linked list freed by `freeifaddrs`. + if unsafe { libc::getifaddrs(&mut ifap) } == 0 && !ifap.is_null() { + let mut cur = ifap; + // SAFETY: walking the valid `ifaddrs` list before `freeifaddrs`. + unsafe { + while let Some(ifa) = cur.as_ref() { + cur = ifa.ifa_next; + if ifa.ifa_name.is_null() || ifa.ifa_addr.is_null() || ifa.ifa_netmask.is_null() { + continue; + } + if i32::from((*ifa.ifa_addr).sa_family) != libc::AF_INET { + continue; + } + let Ok(name) = std::ffi::CStr::from_ptr(ifa.ifa_name).to_str() else { + continue; + }; + let sin_addr = (*ifa.ifa_addr.cast::()).sin_addr.s_addr; + let sin_mask = (*ifa.ifa_netmask.cast::()).sin_addr.s_addr; + let ip = Ipv4Addr::from(u32::from_be(sin_addr)); + let prefix = u32::from_be(sin_mask).leading_ones() as u8; + if let Some(bcast) = subnet_broadcast(ip, prefix) { + let list = map.entry(name.to_string()).or_default(); + let s = bcast.to_string(); + if !list.contains(&s) { + list.push(s); + } + } + } + libc::freeifaddrs(ifap); + } + if !map.is_empty() { + return map; + } + } + let Ok(output) = Command::new("ip").args(["-4", "-o", "addr", "show"]).output() else { return map; }; @@ -190,6 +443,22 @@ fn query_ipv4_broadcasts() -> std::collections::HashMap> { map } +pub fn normalize_mac(raw: &str) -> Option { + let trimmed = raw.trim(); + let parts: Vec<&str> = trimmed.split([':', '-']).collect(); + if parts.len() != 6 { + return None; + } + let mut bytes = [0u8; 6]; + for (dst, part) in bytes.iter_mut().zip(parts) { + if part.is_empty() || part.len() > 2 { + return None; + } + *dst = u8::from_str_radix(part, 16).ok()?; + } + format_mac(&bytes) +} + pub(crate) fn format_mac(bytes: &[u8]) -> Option { if bytes.len() != 6 { return None; @@ -198,7 +467,7 @@ pub(crate) fn format_mac(bytes: &[u8]) -> Option { return None; } Some(format!( - "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5] )) } @@ -218,11 +487,27 @@ mod tests { use super::*; #[test] - fn formats_valid_mac_and_rejects_invalid() { - assert_eq!(format_mac(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]), Some("AA:BB:CC:DD:EE:FF".to_string())); + fn formats_valid_mac_lowercase_and_rejects_invalid() { + assert_eq!(format_mac(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]), Some("aa:bb:cc:dd:ee:ff".to_string())); + assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), Some("aa:bb:cc:dd:ee:ff".to_string())); + assert_eq!(normalize_mac("00-11-22-33-44-55"), Some("00:11:22:33:44:55".to_string())); assert_eq!(format_mac(&[0, 0, 0, 0, 0, 0]), None); assert_eq!(format_mac(&[0xff; 6]), None); assert_eq!(format_mac(&[1, 2, 3]), None); + assert_eq!(normalize_mac("00:00:00:00:00:00"), None); + assert_eq!(normalize_mac("ff:ff:ff:ff:ff:ff"), None); + } + + #[test] + fn excludes_virtual_and_tunnel_interface_names() { + for virt in + ["lo", "tun0", "tap0", "tailscale0", "wg0", "docker0", "veth1a2b3c", "br-123456", "virbr0"] + { + assert!(is_virtual_interface_name(virt), "expected {virt} to be excluded"); + } + for phys in ["eth0", "enp3s0", "eno1", "wlan0", "wlp2s0"] { + assert!(!is_virtual_interface_name(phys), "expected {phys} to be allowed"); + } } #[test] @@ -265,4 +550,34 @@ Settings for enp3s0: assert_eq!(parse_ethtool_wol("Settings for lo:\n\tLink detected: yes\n"), None); } + + #[test] + fn parses_proc_net_route_and_arp_tables() { + let route_table = "\ +Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT +wlan0\t00000000\t0101A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0 +enp3s0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0 +tun0\t00000000\t0100000A\t0003\t0\t0\t50\t00000000\t0\t0\t0 +"; + let (iface, gw) = parse_proc_net_route(route_table).expect("default route"); + assert_eq!(iface, "enp3s0"); + assert_eq!(gw, Some(Ipv4Addr::new(192, 168, 1, 1))); + + let arp_table = "\ +IP address HW type Flags HW address Mask Device +192.168.1.50 0x1 0x0 00:00:00:00:00:00 * enp3s0 +192.168.1.1 0x1 0x2 DC:A6:32:12:34:56 * enp3s0 +"; + assert_eq!( + parse_proc_net_arp(arp_table, Ipv4Addr::new(192, 168, 1, 1)), + Some("dc:a6:32:12:34:56".to_string()) + ); + assert_eq!(parse_proc_net_arp(arp_table, Ipv4Addr::new(192, 168, 1, 50)), None); + + let ip_neigh = "192.168.1.1 dev enp3s0 lladdr DC:A6:32:12:34:56 REACHABLE\n"; + assert_eq!( + parse_ip_neigh(ip_neigh, Ipv4Addr::new(192, 168, 1, 1)), + Some("dc:a6:32:12:34:56".to_string()) + ); + } } diff --git a/desktop/app/src/main.rs b/desktop/app/src/main.rs index 05612063..5e32713e 100644 --- a/desktop/app/src/main.rs +++ b/desktop/app/src/main.rs @@ -249,6 +249,8 @@ struct Options { /// [`UI_CHECK_SECONDS`], and fail if QML reported a warning or an error. /// Qt's messages go to stderr. For CI (`scripts/ui-check.ps1`). ui_check: bool, + /// `--version` / `-V`: print the version and exit. + version: bool, } /// How long `--ui-check` keeps the window open: long enough for the window @@ -287,6 +289,10 @@ fn parse_options_from>(iter: I) -> Op in_share = false; options.ui_check = true; } + Some("--version" | "-V") => { + in_share = false; + options.version = true; + } Some(setup::SETUP_ARG) => { in_share = false; options.setup = Some(PathBuf::new()); @@ -351,6 +357,10 @@ fn parse_options_from>(iter: I) -> Op *files = paths; // Sending doesn't need the window. options.minimized = true; + } else if share_targets.is_empty() && !paths.is_empty() { + // Desktop entry `Exec=nectarlink-desktop %U` passes URLs or dropped files + // as positional arguments without `--share`. + share_targets = paths.into_iter().map(|p| p.to_string_lossy().into_owned()).collect(); } if matches!(options.task.as_deref(), Some("send-clipboard" | "find-phone" | "take-photo")) { options.minimized = true; @@ -459,6 +469,10 @@ fn run_setup_window(mode: setup::Mode) -> ExitCode { fn main() -> ExitCode { let options = parse_options(); + if options.version { + println!("nectarlink-desktop {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } if let Some(installer) = options.setup.clone().filter(|p| !p.as_os_str().is_empty()) { return run_setup_window(setup::Mode::Install { installer }); } @@ -544,6 +558,10 @@ fn main() -> ExitCode { // Before any window: toasts and the taskbar both go by this ID. win::toast::set_process_id(); + // Logging out or shutting down sends SIGTERM: quit as Quit does, so + // phone storage is unmounted instead of left behind. + #[cfg(target_os = "linux")] + win::quit_on_signals(bridge::app::request_quit); win::jumplist::register(options.data_dir.as_deref()); if let Err(e) = core_host::start(data_dir.clone(), Arc::new(DesktopPlatform)) { tracing::error!(error = %e, "can't start the core runtime"); @@ -675,7 +693,7 @@ fn main() -> ExitCode { bridge::app::request_quit(); }); } - let code = app.as_mut().map_or(1, |app| app.exec()); + let code = if bridge::app::is_quit_requested() { 0 } else { app.as_mut().map_or(1, |app| app.exec()) }; tracing::info!(code, "shutting down"); // QML first (it holds references into Qt), then the core. @@ -764,4 +782,35 @@ mod tests { assert_eq!(opts.data_dir, Some(PathBuf::from(r"C:\Temp\nectarlink-test"))); assert!(opts.widget_provider); } + + #[test] + fn parse_options_recognizes_version_and_positional_desktop_uris() { + let ver = parse_options_from(["--version"].into_iter().map(OsString::from)); + assert!(ver.version); + + let web_opts = parse_options_from(["https://example.com"].into_iter().map(OsString::from)); + assert!(web_opts.minimized); + assert_eq!( + web_opts.share, + Some(win::share_target::SharedPayload { + files: Vec::new(), + url: Some("https://example.com".into()), + text: None, + }) + ); + + #[cfg(target_os = "linux")] + { + let uri_opts = parse_options_from(["tel:+15551234567"].into_iter().map(OsString::from)); + assert!(uri_opts.minimized); + assert_eq!( + uri_opts.share, + Some(win::share_target::SharedPayload { + files: Vec::new(), + url: Some("tel:+15551234567".into()), + text: None, + }) + ); + } + } } diff --git a/desktop/app/src/pc_mirror.rs b/desktop/app/src/pc_mirror.rs index 218b4123..e2158654 100644 --- a/desktop/app/src/pc_mirror.rs +++ b/desktop/app/src/pc_mirror.rs @@ -1101,6 +1101,10 @@ mod imp { pub fn on_event(_event: &NodeEvent) {} pub fn set_mute_while_watched(_on: bool) {} + + pub fn pipewire_capture_installed() -> bool { + false + } } pub use imp::*; diff --git a/desktop/app/src/pc_notifications.rs b/desktop/app/src/pc_notifications.rs index 80c44583..df1e1661 100644 --- a/desktop/app/src/pc_notifications.rs +++ b/desktop/app/src/pc_notifications.rs @@ -118,8 +118,13 @@ pub fn extract_notify_call(msg: &zbus::message::Message) -> Option<(String, Stri std::collections::HashMap, i32, ); - let (app_name, _replaces_id, _app_icon, summary, body, _actions, _hints, _timeout) = + let (app_name, _replaces_id, _app_icon, summary, body, _actions, hints, _timeout) = msg.body().deserialize::().ok()?; + if hints.contains_key("x-canonical-private-synchronous") + || hints.get("transient").and_then(|v| bool::try_from(v).ok()).unwrap_or(false) + { + return None; + } let app_name = app_name.trim().to_string(); let summary = summary.trim().to_string(); let body = body.trim().to_string(); diff --git a/desktop/app/src/photos.rs b/desktop/app/src/photos.rs index 531dfc05..70cc3d95 100644 --- a/desktop/app/src/photos.rs +++ b/desktop/app/src/photos.rs @@ -795,11 +795,15 @@ pub fn view() -> View { }) .collect(), ); + #[cfg(windows)] + const DEFAULT_SAVE_FOLDER: &str = r"Downloads\Nectarlink"; + #[cfg(not(windows))] + const DEFAULT_SAVE_FOLDER: &str = "Downloads/Nectarlink"; let save_folder = s .save_folder .as_deref() .map(folder_display_name) - .unwrap_or_else(|| r"Downloads\Nectarlink".to_owned()); + .unwrap_or_else(|| DEFAULT_SAVE_FOLDER.to_owned()); View { device: s.device, status: s.status, diff --git a/desktop/app/src/send_to.rs b/desktop/app/src/send_to.rs index 7082ef4a..da1e7bc2 100644 --- a/desktop/app/src/send_to.rs +++ b/desktop/app/src/send_to.rs @@ -119,10 +119,13 @@ fn sync() { /// Removes this app's shortcuts (when uninstalling). pub fn remove_all() -> io::Result<()> { - match (shortcut::send_to_dir(), std::env::current_exe()) { + let res = match (shortcut::send_to_dir(), std::env::current_exe()) { (Some(dir), Ok(exe)) => reconcile(&dir, &[], &exe), _ => Ok(()), - } + }; + #[cfg(target_os = "linux")] + let _ = shortcut::remove_all_file_manager_menus(); + res } /// Makes `dir` hold exactly one shortcut per device in `desired` (named diff --git a/desktop/app/src/settings.rs b/desktop/app/src/settings.rs index abaf4527..9498d198 100644 --- a/desktop/app/src/settings.rs +++ b/desktop/app/src/settings.rs @@ -643,6 +643,8 @@ impl Settings { /// Saves atomically: a crash mid-write never leaves a half-written file. pub fn save(&self, dir: &Path) -> std::io::Result<()> { + static SAVE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = SAVE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let path = Self::path(dir); let tmp = path.with_extension("json.tmp"); { diff --git a/desktop/packaging/linux/build-appimage.sh b/desktop/packaging/linux/build-appimage.sh index 665f8cf3..2bfb9943 100755 --- a/desktop/packaging/linux/build-appimage.sh +++ b/desktop/packaging/linux/build-appimage.sh @@ -155,6 +155,7 @@ cat > "${APPDIR}/AppRun" <<'EOF' #!/usr/bin/env sh HERE="$(dirname "$(readlink -f "$0")")" export PATH="${HERE}/usr/bin:${PATH}" +export LD_LIBRARY_PATH="${HERE}/usr/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" export QT_PLUGIN_PATH="${HERE}/usr/plugins${QT_PLUGIN_PATH:+:${QT_PLUGIN_PATH}}" export QML2_IMPORT_PATH="${HERE}/usr/qml${QML2_IMPORT_PATH:+:${QML2_IMPORT_PATH}}" exec "${HERE}/usr/bin/nectarlink-desktop" "$@" @@ -165,10 +166,15 @@ OUT_DIR="${OUT_DIR:-${REPO_ROOT}/dist}" mkdir -p "${OUT_DIR}" OUT_APPIMAGE="${OUT_DIR}/Nectarlink-${VERSION}-${ARCH}.AppImage" -if command -v appimagetool >/dev/null 2>&1; then - ARCH="${ARCH}" appimagetool "${APPDIR}" "${OUT_APPIMAGE}" - echo "Built ${OUT_APPIMAGE}" -else +if ! command -v appimagetool >/dev/null 2>&1; then echo "appimagetool not found in PATH; AppDir staged at ${APPDIR}" >&2 - exit 0 + exit 1 +fi +# APPIMAGE_RUNTIME: a runtime file checked beforehand (the release workflow +# pins one). Without it appimagetool downloads the latest runtime itself. +runtime_args=() +if [[ -n "${APPIMAGE_RUNTIME:-}" ]]; then + runtime_args=(--runtime-file "${APPIMAGE_RUNTIME}") fi +ARCH="${ARCH}" appimagetool "${runtime_args[@]}" "${APPDIR}" "${OUT_APPIMAGE}" +echo "Built ${OUT_APPIMAGE}" diff --git a/desktop/packaging/linux/build-deb.sh b/desktop/packaging/linux/build-deb.sh index f81790b5..283faf81 100755 --- a/desktop/packaging/linux/build-deb.sh +++ b/desktop/packaging/linux/build-deb.sh @@ -54,7 +54,16 @@ mkdir -p \ "${PKG_ROOT}/usr/share/icons/hicolor/scalable/apps" install -m 0755 "${BIN_PATH}" "${PKG_ROOT}/usr/lib/nectarlink/nectarlink-desktop" -ln -sf ../lib/nectarlink/nectarlink-desktop "${PKG_ROOT}/usr/bin/nectarlink-desktop" +cat > "${PKG_ROOT}/usr/bin/nectarlink-desktop" <<'EOF' +#!/usr/bin/env sh +if [ -d /usr/lib/nectarlink/lib ]; then + export LD_LIBRARY_PATH="/usr/lib/nectarlink/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" + export QT_PLUGIN_PATH="/usr/lib/nectarlink/plugins${QT_PLUGIN_PATH:+:${QT_PLUGIN_PATH}}" + export QML2_IMPORT_PATH="/usr/lib/nectarlink/qml${QML2_IMPORT_PATH:+:${QML2_IMPORT_PATH}}" +fi +exec /usr/lib/nectarlink/nectarlink-desktop "$@" +EOF +chmod 0755 "${PKG_ROOT}/usr/bin/nectarlink-desktop" for qm in "$(dirname "${BIN_PATH}")"/nectarlink_*.qm; do if [[ -f "${qm}" ]]; then @@ -77,7 +86,7 @@ QT_DEPENDS="" bundle_private_qt_if_needed() { local qmake_bin="${QMAKE:-}" if [[ -z "${qmake_bin}" || ! -x "${qmake_bin}" ]]; then - QT_DEPENDS=", libqt6core6t64 | libqt6core6, libqt6gui6, libqt6widgets6, libqt6qml6, libqt6quick6, libqt6network6, libqt6opengl6, libqt6dbus6, libqt6svg6, qml6-module-qtquick, qml6-module-qtquick-Window, qml6-module-qtquick-controls, qml6-module-qtquick-layouts, qml6-module-qtqml-workerscript" + QT_DEPENDS=", libqt6core6t64 | libqt6core6, libqt6gui6, libqt6widgets6, libqt6qml6, libqt6quick6, libqt6network6, libqt6opengl6, libqt6dbus6, libqt6svg6, qml6-module-qtquick, qml6-module-qtquick-window, qml6-module-qtquick-controls, qml6-module-qtquick-layouts, qml6-module-qtqml-workerscript" return 0 fi local qt_libs qt_plugins qt_qml @@ -165,7 +174,7 @@ Priority: optional Architecture: ${DEB_ARCH} Installed-Size: ${INSTALLED_SIZE} Depends: libc6, libgcc-s1, libstdc++6, libgl1, libx11-6, libxcb1, libxkbcommon0, libwayland-client0, libfontconfig1, libfreetype6, libdbus-1-3${QT_DEPENDS} -Recommends: ffmpeg, libopenh264-7 | libopenh264-6, pulseaudio-utils, pipewire, wl-clipboard, xclip, xdotool, xdg-utils, network-manager, avahi-daemon, bluez, v4l2loopback-dkms, policykit-1 | pkexec +Recommends: fuse3, curl | wget, ffmpeg, gstreamer1.0-tools, gstreamer1.0-pipewire, gstreamer1.0-plugins-base, libopenh264-8 | libopenh264-7 | libopenh264-6 | libavcodec62 | libavcodec61 | libavcodec60, pipewire-bin | pulseaudio-utils, pipewire, wl-clipboard, xclip, xdg-utils, avahi-daemon, bluez, v4l2loopback-dkms, policykit-1 | pkexec Maintainer: thtbee Homepage: https://github.com/thtbee/nectarlink Description: Connect your Android phone and Linux PC over LAN diff --git a/desktop/packaging/linux/build-tarball.sh b/desktop/packaging/linux/build-tarball.sh index 41f1d3eb..c9946503 100755 --- a/desktop/packaging/linux/build-tarball.sh +++ b/desktop/packaging/linux/build-tarball.sh @@ -78,6 +78,7 @@ bundle_private_qt_if_needed() { for pattern in \ "libQt6Core.so*" \ "libQt6Gui.so*" \ + "libQt6Widgets.so*" \ "libQt6Qml*.so*" \ "libQt6Quick*.so*" \ "libQt6Network.so*" \ @@ -170,7 +171,18 @@ done for qm in "${SELF_DIR}"/nectarlink_*.qm; do [[ -f "${qm}" ]] && install -m 0644 "${qm}" "${PREFIX}/$(basename "${qm}")" done -ln -sf "${PREFIX}/nectarlink-desktop" "${BIN_LINK}" +if [[ -d "${PREFIX}/lib" ]]; then + cat > "${BIN_LINK}" <
  • Sync clipboard text and images bidirectionally
  • Mirror phone notifications and reply inline from your desktop
  • -
  • Send and receive files, folders, and browsable phone storage via SFTP
  • +
  • Send and receive files, folders, and browsable phone storage via FUSE
  • Stream phone screen, camera, and audio with low latency
  • Read and send SMS/MMS messages and manage phone calls
  • @@ -25,6 +25,7 @@ https://github.com/thtbee/nectarlink/issues nectarlink-desktop + x-scheme-handler/nectarlink x-scheme-handler/tel x-scheme-handler/sms x-scheme-handler/smsto diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index b3975bde..d8af7a54 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -9,8 +9,8 @@ NectarLink includes a built-in diagnostic tool on Windows that automatically det 1. On your PC, open **Settings › Connection & About**. 2. Next to **Connection Doctor**, click **Check**. 3. The `Connection Doctor` inspects four things and shows plain-language results with action buttons: - - **Windows Firewall**: Checks whether `nectarlink-desktop.exe` is allowed to receive incoming UDP (QUIC) and mDNS packets. If a firewall rule is missing or blocked, click **Fix** to register the rule (requires Administrator approval). - - **Windows Network Profile (`Public` vs. `Private`)**: Windows 11 often marks home Wi-Fi networks as **Public** by default, which blocks local device discovery and incoming connections. If your home Wi-Fi is set to **Public**, switch it to **Private** in **Windows Settings › Network & internet › Wi-Fi › `` properties › Network profile type**. + - **Windows or Linux Firewall**: Checks whether `nectarlink-desktop` is allowed to receive incoming UDP (QUIC) and mDNS packets (Windows Firewall on Windows, or `ufw` / `firewalld` on Linux). If a firewall rule is missing or blocked, click **Fix** to register the rule (requires Administrator / Polkit approval). + - **Network Profile and Local Discovery (`Public` vs. `Private` on Windows, `avahi-daemon` on Linux)**: Windows 11 often marks home Wi-Fi networks as **Public** by default, which blocks local device discovery and incoming connections. If your home Wi-Fi is set to **Public**, switch it to **Private** in **Windows Settings › Network & internet › Wi-Fi › `` properties › Network profile type**. On Linux, Connection Doctor also checks that `avahi-daemon` (mDNS), `fusermount3` (phone storage mount), and PipeWire / PulseAudio are available. - **Active VPN Interfaces**: Full-tunnel corporate or privacy VPNs often route local subnet traffic (`192.168.x.x` / `10.x.x.x`) into the VPN tunnel so your PC and phone cannot reach each other directly. Enable **Split tunneling** (or "Allow LAN traffic") in your VPN client's settings, or connect your phone over a USB cable (`On USB`), or turn on **Connect over mobile data** on both devices. - **Suspected Wi-Fi AP Isolation (Guest / Campus Wi-Fi)**: If your PC can hear your phone's multicast mDNS broadcasts (seen within the last 2 minutes) but every direct QUIC connection attempt times out, your Wi-Fi router has **AP Isolation** (Client Isolation) turned on. AP Isolation is common on hotel, campus, cafe, and "Guest" Wi-Fi networks. To connect on an isolated Wi-Fi network, either switch both devices to your main home Wi-Fi SSID, plug in a USB cable, or turn on **Connect over mobile data** on both devices.