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 01/15] 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 02/15] 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 03/15] 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 04/15] 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 426360fe5132a58c491c11e4124692ee6fe59691 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:28:52 +0400 Subject: [PATCH 05/15] feat(linux): input injection, deck actions, and window capture (Part 3) 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/cpp/native_window.cpp | 267 +++++- desktop/app/cpp/native_window.h | 1 + desktop/app/i18n/nectarlink_de.ts | 5 + desktop/app/i18n/nectarlink_zh_CN.ts | 5 + desktop/app/i18n/rust_strings.h | 1 + desktop/app/qml/components/CaptionButtons.qml | 2 +- desktop/app/src/core_host.rs | 19 +- desktop/app/src/deck.rs | 23 +- desktop/app/src/linux/input.rs | 894 +++++++++++++++++- desktop/app/src/linux/mod.rs | 1 + desktop/app/src/linux/window_capture.rs | 371 ++++++++ 11 files changed, 1555 insertions(+), 34 deletions(-) create mode 100644 desktop/app/src/linux/window_capture.rs diff --git a/desktop/app/cpp/native_window.cpp b/desktop/app/cpp/native_window.cpp index 1731550b..6fa3313c 100644 --- a/desktop/app/cpp/native_window.cpp +++ b/desktop/app/cpp/native_window.cpp @@ -15,9 +15,16 @@ #include #include #include +#elif defined(__linux__) +#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 +56,251 @@ void trimD3DResources(QQuickWindow *window) dxgi3->Release(); } } - -} // namespace #else -namespace { -void trimD3DResources(QQuickWindow * /*window*/) {} -} // namespace +void trimD3DResources(QQuickWindow *) +{ +} #endif +#ifdef __linux__ +void activateX11Window(WId winId) +{ + if (!winId || qEnvironmentVariableIsEmpty("DISPLAY")) + return; + + void *lib = dlopen("libX11.so.6", RTLD_LAZY | RTLD_LOCAL); + if (!lib) + return; + + 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 XFlushFn = int (*)(void *); + + 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 xFlush = reinterpret_cast(dlsym(lib, "XFlush")); + + if (xOpenDisplay && xCloseDisplay && xDefaultRootWindow && xInternAtom && xSendEvent && xFlush) { + if (void *dpy = xOpenDisplay(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 */); + + xFlush(dpy); + 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) { + const Qt::Edges edges = hitResizeEdges(me->position()); + updateCursorForEdges(edges); + } + break; + } + case QEvent::MouseButtonPress: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + 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 && !hitInteractiveControl(window_->contentItem(), pos)) { + window_->startSystemMove(); + return true; + } + } + break; + } + case QEvent::MouseButtonDblClick: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + const QPointF pos = me->position(); + const Qt::Edges edges = hitResizeEdges(pos); + const qreal titleBarHeight = window_->captionHeight(); + if (edges == Qt::Edges() && pos.y() < titleBarHeight + && !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() && 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; +}; +#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 +357,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()); @@ -169,14 +405,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 @@ -237,6 +476,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 } @@ -348,10 +595,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..72f6a8a4 100644 --- a/desktop/app/cpp/native_window.h +++ b/desktop/app/cpp/native_window.h @@ -72,6 +72,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..962b83c4 100644 --- a/desktop/app/i18n/nectarlink_de.ts +++ b/desktop/app/i18n/nectarlink_de.ts @@ -1404,6 +1404,11 @@ + Enter an application path (/usr/bin/..., .desktop, or .AppImage). + Gib einen Anwendungspfad ein (/usr/bin/…, .desktop oder .AppImage). + + + Enter an http:// or https:// web address. Gib eine Webadresse mit http:// oder https:// ein. diff --git a/desktop/app/i18n/nectarlink_zh_CN.ts b/desktop/app/i18n/nectarlink_zh_CN.ts index 5b130adc..f33756c7 100644 --- a/desktop/app/i18n/nectarlink_zh_CN.ts +++ b/desktop/app/i18n/nectarlink_zh_CN.ts @@ -1404,6 +1404,11 @@ + Enter an application path (/usr/bin/..., .desktop, or .AppImage). + 请输入应用路径(/usr/bin/...、.desktop 或 .AppImage)。 + + + Enter an http:// or https:// web address. 请输入以 http:// 或 https:// 开头的网址。 diff --git a/desktop/app/i18n/rust_strings.h b/desktop/app/i18n/rust_strings.h index 29ee72c1..482f0121 100644 --- a/desktop/app/i18n/rust_strings.h +++ b/desktop/app/i18n/rust_strings.h @@ -170,6 +170,7 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Deck", "Choose a key for the shortcut."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Choose an application (.exe or .lnk) on this PC."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter a command to run on this PC."), // desktop/app/src/deck.rs + QT_TRANSLATE_NOOP("Deck", "Enter an application path (/usr/bin/..., .desktop, or .AppImage)."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter an http:// or https:// web address."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter the text snippet to type."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Invalid action configuration."), // desktop/app/src/deck.rs diff --git a/desktop/app/qml/components/CaptionButtons.qml b/desktop/app/qml/components/CaptionButtons.qml index 25f677b9..3f6c6fb6 100644 --- a/desktop/app/qml/components/CaptionButtons.qml +++ b/desktop/app/qml/components/CaptionButtons.qml @@ -12,7 +12,7 @@ Row { required property NativeWindow window readonly property bool maximized: window.visibility === Window.Maximized - visible: Qt.platform.os === "windows" + visible: Qt.platform.os !== "osx" width: visible ? implicitWidth : 0 // As tall as the top bar, like Windows 11's tall title bars, so the glyphs diff --git a/desktop/app/src/core_host.rs b/desktop/app/src/core_host.rs index a150d274..deb88c74 100644 --- a/desktop/app/src/core_host.rs +++ b/desktop/app/src/core_host.rs @@ -101,7 +101,7 @@ pub fn start(data_dir: PathBuf, platform: Arc) -> std::io::Result< /// and input injection (`mirror.input`, `input.inject`) only when enabled for at /// least one paired device (or actively sharing). pub fn compute_capabilities( - #[cfg_attr(not(windows), allow(unused_variables))] node: Option<&Node>, + #[cfg_attr(not(any(windows, target_os = "linux")), allow(unused_variables))] node: Option<&Node>, ) -> Vec { let mut caps = vec![ "pc.power".into(), @@ -113,7 +113,10 @@ pub fn compute_capabilities( nectarlink_core::TOGGLES_SHOW.into(), ]; #[cfg(any(windows, target_os = "linux"))] - caps.push(nectarlink_core::PC_WAKE.into()); + { + caps.push(nectarlink_core::PC_WAKE.into()); + caps.push(nectarlink_core::DECK_ACTIONS.into()); + } #[cfg(windows)] { caps.push("media.control".into()); @@ -122,7 +125,6 @@ pub fn compute_capabilities( caps.push(nectarlink_core::STORAGE_WRITE.into()); caps.push(nectarlink_core::MIRROR_VIEW.into()); caps.push(nectarlink_core::MIRROR_LISTEN.into()); - caps.push(nectarlink_core::DECK_ACTIONS.into()); caps.push(nectarlink_core::PC_AUDIO.into()); caps.push(nectarlink_core::RECORDER.into()); caps.push(nectarlink_core::WEBCAM_VIRTUAL.into()); @@ -131,15 +133,16 @@ pub fn compute_capabilities( } #[cfg(windows)] let mut pc_mirror_enabled = crate::pc_mirror::is_active(); - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] let mut remote_input_enabled = false; - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] if let Some(node) = node && let Ok(devices) = node.paired_devices() { for d in devices { if let Ok(toggles) = node.device_toggles(d.id) { for (name, on) in toggles { + #[cfg(windows)] if on && name == nectarlink_core::PC_MIRROR_TOGGLE { pc_mirror_enabled = true; } @@ -155,7 +158,11 @@ pub fn compute_capabilities( caps.push(nectarlink_core::MIRROR_CAPTURE.into()); } #[cfg(windows)] - if remote_input_enabled { + let input_available = true; + #[cfg(target_os = "linux")] + let input_available = crate::win::input::is_available(); + #[cfg(any(windows, target_os = "linux"))] + if remote_input_enabled && input_available { caps.push(nectarlink_core::MIRROR_INPUT.into()); caps.push(nectarlink_core::INPUT_INJECT.into()); } diff --git a/desktop/app/src/deck.rs b/desktop/app/src/deck.rs index a969cafb..42ebaa9a 100644 --- a/desktop/app/src/deck.rs +++ b/desktop/app/src/deck.rs @@ -321,7 +321,14 @@ fn execute_action(action: &DeckAction) -> Result<(), String> { Ok(()) } DeckAction::Screenshot => { - win::input::press_key("s", &[KeyMod::Win, KeyMod::Shift]); + #[cfg(target_os = "linux")] + { + let _ = win::shell::take_screenshot(); + } + #[cfg(not(target_os = "linux"))] + { + win::input::press_key("s", &[KeyMod::Win, KeyMod::Shift]); + } Ok(()) } DeckAction::Shortcut { key, mods } => { @@ -512,7 +519,13 @@ pub fn build_action( }; if !action.is_valid() { return Err(match kind { - deck_kinds::LAUNCH_APP => tr!("Deck", "Application path must end with .exe or .lnk."), + deck_kinds::LAUNCH_APP => { + if cfg!(target_os = "linux") { + tr!("Deck", "Enter an application path (/usr/bin/..., .desktop, or .AppImage).") + } else { + tr!("Deck", "Application path must end with .exe or .lnk.") + } + } deck_kinds::OPEN_URL => tr!("Deck", "Web address must start with http:// or https://."), deck_kinds::SHORTCUT => { tr!("Deck", "Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.).") @@ -617,6 +630,12 @@ mod tests { assert_eq!(app, DeckAction::LaunchApp { path: "C:\\Windows\\System32\\calc.exe".into() }); assert!(build_action("launch_app", "C:\\script.bat", false, false, false, false).is_err()); + let linux_bin = build_action("launch_app", "/usr/bin/firefox", false, false, false, false).unwrap(); + assert_eq!(linux_bin, DeckAction::LaunchApp { path: "/usr/bin/firefox".into() }); + let linux_desktop = + build_action("launch_app", "org.mozilla.firefox.desktop", false, false, false, false).unwrap(); + assert_eq!(linux_desktop, DeckAction::LaunchApp { path: "org.mozilla.firefox.desktop".into() }); + let cmd = build_action("run_command", " echo hello ", false, false, false, false).unwrap(); assert_eq!(cmd, DeckAction::RunCommand { command: "echo hello".into() }); } diff --git a/desktop/app/src/linux/input.rs b/desktop/app/src/linux/input.rs index 9396fb0f..25fd8e45 100644 --- a/desktop/app/src/linux/input.rs +++ b/desktop/app/src/linux/input.rs @@ -1,20 +1,888 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Synthesizes mouse and keyboard input on Linux for a paired phone acting as +//! a touchpad, keyboard, or presentation remote (`docs/protocol/remote.md`), +//! as well as reverse PC mirror touch injection. +//! +//! On X11 / XWayland (`DISPLAY` set), pointer and button events are injected +//! in-process through a persistent `libX11.so.6` + `libXtst.so.6` (`XTest`) +//! display connection so 60 Hz mouse/touch streams do not fork a child process +//! per event. When `libXtst.so.6` is unavailable or running on pure Wayland, +//! falls back to `ydotool` / `wtype` / `xdotool`. -use nectarlink_core::{ButtonAction, KeyMod, MouseButton, SlideAction, TouchAction}; +use std::{ + ffi::{CStr, CString, c_char, c_int, c_uint, c_ulong, c_void}, + process::{Command, Stdio}, + sync::{Mutex, OnceLock}, +}; +use nectarlink_core::{ButtonAction, KeyMod, MouseButton, PowerAction, SlideAction, TouchAction}; + +struct X11TestApi { + _x11_lib: *mut c_void, + _xtst_lib: *mut c_void, + open_display: unsafe extern "C" fn(*const c_char) -> *mut c_void, + flush: unsafe extern "C" fn(*mut c_void) -> c_int, + string_to_keysym: unsafe extern "C" fn(*const c_char) -> c_ulong, + keysym_to_keycode: unsafe extern "C" fn(*mut c_void, c_ulong) -> u8, + query_extension: + unsafe extern "C" fn(*mut c_void, *mut c_int, *mut c_int, *mut c_int, *mut c_int) -> c_int, + fake_motion: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_ulong) -> c_int, + fake_relative_motion: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_ulong) -> c_int, + fake_button: unsafe extern "C" fn(*mut c_void, c_uint, c_int, c_ulong) -> c_int, + fake_key: unsafe extern "C" fn(*mut c_void, c_uint, c_int, c_ulong) -> c_int, +} + +// SAFETY: Function pointers loaded from libX11.so.6 and libXtst.so.6 are process-wide. +unsafe impl Send for X11TestApi {} +unsafe impl Sync for X11TestApi {} + +struct X11Display(*mut c_void); + +// SAFETY: Access to the Display pointer is serialized through a Mutex. +unsafe impl Send for X11Display {} + +unsafe fn load_sym(handle: *mut c_void, name: &CStr) -> Option { + let raw = unsafe { libc::dlsym(handle, name.as_ptr()) }; + if raw.is_null() { + return None; + } + Some(unsafe { std::mem::transmute_copy(&raw) }) +} + +fn x11_test_api() -> Option<&'static X11TestApi> { + static API: OnceLock> = OnceLock::new(); + API.get_or_init(|| unsafe { + let x11_lib = libc::dlopen(c"libX11.so.6".as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL); + if x11_lib.is_null() { + return None; + } + let xtst_lib = libc::dlopen(c"libXtst.so.6".as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL); + if xtst_lib.is_null() { + let _ = libc::dlclose(x11_lib); + return None; + } + let api = X11TestApi { + _x11_lib: x11_lib, + _xtst_lib: xtst_lib, + open_display: load_sym(x11_lib, c"XOpenDisplay")?, + flush: load_sym(x11_lib, c"XFlush")?, + string_to_keysym: load_sym(x11_lib, c"XStringToKeysym")?, + keysym_to_keycode: load_sym(x11_lib, c"XKeysymToKeycode")?, + query_extension: load_sym(xtst_lib, c"XTestQueryExtension")?, + fake_motion: load_sym(xtst_lib, c"XTestFakeMotionEvent")?, + fake_relative_motion: load_sym(xtst_lib, c"XTestFakeRelativeMotionEvent")?, + fake_button: load_sym(xtst_lib, c"XTestFakeButtonEvent")?, + fake_key: load_sym(xtst_lib, c"XTestFakeKeyEvent")?, + }; + Some(api) + }) + .as_ref() +} + +fn with_xtest(f: F) -> bool { + if std::env::var_os("DISPLAY").is_none() { + return false; + } + let Some(api) = x11_test_api() else { + return false; + }; + static DISPLAY_CONN: OnceLock>> = OnceLock::new(); + let mutex = DISPLAY_CONN.get_or_init(|| Mutex::new(None)); + let mut guard = mutex.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_none() { + // SAFETY: XOpenDisplay(NULL) connects to $DISPLAY; XTestQueryExtension checks XTEST support. + let dpy = unsafe { (api.open_display)(std::ptr::null()) }; + if dpy.is_null() { + return false; + } + let mut ev = 0; + let mut err = 0; + let mut maj = 0; + let mut min = 0; + let ok = unsafe { (api.query_extension)(dpy, &mut ev, &mut err, &mut maj, &mut min) }; + if ok == 0 { + return false; + } + *guard = Some(X11Display(dpy)); + } + if let Some(X11Display(dpy)) = *guard { + f(api, dpy); + // SAFETY: dpy is a valid open X11 Display pointer protected by DISPLAY_CONN mutex. + unsafe { + let _ = (api.flush)(dpy); + } + true + } else { + false + } +} + +/// Returns `true` when input synthesis is supported in the current desktop session. pub fn is_available() -> bool { + if has_wayland_display() && (has_executable_in_path("ydotool") || has_executable_in_path("wtype")) { + return true; + } + if has_x11_display() && (x11_test_api().is_some() || has_executable_in_path("xdotool")) { + return true; + } false } -pub fn move_relative(_dx: f32, _dy: f32) {} -pub fn move_absolute(_x: f32, _y: f32) {} -pub fn mirror_touch(_action: TouchAction, _x: f32, _y: f32) {} -pub fn mouse_button(_button: MouseButton, _action: ButtonAction) {} -pub fn release_held_buttons() {} -pub fn scroll(_dx: f32, _dy: f32) {} -pub fn type_text(_text: &str) {} -pub fn press_key(_key: &str, _mods: &[KeyMod]) {} -pub fn slide(_action: SlideAction) {} -pub fn is_user_active_within_ms(_max_idle_ms: u32) -> bool { - false + +fn has_executable_in_path(binary: &str) -> bool { + std::env::var_os("PATH") + .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(binary).is_file())) +} + +/// Fractional remainders for sub-pixel pointer motion and sub-notch scrolling, +/// plus which mouse buttons are currently held down by remote input. +#[derive(Default, Debug, Clone, PartialEq)] +struct InputState { + move_rem_x: f32, + move_rem_y: f32, + scroll_rem_x: f32, + scroll_rem_y: f32, + held_left: bool, + held_right: bool, + held_middle: bool, +} + +impl InputState { + fn accumulate_move(&mut self, dx: f32, dy: f32) -> (i32, i32) { + let total_x = self.move_rem_x + dx; + let total_y = self.move_rem_y + dy; + let ix = total_x.round() as i32; + let iy = total_y.round() as i32; + self.move_rem_x = total_x - (ix as f32); + self.move_rem_y = total_y - (iy as f32); + (ix, iy) + } + + fn accumulate_scroll(&mut self, dx: f32, dy: f32) -> (i32, i32) { + let total_x = self.scroll_rem_x + dx; + let total_y = self.scroll_rem_y + dy; + let nx = total_x.round() as i32; + let ny = total_y.round() as i32; + self.scroll_rem_x = total_x - (nx as f32); + self.scroll_rem_y = total_y - (ny as f32); + (nx, ny) + } + + fn record_button(&mut self, button: MouseButton, action: ButtonAction) { + let slot = match button { + MouseButton::Left => &mut self.held_left, + MouseButton::Right => &mut self.held_right, + MouseButton::Middle => &mut self.held_middle, + }; + *slot = matches!(action, ButtonAction::Down); + } + + fn take_held_and_reset(&mut self) -> (bool, bool, bool) { + let held = (self.held_left, self.held_right, self.held_middle); + self.held_left = false; + self.held_right = false; + self.held_middle = false; + self.move_rem_x = 0.0; + self.move_rem_y = 0.0; + self.scroll_rem_x = 0.0; + self.scroll_rem_y = 0.0; + held + } +} + +static STATE: Mutex = Mutex::new(InputState { + move_rem_x: 0.0, + move_rem_y: 0.0, + scroll_rem_x: 0.0, + scroll_rem_y: 0.0, + held_left: false, + held_right: false, + held_middle: false, +}); + +fn has_display_env() -> bool { + std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some() +} + +fn has_wayland_display() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) +} + +fn has_x11_display() -> bool { + std::env::var_os("DISPLAY").is_some_and(|v| !v.is_empty()) +} + +fn run_quiet(program: &str, args: &[&str]) -> bool { + if !has_display_env() { + return false; + } + Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +fn display_dimensions() -> (i32, i32) { + if let Some((x, y, w, h)) = super::work_area() { + let total_w = (x.max(0) + w).max(1); + let total_h = (y.max(0) + h).max(1); + if total_w > 0 && total_h > 0 { + return (total_w, total_h); + } + } + if has_display_env() + && let Ok(out) = Command::new("xdotool") + .arg("getdisplaygeometry") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + && out.status.success() + { + let text = String::from_utf8_lossy(&out.stdout); + let mut parts = text.split_whitespace().filter_map(|s| s.parse::().ok()); + if let (Some(w), Some(h)) = (parts.next(), parts.next()) + && w > 0 + && h > 0 + { + return (w, h); + } + } + (1920, 1080) +} + +fn normalized_to_pixels(x: f32, y: f32, width: i32, height: i32) -> (i32, i32) { + let max_x = (width - 1).max(1) as f32; + let max_y = (height - 1).max(1) as f32; + let px = (x.clamp(0.0, 1.0) * max_x).round() as i32; + let py = (y.clamp(0.0, 1.0) * max_y).round() as i32; + (px, py) +} + +/// Moves the mouse cursor relatively by `(dx, dy)` pixels, keeping sub-pixel +/// fractions for the next move. +pub fn move_relative(dx: f32, dy: f32) { + if !dx.is_finite() || !dy.is_finite() { + return; + } + let (ix, iy) = { + let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner()); + s.accumulate_move(dx, dy) + }; + if ix == 0 && iy == 0 { + return; + } + let sx = ix.to_string(); + let sy = iy.to_string(); + if has_wayland_display() && run_quiet("ydotool", &["mousemove", "-x", &sx, "-y", &sy]) { + return; + } + if with_xtest(|api, dpy| unsafe { + let _ = (api.fake_relative_motion)(dpy, ix, iy, 0); + }) { + return; + } + if !run_quiet("xdotool", &["mousemove_relative", "--", &sx, &sy]) { + let _ = run_quiet("ydotool", &["mousemove", "-x", &sx, "-y", &sy]); + } +} + +/// Moves the mouse cursor to normalized coordinates `(x, y)` on the primary +/// display (`0.0..=1.0`). +pub fn move_absolute(x: f32, y: f32) { + if !x.is_finite() || !y.is_finite() { + return; + } + let (w, h) = display_dimensions(); + let (px, py) = normalized_to_pixels(x, y, w, h); + let sx = px.to_string(); + let sy = py.to_string(); + if has_wayland_display() && run_quiet("ydotool", &["mousemove", "--absolute", "-x", &sx, "-y", &sy]) { + return; + } + if with_xtest(|api, dpy| unsafe { + let _ = (api.fake_motion)(dpy, -1, px, py, 0); + }) { + return; + } + if !run_quiet("xdotool", &["mousemove", "--", &sx, &sy]) { + let _ = run_quiet("ydotool", &["mousemove", "--absolute", "-x", &sx, "-y", &sy]); + } +} + +/// Injects a normalized touch event (`0.0..=1.0`) from reverse PC mirroring as +/// absolute mouse movement + left button press/release. +pub fn mirror_touch(action: TouchAction, x: f32, y: f32) { + if !x.is_finite() || !y.is_finite() { + return; + } + let (w, h) = display_dimensions(); + let (px, py) = normalized_to_pixels(x, y, w, h); + let sx = px.to_string(); + let sy = py.to_string(); + + match action { + TouchAction::Down => { + if let Ok(mut s) = STATE.lock() { + s.held_left = true; + } + if !has_wayland_display() + && with_xtest(|api, dpy| unsafe { + let _ = (api.fake_motion)(dpy, -1, px, py, 0); + let _ = (api.fake_button)(dpy, 1, 1, 0); + }) + { + return; + } + if has_wayland_display() + || !run_quiet("xdotool", &["mousemove", "--", &sx, &sy, "mousedown", "1"]) + { + move_absolute(x, y); + mouse_button(MouseButton::Left, ButtonAction::Down); + } + } + TouchAction::Move => { + move_absolute(x, y); + } + TouchAction::Up => { + if let Ok(mut s) = STATE.lock() { + s.held_left = false; + } + if !has_wayland_display() + && with_xtest(|api, dpy| unsafe { + let _ = (api.fake_motion)(dpy, -1, px, py, 0); + let _ = (api.fake_button)(dpy, 1, 0, 0); + }) + { + return; + } + if has_wayland_display() || !run_quiet("xdotool", &["mousemove", "--", &sx, &sy, "mouseup", "1"]) + { + move_absolute(x, y); + mouse_button(MouseButton::Left, ButtonAction::Up); + } + } + } +} + +fn x11_button_num(button: MouseButton) -> c_uint { + match button { + MouseButton::Left => 1, + MouseButton::Middle => 2, + MouseButton::Right => 3, + } +} + +fn xdotool_button_id(button: MouseButton) -> &'static str { + match button { + MouseButton::Left => "1", + MouseButton::Middle => "2", + MouseButton::Right => "3", + } +} + +/// Presses, releases or clicks a mouse button. +pub fn mouse_button(button: MouseButton, action: ButtonAction) { + { + let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner()); + s.record_button(button, action); + } + let ydo_code = match (button, action) { + (MouseButton::Left, ButtonAction::Click) => "0xC0", + (MouseButton::Left, ButtonAction::Down) => "0x40", + (MouseButton::Left, ButtonAction::Up) => "0x80", + (MouseButton::Right, ButtonAction::Click) => "0xC1", + (MouseButton::Right, ButtonAction::Down) => "0x41", + (MouseButton::Right, ButtonAction::Up) => "0x81", + (MouseButton::Middle, ButtonAction::Click) => "0xC2", + (MouseButton::Middle, ButtonAction::Down) => "0x42", + (MouseButton::Middle, ButtonAction::Up) => "0x82", + }; + if has_wayland_display() && run_quiet("ydotool", &["click", ydo_code]) { + return; + } + let btn_num = x11_button_num(button); + if with_xtest(|api, dpy| unsafe { + match action { + ButtonAction::Click => { + let _ = (api.fake_button)(dpy, btn_num, 1, 0); + let _ = (api.fake_button)(dpy, btn_num, 0, 0); + } + ButtonAction::Down => { + let _ = (api.fake_button)(dpy, btn_num, 1, 0); + } + ButtonAction::Up => { + let _ = (api.fake_button)(dpy, btn_num, 0, 0); + } + } + }) { + return; + } + let btn = xdotool_button_id(button); + let subcmd = match action { + ButtonAction::Click => "click", + ButtonAction::Down => "mousedown", + ButtonAction::Up => "mouseup", + }; + if !run_quiet("xdotool", &[subcmd, btn]) { + let _ = run_quiet("ydotool", &["click", ydo_code]); + } +} + +/// Releases any mouse buttons still held down by remote input. +pub fn release_held_buttons() { + let (left, right, middle) = { + let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner()); + s.take_held_and_reset() + }; + if !left && !right && !middle { + return; + } + if has_wayland_display() { + let mut handled = true; + if left { + handled &= run_quiet("ydotool", &["click", "0x80"]); + } + if middle { + handled &= run_quiet("ydotool", &["click", "0x82"]); + } + if right { + handled &= run_quiet("ydotool", &["click", "0x81"]); + } + if handled { + return; + } + } + if !has_wayland_display() + && with_xtest(|api, dpy| unsafe { + if left { + let _ = (api.fake_button)(dpy, 1, 0, 0); + } + if middle { + let _ = (api.fake_button)(dpy, 2, 0, 0); + } + if right { + let _ = (api.fake_button)(dpy, 3, 0, 0); + } + }) + { + return; + } + if left && !run_quiet("xdotool", &["mouseup", "1"]) { + let _ = run_quiet("ydotool", &["click", "0x80"]); + } + if middle && !run_quiet("xdotool", &["mouseup", "2"]) { + let _ = run_quiet("ydotool", &["click", "0x82"]); + } + if right && !run_quiet("xdotool", &["mouseup", "3"]) { + let _ = run_quiet("ydotool", &["click", "0x81"]); + } +} + +/// Scrolls the mouse wheel smoothly (`dy > 0` scrolls down, `dx > 0` scrolls right). +pub fn scroll(dx: f32, dy: f32) { + if !dx.is_finite() || !dy.is_finite() { + return; + } + let (nx, ny) = { + let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner()); + s.accumulate_scroll(dx, dy) + }; + if nx == 0 && ny == 0 { + return; + } + if has_wayland_display() { + let mut ydo_ok = true; + if ny != 0 { + let wheel_y = (-ny).to_string(); + ydo_ok &= run_quiet("ydotool", &["mousemove", "--wheel", "-x", "0", "-y", &wheel_y]); + } + if nx != 0 { + let wheel_x = nx.to_string(); + ydo_ok &= run_quiet("ydotool", &["mousemove", "--wheel", "-x", &wheel_x, "-y", "0"]); + } + if ydo_ok { + return; + } + } + if with_xtest(|api, dpy| unsafe { + if ny != 0 { + let btn: c_uint = if ny > 0 { 5 } else { 4 }; + for _ in 0..ny.unsigned_abs() { + let _ = (api.fake_button)(dpy, btn, 1, 0); + let _ = (api.fake_button)(dpy, btn, 0, 0); + } + } + if nx != 0 { + let btn: c_uint = if nx > 0 { 7 } else { 6 }; + for _ in 0..nx.unsigned_abs() { + let _ = (api.fake_button)(dpy, btn, 1, 0); + let _ = (api.fake_button)(dpy, btn, 0, 0); + } + } + }) { + return; + } + if ny != 0 { + // X11 button 4 = wheel up, button 5 = wheel down. + let btn = if ny > 0 { "5" } else { "4" }; + let count = ny.unsigned_abs().to_string(); + if !run_quiet("xdotool", &["click", "--repeat", &count, "--delay", "1", btn]) { + let wheel_y = (-ny).to_string(); + let _ = run_quiet("ydotool", &["mousemove", "--wheel", "-x", "0", "-y", &wheel_y]); + } + } + if nx != 0 { + // X11 button 6 = wheel left, button 7 = wheel right. + let btn = if nx > 0 { "7" } else { "6" }; + let count = nx.unsigned_abs().to_string(); + if !run_quiet("xdotool", &["click", "--repeat", &count, "--delay", "1", btn]) { + let wheel_x = nx.to_string(); + let _ = run_quiet("ydotool", &["mousemove", "--wheel", "-x", &wheel_x, "-y", "0"]); + } + } +} + +/// Types arbitrary Unicode text (filtering carriage returns `\r`). +pub fn type_text(text: &str) { + let cleaned: String = text.chars().filter(|&c| c != '\r').collect(); + if cleaned.is_empty() { + return; + } + if has_wayland_display() && run_quiet("wtype", &["--", &cleaned]) { + return; + } + if !run_quiet("xdotool", &["type", "--clearmodifiers", "--delay", "0", "--", &cleaned]) { + let _ = run_quiet("wtype", &["--", &cleaned]); + } +} + +/// Resolves a protocol key or shortcut name into `(keysym, extra_mods)`. +pub(crate) fn resolve_key(key: &str) -> Option<(String, &'static [KeyMod])> { + const CTRL: &[KeyMod] = &[KeyMod::Ctrl]; + const WIN: &[KeyMod] = &[KeyMod::Win]; + const NONE: &[KeyMod] = &[]; + + let trimmed = key.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_ascii_lowercase(); + let (sym, mods): (&str, &'static [KeyMod]) = match lower.as_str() { + "copy" => ("c", CTRL), + "paste" => ("v", CTRL), + "cut" => ("x", CTRL), + "undo" => ("z", CTRL), + "redo" => ("y", CTRL), + "select_all" => ("a", CTRL), + "task_view" => ("Tab", WIN), + "enter" | "return" => ("Return", NONE), + "backspace" => ("BackSpace", NONE), + "tab" => ("Tab", NONE), + "escape" | "esc" => ("Escape", NONE), + "space" => ("space", NONE), + "delete" | "del" => ("Delete", NONE), + "insert" | "ins" => ("Insert", NONE), + "up" => ("Up", NONE), + "down" => ("Down", NONE), + "left" => ("Left", NONE), + "right" => ("Right", NONE), + "home" => ("Home", NONE), + "end" => ("End", NONE), + "page_up" | "pageup" | "pgup" | "prior" => ("Prior", NONE), + "page_down" | "pagedown" | "pgdn" | "next" => ("Next", NONE), + "print" | "printscreen" | "sysrq" => ("Print", NONE), + "f1" => ("F1", NONE), + "f2" => ("F2", NONE), + "f3" => ("F3", NONE), + "f4" => ("F4", NONE), + "f5" => ("F5", NONE), + "f6" => ("F6", NONE), + "f7" => ("F7", NONE), + "f8" => ("F8", NONE), + "f9" => ("F9", NONE), + "f10" => ("F10", NONE), + "f11" => ("F11", NONE), + "f12" => ("F12", NONE), + "volume_up" | "xf86audioraisevolume" => ("XF86AudioRaiseVolume", NONE), + "volume_down" | "xf86audiolowervolume" => ("XF86AudioLowerVolume", NONE), + "volume_mute" | "xf86audiomute" => ("XF86AudioMute", NONE), + "play_pause" | "xf86audioplay" => ("XF86AudioPlay", NONE), + "next_track" | "xf86audionext" => ("XF86AudioNext", NONE), + "prev_track" | "xf86audioprev" => ("XF86AudioPrev", NONE), + s if s.len() == 1 => { + let b = s.as_bytes()[0]; + if b.is_ascii_lowercase() || b.is_ascii_digit() { + return Some((s.to_owned(), NONE)); + } + return None; + } + _ => return None, + }; + Some((sym.to_owned(), mods)) +} + +fn mod_name(m: KeyMod) -> &'static str { + match m { + KeyMod::Ctrl => "ctrl", + KeyMod::Alt => "alt", + KeyMod::Shift => "shift", + KeyMod::Win => "super", + } +} + +/// Formats an `xdotool key` combo string (e.g. `"ctrl+shift+k"` or `"super+Tab"`) +/// for a protocol key and modifier list. +pub(crate) fn format_xdotool_combo(key: &str, mods: &[KeyMod]) -> Option { + let (sym, preset_mods) = resolve_key(key)?; + let mut all_mods: Vec = Vec::with_capacity(4); + for &m in preset_mods.iter().chain(mods.iter()) { + if !all_mods.contains(&m) { + all_mods.push(m); + } + } + if all_mods.is_empty() { + return Some(sym); + } + let mut parts: Vec<&str> = all_mods.iter().map(|&m| mod_name(m)).collect(); + parts.push(&sym); + Some(parts.join("+")) +} + +fn mod_x11_keysym_name(m: KeyMod) -> &'static CStr { + match m { + KeyMod::Ctrl => c"Control_L", + KeyMod::Alt => c"Alt_L", + KeyMod::Shift => c"Shift_L", + KeyMod::Win => c"Super_L", + } +} + +/// Presses a named key or shortcut with optional modifiers. +pub fn press_key(key: &str, mods: &[KeyMod]) { + if key.eq_ignore_ascii_case("lock") { + let _ = crate::links::power(PowerAction::Lock); + return; + } + let Some((sym, preset_mods)) = resolve_key(key) else { + return; + }; + let mut all_mods: Vec = Vec::with_capacity(4); + for &m in preset_mods.iter().chain(mods.iter()) { + if !all_mods.contains(&m) { + all_mods.push(m); + } + } + if has_wayland_display() { + let mut wtype_args: Vec = Vec::new(); + for &m in &all_mods { + let wm = match m { + KeyMod::Ctrl => "ctrl", + KeyMod::Alt => "alt", + KeyMod::Shift => "shift", + KeyMod::Win => "logo", + }; + wtype_args.push("-M".into()); + wtype_args.push(wm.into()); + } + wtype_args.push("-k".into()); + wtype_args.push(sym.clone()); + let refs: Vec<&str> = wtype_args.iter().map(String::as_str).collect(); + if run_quiet("wtype", &refs) { + return; + } + } + if let Ok(c_sym) = CString::new(sym.as_str()) { + let mut injected = false; + let _ = with_xtest(|api, dpy| unsafe { + let ks = (api.string_to_keysym)(c_sym.as_ptr()); + if ks == 0 { + return; + } + let kc = (api.keysym_to_keycode)(dpy, ks); + if kc == 0 { + return; + } + let mut mod_kcs: Vec = Vec::with_capacity(all_mods.len()); + for &m in &all_mods { + let mks = (api.string_to_keysym)(mod_x11_keysym_name(m).as_ptr()); + let mkc = (api.keysym_to_keycode)(dpy, mks); + if mkc != 0 { + mod_kcs.push(c_uint::from(mkc)); + } + } + for &mkc in &mod_kcs { + let _ = (api.fake_key)(dpy, mkc, 1, 0); + } + let _ = (api.fake_key)(dpy, c_uint::from(kc), 1, 0); + let _ = (api.fake_key)(dpy, c_uint::from(kc), 0, 0); + for &mkc in mod_kcs.iter().rev() { + let _ = (api.fake_key)(dpy, mkc, 0, 0); + } + injected = true; + }); + if injected { + return; + } + } + if let Some(combo) = format_xdotool_combo(key, mods) { + let _ = run_quiet("xdotool", &["key", "--clearmodifiers", &combo]); + } +} + +/// Executes a presentation slide action. +pub fn slide(action: SlideAction) { + let key = match action { + SlideAction::Next => "page_down", + SlideAction::Previous => "page_up", + SlideAction::Start => "f5", + SlideAction::Stop => "escape", + SlideAction::Black => "b", + }; + press_key(key, &[]); +} + +/// Parses idle milliseconds from `xprintidle` output (`"8801\n"`) or D-Bus +/// `GetIdletime` / `GetSessionIdleTime` output (`"(uint64 8801,)"` or `"(uint32 8,)"`). +pub(crate) fn parse_idle_output_ms(raw: &str, is_seconds: bool) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(val) = trimmed.parse::() { + return Some(if is_seconds { val.saturating_mul(1000) } else { val }); + } + // Extract the first integer token from gdbus tuple output like "(uint64 8801,)" or "(uint32 12,)". + for token in trimmed.split(|c: char| !c.is_ascii_digit()) { + if !token.is_empty() + && token != "64" + && token != "32" + && let Ok(val) = token.parse::() + { + return Some(if is_seconds { val.saturating_mul(1000) } else { val }); + } + } + None +} + +fn query_idle_ms_dbus() -> Option { + let conn = zbus::blocking::Connection::session().ok()?; + if let Ok(reply) = conn.call_method( + Some("org.gnome.Mutter.IdleMonitor"), + "/org/gnome/Mutter/IdleMonitor/Core", + Some("org.gnome.Mutter.IdleMonitor"), + "GetIdletime", + &(), + ) && let Ok(ms) = reply.body().deserialize::() + { + return Some(ms); + } + if let Ok(reply) = conn.call_method( + Some("org.freedesktop.ScreenSaver"), + "/org/freedesktop/ScreenSaver", + Some("org.freedesktop.ScreenSaver"), + "GetSessionIdleTime", + &(), + ) { + let body = reply.body(); + if let Ok(val) = body.deserialize::() { + return Some(u64::from(val)); + } + if let Ok(val) = body.deserialize::() { + return Some(val); + } + } + None +} + +fn query_idle_ms() -> Option { + if !has_display_env() { + return None; + } + if let Ok(out) = Command::new("xprintidle").stdin(Stdio::null()).stderr(Stdio::null()).output() + && out.status.success() + && let Some(ms) = parse_idle_output_ms(&String::from_utf8_lossy(&out.stdout), false) + { + return Some(ms); + } + query_idle_ms_dbus() +} + +/// Returns `true` when the user has moved the mouse or pressed a key on this PC +/// within the last `max_idle_ms` milliseconds. +pub fn is_user_active_within_ms(max_idle_ms: u32) -> bool { + match query_idle_ms() { + Some(idle_ms) => idle_ms <= u64::from(max_idle_ms), + None => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_named_keys_and_shortcuts() { + assert_eq!(resolve_key("enter"), Some(("Return".to_owned(), &[][..]))); + assert_eq!(resolve_key("up"), Some(("Up".to_owned(), &[][..]))); + assert_eq!(resolve_key("copy"), Some(("c".to_owned(), &[KeyMod::Ctrl][..]))); + assert_eq!(resolve_key("paste"), Some(("v".to_owned(), &[KeyMod::Ctrl][..]))); + assert_eq!(resolve_key("task_view"), Some(("Tab".to_owned(), &[KeyMod::Win][..]))); + assert_eq!(resolve_key("a"), Some(("a".to_owned(), &[][..]))); + assert_eq!(resolve_key("A"), Some(("a".to_owned(), &[][..]))); + assert_eq!(resolve_key("5"), Some(("5".to_owned(), &[][..]))); + assert_eq!(resolve_key("f5"), Some(("F5".to_owned(), &[][..]))); + assert_eq!(resolve_key("play_pause"), Some(("XF86AudioPlay".to_owned(), &[][..]))); + assert_eq!(resolve_key("volume_up"), Some(("XF86AudioRaiseVolume".to_owned(), &[][..]))); + assert_eq!(resolve_key("print"), Some(("Print".to_owned(), &[][..]))); + assert!(resolve_key("unknown_key").is_none()); + } + + #[test] + fn formats_xdotool_combos_with_preset_and_explicit_modifiers() { + assert_eq!(format_xdotool_combo("copy", &[]), Some("ctrl+c".to_owned())); + assert_eq!( + format_xdotool_combo("k", &[KeyMod::Ctrl, KeyMod::Shift]), + Some("ctrl+shift+k".to_owned()) + ); + assert_eq!(format_xdotool_combo("tab", &[KeyMod::Alt]), Some("alt+Tab".to_owned())); + assert_eq!(format_xdotool_combo("d", &[KeyMod::Win]), Some("super+d".to_owned())); + assert_eq!(format_xdotool_combo("f5", &[]), Some("F5".to_owned())); + } + + #[test] + fn accumulates_subpixel_move_and_scroll_remainders() { + let mut s = InputState::default(); + assert_eq!(s.accumulate_move(0.4, -0.4), (0, 0)); + assert_eq!(s.accumulate_move(0.4, -0.4), (1, -1)); + assert_eq!(s.accumulate_move(0.4, -0.4), (0, 0)); + + assert_eq!(s.accumulate_scroll(0.3, 0.3), (0, 0)); + assert_eq!(s.accumulate_scroll(0.3, 0.3), (1, 1)); + } + + #[test] + fn tracks_held_mouse_buttons_and_resets_on_release() { + let mut s = InputState::default(); + s.record_button(MouseButton::Left, ButtonAction::Down); + s.record_button(MouseButton::Right, ButtonAction::Click); + assert!(s.held_left); + assert!(!s.held_right); + + let held = s.take_held_and_reset(); + assert_eq!(held, (true, false, false)); + assert!(!s.held_left); + } + + #[test] + fn maps_normalized_coordinates_and_parses_idle_output() { + assert_eq!(normalized_to_pixels(0.0, 0.0, 1920, 1080), (0, 0)); + assert_eq!(normalized_to_pixels(1.0, 1.0, 1920, 1080), (1919, 1079)); + assert_eq!(normalized_to_pixels(0.5, 0.5, 1921, 1081), (960, 540)); + + assert_eq!(parse_idle_output_ms("8801\n", false), Some(8801)); + assert_eq!(parse_idle_output_ms("(uint64 4250,)", false), Some(4250)); + assert_eq!(parse_idle_output_ms("12", true), Some(12_000)); + } } diff --git a/desktop/app/src/linux/mod.rs b/desktop/app/src/linux/mod.rs index f1538cb6..b61829f7 100644 --- a/desktop/app/src/linux/mod.rs +++ b/desktop/app/src/linux/mod.rs @@ -35,6 +35,7 @@ pub mod vmic; pub mod wallpaper; pub mod widget; pub mod wifi; +pub mod window_capture; pub mod wol; use std::{fs, io::Read, process::Command}; diff --git a/desktop/app/src/linux/window_capture.rs b/desktop/app/src/linux/window_capture.rs new file mode 100644 index 00000000..1e78310a --- /dev/null +++ b/desktop/app/src/linux/window_capture.rs @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Capture a single window on Linux via in-process X11 `XGetImage`, +//! matching the public API of `desktop/app/src/win/window_capture.rs`. + +use std::{ + cell::RefCell, + ffi::{CStr, c_char, c_int, c_long, c_uint, c_ulong, c_void}, + sync::OnceLock, +}; + +use super::browser::{HWND, RECT, frame_of}; + +pub struct WindowCapture { + hwnd: HWND, + rgba: Vec, +} + +// SAFETY: WindowCapture only holds a window XID and an owned byte buffer. +unsafe impl Send for WindowCapture {} + +impl WindowCapture { + pub fn new(hwnd: HWND) -> Result { + if hwnd.0.is_null() { + return Err("invalid window handle".to_owned()); + } + Ok(WindowCapture { hwnd, rgba: Vec::new() }) + } + + pub fn window(&self) -> HWND { + self.hwnd + } + + /// Capture `self.hwnd`, optionally cropped to `crop` (in screen coordinates), + /// and scale the result into `bgra` (`dst_w` x `dst_h`, tightly packed BGRA). + pub fn capture(&mut self, crop: RECT, dst_w: u32, dst_h: u32, bgra: &mut Vec) -> bool { + if dst_w == 0 || dst_h == 0 { + return false; + } + let Some(frame) = frame_of(self.hwnd) else { + return false; + }; + let win_w = (frame.right - frame.left).max(0) as u32; + let win_h = (frame.bottom - frame.top).max(0) as u32; + if win_w < 32 || win_h < 32 { + return false; + } + + let xid = self.hwnd.0 as usize; + if let Some((src_w, src_h)) = capture_x11_window_bgra(xid, win_w, win_h, &mut self.rgba) { + let (cx0, cy0, cw, ch) = compute_window_crop(frame, crop, src_w, src_h); + scale_bgra(&self.rgba, src_w, src_w * 4, cx0, cy0, cw, ch, dst_w, dst_h, bgra); + return true; + } + + false + } +} + +fn compute_window_crop(frame: RECT, crop: RECT, src_w: u32, src_h: u32) -> (u32, u32, u32, u32) { + let cw = crop.right - crop.left; + let ch = crop.bottom - crop.top; + if cw >= 64 && ch >= 64 { + let x0 = (crop.left - frame.left).clamp(0, src_w as i32) as u32; + let y0 = (crop.top - frame.top).clamp(0, src_h as i32) as u32; + let x1 = (crop.right - frame.left).clamp(x0 as i32, src_w as i32) as u32; + let y1 = (crop.bottom - frame.top).clamp(y0 as i32, src_h as i32) as u32; + if x1 - x0 >= 64 && y1 - y0 >= 64 { + return (x0, y0, x1 - x0, y1 - y0); + } + } + (0, 0, src_w, src_h) +} + +#[repr(C)] +struct XWindowAttributes { + x: c_int, + y: c_int, + width: c_int, + height: c_int, + border_width: c_int, + depth: c_int, + visual: *mut c_void, + root: c_ulong, + class: c_int, + bit_gravity: c_int, + win_gravity: c_int, + backing_store: c_int, + backing_planes: c_ulong, + backing_pixel: c_ulong, + save_under: c_int, + colormap: c_ulong, + map_installed: c_int, + map_state: c_int, + all_event_masks: c_long, + your_event_mask: c_long, + do_not_propagate_mask: c_long, + override_redirect: c_int, + screen: *mut c_void, +} + +#[repr(C)] +struct XImageFuncs { + create_image: *mut c_void, + destroy_image: Option c_int>, + get_pixel: *mut c_void, + put_pixel: *mut c_void, + sub_image: *mut c_void, + add_pixel: *mut c_void, +} + +#[repr(C)] +struct XImage { + width: c_int, + height: c_int, + xoffset: c_int, + format: c_int, + data: *mut u8, + byte_order: c_int, + bitmap_unit: c_int, + bitmap_bit_order: c_int, + bitmap_pad: c_int, + depth: c_int, + bytes_per_line: c_int, + bits_per_pixel: c_int, + red_mask: c_ulong, + green_mask: c_ulong, + blue_mask: c_ulong, + obdata: *mut c_void, + f: XImageFuncs, +} + +struct X11WinCapApi { + open_display: unsafe extern "C" fn(*const c_char) -> *mut c_void, + get_window_attributes: unsafe extern "C" fn(*mut c_void, c_ulong, *mut XWindowAttributes) -> c_int, + get_image: unsafe extern "C" fn( + *mut c_void, + c_ulong, + c_int, + c_int, + c_uint, + c_uint, + c_ulong, + c_int, + ) -> *mut XImage, + close_display: unsafe extern "C" fn(*mut c_void) -> c_int, + free: unsafe extern "C" fn(*mut c_void) -> c_int, +} + +unsafe fn dl_sym(handle: *mut c_void, name: &CStr) -> Option { + let raw = unsafe { libc::dlsym(handle, name.as_ptr()) }; + if raw.is_null() { + return None; + } + Some(unsafe { std::mem::transmute_copy(&raw) }) +} + +fn x11_win_cap_api() -> Option<&'static X11WinCapApi> { + static API: OnceLock> = OnceLock::new(); + API.get_or_init(|| unsafe { + let mut lib = std::ptr::null_mut(); + for name in [c"libX11.so.6", c"libX11.so"] { + lib = libc::dlopen(name.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL); + if !lib.is_null() { + break; + } + } + if lib.is_null() { + return None; + } + Some(X11WinCapApi { + open_display: dl_sym(lib, c"XOpenDisplay")?, + get_window_attributes: dl_sym(lib, c"XGetWindowAttributes")?, + get_image: dl_sym(lib, c"XGetImage")?, + close_display: dl_sym(lib, c"XCloseDisplay")?, + free: dl_sym(lib, c"XFree")?, + }) + }) + .as_ref() +} + +struct ThreadDisplay(*mut c_void); + +impl Drop for ThreadDisplay { + fn drop(&mut self) { + if !self.0.is_null() + && let Some(api) = x11_win_cap_api() + { + unsafe { + let _ = (api.close_display)(self.0); + } + } + } +} + +fn capture_x11_window_bgra(xid: usize, _w: u32, _h: u32, out: &mut Vec) -> Option<(u32, u32)> { + if std::env::var_os("DISPLAY").is_none() || xid == 0 { + return None; + } + std::thread_local! { + static DISPLAY: RefCell> = const { RefCell::new(None) }; + } + let api = x11_win_cap_api()?; + DISPLAY.with(|cell| unsafe { + let mut borrow = cell.borrow_mut(); + if borrow.is_none() { + let dpy = (api.open_display)(std::ptr::null()); + if !dpy.is_null() { + *borrow = Some(ThreadDisplay(dpy)); + } + } + let dpy = borrow.as_ref()?.0; + let mut attrs: XWindowAttributes = std::mem::zeroed(); + // map_state == 2 is IsViewable in X11. + if (api.get_window_attributes)(dpy, xid as c_ulong, &mut attrs) == 0 + || attrs.map_state != 2 + || attrs.width < 32 + || attrs.height < 32 + { + return None; + } + let src_w = attrs.width as u32; + let src_h = attrs.height as u32; + let img = (api.get_image)( + dpy, + xid as c_ulong, + 0, + 0, + src_w, + src_h, + !0 as c_ulong, // AllPlanes + 2, // ZPixmap + ); + if img.is_null() { + return None; + } + if (*img).data.is_null() { + if let Some(destroy) = (*img).f.destroy_image { + let _ = destroy(img); + } else { + let _ = (api.free)(img.cast()); + } + return None; + } + let pitch = (*img).bytes_per_line.max(0) as usize; + let bpp = ((*img).bits_per_pixel.max(24) as usize) / 8; + let needed = (src_w as usize).checked_mul(src_h as usize)?.checked_mul(4)?; + out.resize(needed, 0); + let raw_len = pitch.saturating_mul(src_h as usize); + let src_bytes = std::slice::from_raw_parts((*img).data, raw_len); + for y in 0..src_h as usize { + let s_row = y * pitch; + let d_row = y * (src_w as usize) * 4; + for x in 0..src_w as usize { + let s_idx = s_row + x * bpp; + let d_idx = d_row + x * 4; + if s_idx + 3 <= src_bytes.len() && d_idx + 4 <= out.len() { + out[d_idx] = src_bytes[s_idx]; + out[d_idx + 1] = src_bytes[s_idx + 1]; + out[d_idx + 2] = src_bytes[s_idx + 2]; + out[d_idx + 3] = 0xFF; + } + } + } + if let Some(destroy) = (*img).f.destroy_image { + let _ = destroy(img); + } else { + let _ = (api.free)(img.cast()); + } + Some((src_w, src_h)) + }) +} + +/// Area-averaging box filter from a cropped BGRA region `(crop_x, crop_y, crop_w, crop_h)` +/// of `src` into a tightly packed `dst_w` x `dst_h` BGRA buffer. +#[allow(clippy::too_many_arguments)] +pub(crate) fn scale_bgra( + src: &[u8], + src_w: u32, + src_pitch: u32, + crop_x: u32, + crop_y: u32, + crop_w: u32, + crop_h: u32, + dst_w: u32, + dst_h: u32, + dst: &mut Vec, +) { + let dst_len = (dst_w as usize) * (dst_h as usize) * 4; + dst.resize(dst_len, 0); + if crop_w == 0 || crop_h == 0 || dst_w == 0 || dst_h == 0 || src_w == 0 { + return; + } + let pitch = src_pitch as usize; + + if crop_w == dst_w && crop_h == dst_h { + let row_bytes = (dst_w as usize) * 4; + let x_off = (crop_x as usize) * 4; + for y in 0..dst_h as usize { + let s_off = (crop_y as usize + y) * pitch + x_off; + let d_off = y * row_bytes; + if s_off + row_bytes <= src.len() { + dst[d_off..d_off + row_bytes].copy_from_slice(&src[s_off..s_off + row_bytes]); + } + } + return; + } + + for dy in 0..dst_h { + let sy0 = crop_y + ((dy as u64 * crop_h as u64) / dst_h as u64) as u32; + let sy1 = (crop_y + (((dy + 1) as u64 * crop_h as u64) / dst_h as u64) as u32).max(sy0 + 1); + for dx in 0..dst_w { + let sx0 = crop_x + ((dx as u64 * crop_w as u64) / dst_w as u64) as u32; + let sx1 = (crop_x + (((dx + 1) as u64 * crop_w as u64) / dst_w as u64) as u32).max(sx0 + 1); + let mut b_sum = 0u32; + let mut g_sum = 0u32; + let mut r_sum = 0u32; + let mut count = 0u32; + for sy in sy0..sy1 { + let row = (sy as usize) * pitch; + for sx in sx0..sx1 { + let idx = row + (sx as usize) * 4; + if idx + 4 <= src.len() { + b_sum += src[idx] as u32; + g_sum += src[idx + 1] as u32; + r_sum += src[idx + 2] as u32; + count += 1; + } + } + } + let out_idx = ((dy * dst_w + dx) as usize) * 4; + if let (Some(b), Some(g), Some(r)) = + (b_sum.checked_div(count), g_sum.checked_div(count), r_sum.checked_div(count)) + { + dst[out_idx] = b as u8; + dst[out_idx + 1] = g as u8; + dst[out_idx + 2] = r as u8; + dst[out_idx + 3] = 0xFF; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_size_is_a_copy() { + let src = vec![ + 10, 20, 30, 255, 40, 50, 60, 255, // + 70, 80, 90, 255, 100, 110, 120, 255, + ]; + let mut dst = Vec::new(); + scale_bgra(&src, 2, 8, 0, 0, 2, 2, 2, 2, &mut dst); + assert_eq!(dst, src); + } + + #[test] + fn halving_averages_and_a_flat_picture_stays_flat() { + let mut src = Vec::with_capacity(4 * 4 * 4); + for _ in 0..16 { + src.extend_from_slice(&[50, 100, 150, 255]); + } + let mut dst = Vec::new(); + scale_bgra(&src, 4, 16, 0, 0, 4, 4, 2, 2, &mut dst); + assert_eq!(dst.len(), 2 * 2 * 4); + for px in dst.as_chunks::<4>().0 { + assert_eq!(px, &[50, 100, 150, 255]); + } + } +} From 355c08b1311af7145bd4795956775c46855309c6 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:40:24 +0400 Subject: [PATCH 06/15] feat(linux): audio, MPRIS, vcam, vmic, H.264/MP4, mirroring, and recordings (Part 4) 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> --- .github/workflows/ci.yml | 3 +- desktop/app/src/core_host.rs | 19 +- desktop/app/src/linux/audio.rs | 383 ++++++++- desktop/app/src/linux/audio_out.rs | 314 +++++++- desktop/app/src/linux/browser.rs | 378 ++++++++- desktop/app/src/linux/bt_hfp.rs | 209 ++++- desktop/app/src/linux/h264.rs | 985 +++++++++++++++++++++++- desktop/app/src/linux/media_sessions.rs | 966 ++++++++++++++++++++++- desktop/app/src/linux/mp4.rs | 920 +++++++++++++++++++++- desktop/app/src/linux/smtc.rs | 577 +++++++++++++- desktop/app/src/linux/sound.rs | 232 +++++- desktop/app/src/linux/vcam.rs | 414 +++++++++- desktop/app/src/linux/vmic.rs | 651 +++++++++++++++- desktop/app/src/linux/wallpaper.rs | 385 ++++++++- desktop/app/src/mirror.rs | 182 ++++- desktop/app/src/pc_audio_stream.rs | 168 +++- desktop/app/src/pc_mirror.rs | 786 ++++++++++++++++++- desktop/app/src/phone_mic.rs | 100 ++- desktop/app/src/play_on_phone.rs | 9 +- desktop/app/src/recordings.rs | 42 +- 20 files changed, 7541 insertions(+), 182 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e772e15c..262c25af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,7 +168,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libgl1-mesa-dev libxkbcommon-dev libvulkan-dev libx11-dev + libgl1-mesa-dev libxkbcommon-dev libvulkan-dev libx11-dev \ + ffmpeg - name: Install Qt uses: jurplel/install-qt-action@v4 with: diff --git a/desktop/app/src/core_host.rs b/desktop/app/src/core_host.rs index deb88c74..808c7531 100644 --- a/desktop/app/src/core_host.rs +++ b/desktop/app/src/core_host.rs @@ -115,16 +115,10 @@ pub fn compute_capabilities( #[cfg(any(windows, target_os = "linux"))] { caps.push(nectarlink_core::PC_WAKE.into()); - caps.push(nectarlink_core::DECK_ACTIONS.into()); - } - #[cfg(windows)] - { caps.push("media.control".into()); - caps.push(nectarlink_core::STORAGE_MOUNT.into()); - caps.push(nectarlink_core::STORAGE_READ.into()); - caps.push(nectarlink_core::STORAGE_WRITE.into()); caps.push(nectarlink_core::MIRROR_VIEW.into()); caps.push(nectarlink_core::MIRROR_LISTEN.into()); + caps.push(nectarlink_core::DECK_ACTIONS.into()); caps.push(nectarlink_core::PC_AUDIO.into()); caps.push(nectarlink_core::RECORDER.into()); caps.push(nectarlink_core::WEBCAM_VIRTUAL.into()); @@ -132,6 +126,12 @@ pub fn compute_capabilities( caps.push(nectarlink_core::MIC_LISTEN.into()); } #[cfg(windows)] + { + caps.push(nectarlink_core::STORAGE_MOUNT.into()); + caps.push(nectarlink_core::STORAGE_READ.into()); + caps.push(nectarlink_core::STORAGE_WRITE.into()); + } + #[cfg(any(windows, target_os = "linux"))] let mut pc_mirror_enabled = crate::pc_mirror::is_active(); #[cfg(any(windows, target_os = "linux"))] let mut remote_input_enabled = false; @@ -142,7 +142,6 @@ pub fn compute_capabilities( for d in devices { if let Ok(toggles) = node.device_toggles(d.id) { for (name, on) in toggles { - #[cfg(windows)] if on && name == nectarlink_core::PC_MIRROR_TOGGLE { pc_mirror_enabled = true; } @@ -153,8 +152,8 @@ pub fn compute_capabilities( } } } - #[cfg(windows)] - if pc_mirror_enabled { + #[cfg(any(windows, target_os = "linux"))] + if pc_mirror_enabled && crate::pc_mirror::is_capture_available() { caps.push(nectarlink_core::MIRROR_CAPTURE.into()); } #[cfg(windows)] diff --git a/desktop/app/src/linux/audio.rs b/desktop/app/src/linux/audio.rs index 7f4ee91f..61a30fe9 100644 --- a/desktop/app/src/linux/audio.rs +++ b/desktop/app/src/linux/audio.rs @@ -1,36 +1,393 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Linux speaker volume, mute, output device enumeration, microphone mute +//! state, and event-driven change notifications via PipeWire (`wpctl`) and +//! PulseAudio (`pactl`). + +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock, mpsc}; +use std::time::Duration; use nectarlink_core::AudioOutputDevice; +static FALLBACK_SPEAKER: Mutex<(u8, bool)> = Mutex::new((50, false)); +static FALLBACK_MIC_MUTED: Mutex = Mutex::new(false); +static WATCH_STARTED: OnceLock<()> = OnceLock::new(); + +/// Parses `wpctl get-volume` output such as `"Volume: 0.45"` or +/// `"Volume: 0.45 [MUTED]"` into `(volume_percent, muted)`. +pub(crate) fn parse_wpctl_volume(output: &str) -> Option<(u8, bool)> { + let muted = output.contains("[MUTED]"); + let after_colon = output.split_once("Volume:")?.1; + let vol_token = after_colon.split_whitespace().next()?; + let val = vol_token.parse::().ok()?; + if !val.is_finite() { + return None; + } + let pct = (val * 100.0).round().clamp(0.0, 100.0) as u8; + Some((pct, muted)) +} + +/// Parses `pactl get-sink-mute` / `pactl get-source-mute` output such as +/// `"Mute: yes"` or `"Mute: no"`. +pub(crate) fn parse_pactl_mute(mute_out: &str) -> Option { + let rhs = mute_out.split_once(':').map(|(_, r)| r).unwrap_or(mute_out).trim(); + if rhs.eq_ignore_ascii_case("yes") || rhs.eq_ignore_ascii_case("true") || rhs == "1" { + Some(true) + } else if rhs.eq_ignore_ascii_case("no") || rhs.eq_ignore_ascii_case("false") || rhs == "0" { + Some(false) + } else { + None + } +} + +/// Parses `pactl get-sink-volume` and optional `pactl get-sink-mute` output +/// into `(volume_percent, muted)`. +pub(crate) fn parse_pactl_volume_and_mute(vol_out: &str, mute_out: Option<&str>) -> Option<(u8, bool)> { + let pct_token = vol_out.split_whitespace().find_map(|tok| tok.strip_suffix('%'))?; + let pct_val = pct_token.trim().parse::().ok()?; + if !pct_val.is_finite() { + return None; + } + let pct = pct_val.round().clamp(0.0, 100.0) as u8; + let muted = mute_out.and_then(parse_pactl_mute).unwrap_or(false); + Some((pct, muted)) +} + +/// Parses `pactl list sinks` output along with `pactl get-default-sink` into +/// sorted `AudioOutputDevice` entries (default first, then alphabetical by name). +pub(crate) fn parse_pactl_sinks(list_out: &str, default_sink: Option<&str>) -> Vec { + let default_trimmed = default_sink.map(str::trim).filter(|s| !s.is_empty()); + let mut out = Vec::new(); + let mut current_id: Option = None; + let mut current_desc: Option = None; + + let flush = |id: &mut Option, desc: &mut Option, out: &mut Vec| { + if let Some(sink_id) = id.take() { + let name = desc.take().filter(|d| !d.is_empty()).unwrap_or_else(|| sink_id.clone()); + let is_default = default_trimmed == Some(sink_id.as_str()); + out.push(AudioOutputDevice { id: sink_id, name, is_default }); + } else { + *desc = None; + } + }; + + for raw_line in list_out.lines() { + let line = raw_line.trim(); + if line.starts_with("Sink #") { + flush(&mut current_id, &mut current_desc, &mut out); + } else if let Some(rest) = line.strip_prefix("Name:") { + let id = rest.trim(); + if !id.is_empty() && current_id.is_none() { + current_id = Some(id.to_owned()); + } + } else if let Some(rest) = line.strip_prefix("Description:") { + let desc = rest.trim(); + if !desc.is_empty() && current_desc.is_none() { + current_desc = Some(desc.to_owned()); + } + } + } + flush(&mut current_id, &mut current_desc, &mut out); + + out.sort_by(|a, b| b.is_default.cmp(&a.is_default).then_with(|| a.name.cmp(&b.name))); + out +} + +/// Returns `true` when a `pactl subscribe` event line indicates a sink, source, +/// or server state/device change (ignoring per-stream `sink-input` / `source-output` noise). +pub(crate) fn parse_pactl_subscribe_line(line: &str) -> bool { + let trimmed = line.trim(); + if !trimmed.starts_with("Event '") { + return false; + } + (trimmed.contains(" on sink #") + || trimmed.ends_with(" on sink") + || trimmed.contains(" on source #") + || trimmed.ends_with(" on source") + || trimmed.contains(" on server #") + || trimmed.ends_with(" on server")) + && !trimmed.contains("sink-input") + && !trimmed.contains("source-output") +} + +/// Reads the default speaker endpoint's `(volume_percent, muted)`. pub fn speaker_state() -> Option<(u8, bool)> { - None + if let Ok(out) = Command::new("wpctl").args(["get-volume", "@DEFAULT_AUDIO_SINK@"]).output() + && out.status.success() + && let Some(state) = parse_wpctl_volume(&String::from_utf8_lossy(&out.stdout)) + { + if let Ok(mut guard) = FALLBACK_SPEAKER.lock() { + *guard = state; + } + return Some(state); + } + + if let Ok(vol_out) = Command::new("pactl").args(["get-sink-volume", "@DEFAULT_SINK@"]).output() + && vol_out.status.success() + { + let mute_out = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + if let Some(state) = + parse_pactl_volume_and_mute(&String::from_utf8_lossy(&vol_out.stdout), mute_out.as_deref()) + { + if let Ok(mut guard) = FALLBACK_SPEAKER.lock() { + *guard = state; + } + return Some(state); + } + } + + Some(*FALLBACK_SPEAKER.lock().ok()?) } -pub fn set_speaker_volume(_level: u8) -> Result<(), String> { + +/// Sets the default speaker endpoint's master volume (`0..=100`). +pub fn set_speaker_volume(level: u8) -> Result<(), String> { + let clamped = level.min(100); + if let Ok(mut guard) = FALLBACK_SPEAKER.lock() { + guard.0 = clamped; + } + let arg = format!("{clamped}%"); + let _ = Command::new("wpctl") + .args(["set-volume", "@DEFAULT_AUDIO_SINK@", &arg]) + .status() + .and_then(|s| if s.success() { Ok(s) } else { Err(std::io::Error::other("wpctl failed")) }) + .or_else(|_| Command::new("pactl").args(["set-sink-volume", "@DEFAULT_SINK@", &arg]).status()); Ok(()) } -pub fn set_speaker_mute(_muted: bool) -> Result<(), String> { + +/// Sets the default speaker endpoint's mute state. +pub fn set_speaker_mute(muted: bool) -> Result<(), String> { + if let Ok(mut guard) = FALLBACK_SPEAKER.lock() { + guard.1 = muted; + } + let flag = if muted { "1" } else { "0" }; + let _ = Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", flag]) + .status() + .and_then(|s| if s.success() { Ok(s) } else { Err(std::io::Error::other("wpctl failed")) }) + .or_else(|_| Command::new("pactl").args(["set-sink-mute", "@DEFAULT_SINK@", flag]).status()); Ok(()) } + +/// Steps the default speaker volume up by one increment (2%) and unmutes if muted. pub fn speaker_volume_up() -> Result<(u8, bool), String> { - Ok((50, false)) + let (current, muted) = speaker_state().unwrap_or((50, false)); + let next = current.saturating_add(2).min(100); + set_speaker_volume(next)?; + if muted { + set_speaker_mute(false)?; + } + Ok((next, false)) } + +/// Steps the default speaker volume down by one increment (2%). pub fn speaker_volume_down() -> Result<(u8, bool), String> { - Ok((50, false)) + let (current, muted) = speaker_state().unwrap_or((50, false)); + let next = current.saturating_sub(2); + set_speaker_volume(next)?; + Ok((next, muted)) } + +/// Toggles mute on the default speaker endpoint and returns the new muted state. pub fn toggle_speaker_mute() -> Result { - Ok(false) + let (_, muted) = speaker_state().unwrap_or((50, false)); + let next = !muted; + set_speaker_mute(next)?; + Ok(next) } + +/// Reads whether the default capture (microphone) endpoint is muted. pub fn mic_muted() -> Option { - None + if let Ok(out) = Command::new("wpctl").args(["get-volume", "@DEFAULT_AUDIO_SOURCE@"]).output() + && out.status.success() + && let Some((_, muted)) = parse_wpctl_volume(&String::from_utf8_lossy(&out.stdout)) + { + if let Ok(mut guard) = FALLBACK_MIC_MUTED.lock() { + *guard = muted; + } + return Some(muted); + } + + if let Ok(out) = Command::new("pactl").args(["get-source-mute", "@DEFAULT_SOURCE@"]).output() + && out.status.success() + && let Some(muted) = parse_pactl_mute(&String::from_utf8_lossy(&out.stdout)) + { + if let Ok(mut guard) = FALLBACK_MIC_MUTED.lock() { + *guard = muted; + } + return Some(muted); + } + + Some(*FALLBACK_MIC_MUTED.lock().ok()?) } + +/// Toggles mute on the default capture (microphone) endpoint and returns the new muted state. pub fn toggle_mic_mute() -> Result { - Ok(false) + let current = mic_muted().unwrap_or(false); + let next = !current; + if let Ok(mut guard) = FALLBACK_MIC_MUTED.lock() { + *guard = next; + } + let flag = if next { "1" } else { "0" }; + let _ = Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SOURCE@", flag]) + .status() + .and_then(|s| if s.success() { Ok(s) } else { Err(std::io::Error::other("wpctl failed")) }) + .or_else(|_| Command::new("pactl").args(["set-source-mute", "@DEFAULT_SOURCE@", flag]).status()); + Ok(next) } + +/// Lists active audio output sinks on this PC, marking the current default +/// output (`is_default: true`) and sorting the default first. pub fn output_devices() -> Vec { - Vec::new() + let default_sink = Command::new("pactl") + .arg("get-default-sink") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) + .filter(|s| !s.is_empty()); + + if let Ok(list_out) = Command::new("pactl").args(["list", "sinks"]).output() + && list_out.status.success() + { + let parsed = parse_pactl_sinks(&String::from_utf8_lossy(&list_out.stdout), default_sink.as_deref()); + if !parsed.is_empty() { + return parsed; + } + } + + vec![AudioOutputDevice { + id: "default".to_string(), + name: "Default Output".to_string(), + is_default: true, + }] } -pub fn set_default_output_device(_id: &str) -> Result<(), String> { - Ok(()) + +/// Switches the system default output sink to `id`. +pub fn set_default_output_device(id: &str) -> Result<(), String> { + let trimmed = id.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') || trimmed.chars().any(char::is_control) { + return Err("output device id is invalid".to_string()); + } + let status = Command::new("pactl") + .args(["set-default-sink", trimmed]) + .status() + .and_then(|s| if s.success() { Ok(s) } else { Err(std::io::Error::other("pactl failed")) }) + .or_else(|_| Command::new("wpctl").args(["set-default", trimmed]).status()) + .map_err(|e| format!("failed to set default output device: {e}"))?; + + if status.success() { Ok(()) } else { Err(format!("audio backend rejected default sink '{trimmed}'")) } +} + +/// Subscribes to PipeWire/PulseAudio sink/source/server events and invokes +/// `changed` after coalescing rapid event bursts over 80 ms. +pub fn watch(changed: fn()) { + if WATCH_STARTED.set(()).is_err() { + return; + } + let _ = std::thread::Builder::new().name("audio-watch".into()).spawn(move || watch_thread(changed)); +} + +fn watch_thread(changed: fn()) { + let Ok(mut child) = + Command::new("pactl").arg("subscribe").stdout(Stdio::piped()).stderr(Stdio::null()).spawn() + else { + return; + }; + let Some(stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return; + }; + + let (tx, rx) = mpsc::channel::<()>(); + let _ = std::thread::Builder::new().name("audio-watch-reader".into()).spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if parse_pactl_subscribe_line(&line) && tx.send(()).is_err() { + break; + } + } + }); + + while rx.recv().is_ok() { + while rx.recv_timeout(Duration::from_millis(80)).is_ok() {} + changed(); + } + + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_wpctl_volume_handles_normal_and_muted_lines() { + assert_eq!(parse_wpctl_volume("Volume: 0.45\n"), Some((45, false))); + assert_eq!(parse_wpctl_volume("Volume: 0.72 [MUTED]\n"), Some((72, true))); + assert_eq!(parse_wpctl_volume("Volume: 1.35"), Some((100, false))); + assert_eq!(parse_wpctl_volume("garbage"), None); + } + + #[test] + fn parse_pactl_volume_and_mute_extracts_percentage_and_mute() { + let vol = "Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 32768 / 50% / -18.06 dB\n"; + assert_eq!(parse_pactl_volume_and_mute(vol, Some("Mute: no\n")), Some((50, false))); + assert_eq!(parse_pactl_volume_and_mute(vol, Some("Mute: yes\n")), Some((50, true))); + assert_eq!(parse_pactl_volume_and_mute("invalid output", None), None); + } + + #[test] + fn parse_pactl_sinks_parses_and_sorts_default_first() { + let sample = "\ +Sink #41 +\tState: RUNNING +\tName: alsa_output.pci-0000_00_1f.3.analog-stereo +\tDescription: Built-in Audio Analog Stereo +\tVolume: front-left: 40000 / 61% + +Sink #52 +\tState: IDLE +\tName: bluez_output.AA_BB_CC_DD_EE_FF.1 +\tDescription: Pixel Buds Pro +"; + let devices = parse_pactl_sinks(sample, Some("bluez_output.AA_BB_CC_DD_EE_FF.1")); + assert_eq!(devices.len(), 2); + assert_eq!(devices[0].id, "bluez_output.AA_BB_CC_DD_EE_FF.1"); + assert_eq!(devices[0].name, "Pixel Buds Pro"); + assert!(devices[0].is_default); + assert_eq!(devices[1].id, "alsa_output.pci-0000_00_1f.3.analog-stereo"); + assert_eq!(devices[1].name, "Built-in Audio Analog Stereo"); + assert!(!devices[1].is_default); + } + + #[test] + fn parse_pactl_subscribe_line_filters_relevant_events() { + assert!(parse_pactl_subscribe_line("Event 'change' on sink #0")); + assert!(parse_pactl_subscribe_line("Event 'change' on source #2")); + assert!(parse_pactl_subscribe_line("Event 'change' on server #0")); + assert!(!parse_pactl_subscribe_line("Event 'change' on sink-input #44")); + assert!(!parse_pactl_subscribe_line("Event 'change' on source-output #12")); + assert!(!parse_pactl_subscribe_line("Event 'new' on client #99")); + } + + #[test] + fn volume_stepping_increments_by_two_percent_and_unmutes_on_up() { + let _ = set_speaker_volume(40); + let _ = set_speaker_mute(true); + let (vol_up, muted_after_up) = speaker_volume_up().unwrap(); + assert!(!muted_after_up); + assert!(vol_up <= 100); + + let (vol_down, _) = speaker_volume_down().unwrap(); + assert!(vol_down <= 100); + } } -pub fn watch(_changed: fn()) {} diff --git a/desktop/app/src/linux/audio_out.rs b/desktop/app/src/linux/audio_out.rs index 21aee306..bc2a602f 100644 --- a/desktop/app/src/linux/audio_out.rs +++ b/desktop/app/src/linux/audio_out.rs @@ -1,19 +1,313 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables)] +//! Low-latency 16-bit signed little-endian PCM playback on Linux via +//! PulseAudio (`pacat`), PipeWire (`pw-play`), or ALSA (`aplay`), backed by an +//! adaptive jitter buffer with smooth sample interpolation. -use std::io; +use std::collections::VecDeque; +use std::io::{Result, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, SyncSender, TrySendError}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; -pub struct Player; +const BYTES_PER_SAMPLE: usize = 2; +const JITTER_START_MS: usize = 40; +const JITTER_LOW_MS: usize = 20; +const JITTER_TARGET_MS: usize = 80; +const JITTER_HIGH_MS: usize = 160; +const JITTER_MAX_MS: usize = 220; + +/// Linearly interpolates two interleaved `s16le` frames (`a` and `b`) into `out`. +pub(crate) fn interpolate_frame_s16le(a: &[u8], b: &[u8], out: &mut [u8]) { + let len = a.len().min(b.len()).min(out.len()) & !1; + for i in (0..len).step_by(2) { + let sa = i16::from_le_bytes([a[i], a[i + 1]]); + let sb = i16::from_le_bytes([b[i], b[i + 1]]); + let mid = ((i32::from(sa) + i32::from(sb)) / 2) as i16; + let bytes = mid.to_le_bytes(); + out[i] = bytes[0]; + out[i + 1] = bytes[1]; + } +} + +#[derive(Debug)] +struct Jitter { + pcm: VecDeque, + frame_bytes: usize, + start_bytes: usize, + low_bytes: usize, + target_bytes: usize, + high_bytes: usize, + max_bytes: usize, + primed: bool, + last_frame: Vec, +} + +impl Jitter { + fn new(rate: u32, channels: u16) -> Self { + let frame_bytes = (channels as usize).max(1) * BYTES_PER_SAMPLE; + let bytes_per_ms = ((rate as usize) * frame_bytes / 1000).max(frame_bytes); + let align = |ms: usize| { + let raw = (ms * bytes_per_ms).max(frame_bytes); + (raw / frame_bytes) * frame_bytes + }; + Self { + pcm: VecDeque::with_capacity(align(JITTER_MAX_MS)), + frame_bytes, + start_bytes: align(JITTER_START_MS), + low_bytes: align(JITTER_LOW_MS), + target_bytes: align(JITTER_TARGET_MS), + high_bytes: align(JITTER_HIGH_MS), + max_bytes: align(JITTER_MAX_MS), + primed: false, + last_frame: vec![0u8; frame_bytes], + } + } + + fn push(&mut self, data: &[u8]) { + let usable = (data.len() / self.frame_bytes) * self.frame_bytes; + if usable == 0 { + return; + } + self.pcm.extend(&data[..usable]); + if self.pcm.len() > self.max_bytes { + let drop = ((self.pcm.len() - self.target_bytes) / self.frame_bytes) * self.frame_bytes; + self.pcm.drain(..drop.min(self.pcm.len())); + } + if self.pcm.len() >= self.start_bytes { + self.primed = true; + } + } + + fn pull(&mut self, dst: &mut [u8], frames: usize) -> bool { + let total_bytes = (frames * self.frame_bytes).min(dst.len()); + if !self.primed { + dst[..total_bytes].fill(0); + return false; + } + + if self.pcm.len() > self.high_bytes && frames >= 8 { + for _ in 0..self.frame_bytes { + let _ = self.pcm.pop_front(); + } + } + + let available_frames = self.pcm.len() / self.frame_bytes; + if available_frames == 0 { + self.primed = false; + self.last_frame.fill(0); + dst[..total_bytes].fill(0); + return false; + } + + let stretch = self.pcm.len() < self.low_bytes && frames >= 16; + let mut written = 0usize; + let mut produced_frames = 0usize; + let mut next_peek = vec![0u8; self.frame_bytes]; + + while produced_frames < frames && written + self.frame_bytes <= total_bytes { + if self.pcm.len() >= self.frame_bytes { + for b in &mut self.last_frame { + *b = self.pcm.pop_front().unwrap_or(0); + } + dst[written..written + self.frame_bytes].copy_from_slice(&self.last_frame); + written += self.frame_bytes; + produced_frames += 1; + + if stretch && produced_frames == frames / 2 && written + self.frame_bytes <= total_bytes { + if self.pcm.len() >= self.frame_bytes { + for (i, b) in next_peek.iter_mut().enumerate() { + *b = self.pcm.get(i).copied().unwrap_or(0); + } + interpolate_frame_s16le( + &self.last_frame, + &next_peek, + &mut dst[written..written + self.frame_bytes], + ); + } else { + dst[written..written + self.frame_bytes].copy_from_slice(&self.last_frame); + } + written += self.frame_bytes; + produced_frames += 1; + } + } else { + self.primed = false; + dst[written..total_bytes].fill(0); + break; + } + } + true + } +} + +fn spawn_pcm_sink(rate: u32, channels: u16, device_id: Option<&str>) -> Option { + let rate_s = rate.to_string(); + let ch_s = channels.to_string(); + let dev = device_id.map(str::trim).filter(|s| { + !s.is_empty() && *s != "default" && !s.starts_with('-') && !s.chars().any(char::is_control) + }); + + let mut pacat = Command::new("pacat"); + pacat.args([ + "--playback", + "--raw", + "--format=s16le", + &format!("--rate={rate_s}"), + &format!("--channels={ch_s}"), + "--latency-msec=20", + ]); + if let Some(d) = dev { + pacat.arg(format!("--device={d}")); + } + if let Ok(child) = pacat.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + return Some(child); + } + + let mut pw = Command::new("pw-play"); + pw.args(["--raw", "--format=s16", &format!("--rate={rate_s}"), &format!("--channels={ch_s}")]); + if let Some(d) = dev { + pw.arg(format!("--target={d}")); + } + pw.arg("-"); + if let Ok(child) = pw.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + return Some(child); + } + + Command::new("aplay") + .args(["-q", "-t", "raw", "-f", "S16_LE", "-r", &rate_s, "-c", &ch_s, "--buffer-size=1920", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() +} + +#[derive(Debug)] +pub struct Player { + tx: SyncSender>, + stop: Arc, + handle: Option>, +} impl Player { - pub fn start(_rate: u32, _channels: u16) -> io::Result { - Ok(Player) + pub fn start(rate: u32, channels: u16) -> Result { + Self::start_for_device(rate, channels, None) + } + + pub fn start_on(device: Option, rate: u32, channels: u16) -> Result { + Self::start_for_device(rate, channels, device.as_deref()) + } + + pub fn start_for_device(rate: u32, channels: u16, device_id: Option<&str>) -> Result { + let (tx, rx) = mpsc::sync_channel::>(64); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let clean_rate = rate.max(8_000); + let clean_channels = channels.max(1); + let dev_owned = device_id.map(str::to_owned); + + let handle = thread::Builder::new().name("nectarlink-audio-out".into()).spawn(move || { + let mut jitter = Jitter::new(clean_rate, clean_channels); + let frame_bytes = jitter.frame_bytes; + let frames_per_tick = ((clean_rate as usize) / 100).max(1); + let mut scratch = vec![0u8; frames_per_tick * frame_bytes]; + let mut child = spawn_pcm_sink(clean_rate, clean_channels, dev_owned.as_deref()); + + while !thread_stop.load(Ordering::Acquire) { + while let Ok(chunk) = rx.try_recv() { + jitter.push(&chunk); + } + let has_audio = jitter.pull(&mut scratch, frames_per_tick); + if has_audio + && let Some(proc) = child.as_mut() + && let Some(stdin) = proc.stdin.as_mut() + && stdin.write_all(&scratch).is_err() + { + let _ = proc.kill(); + let _ = proc.wait(); + child = spawn_pcm_sink(clean_rate, clean_channels, dev_owned.as_deref()); + } + thread::sleep(Duration::from_millis(10)); + } + + if let Some(mut proc) = child { + drop(proc.stdin.take()); + let _ = proc.kill(); + let _ = proc.wait(); + } + })?; + + Ok(Player { tx, stop, handle: Some(handle) }) + } + + pub fn push(&self, data: Vec) { + if data.is_empty() { + return; + } + match self.tx.try_send(data) { + Ok(()) | Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {} + } + } +} + +impl Drop for Player { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } } - pub fn start_on(_device: Option, _rate: u32, _channels: u16) -> io::Result { - Ok(Player) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jitter_waits_for_prime_threshold_before_emitting_audio() { + let mut jitter = Jitter::new(48_000, 2); + let mut out = vec![0xFFu8; 480 * 4]; + jitter.push(&vec![0x11u8; 480 * 4]); + assert!(!jitter.pull(&mut out, 480)); + assert!(out.iter().all(|&b| b == 0)); + + jitter.push(&vec![0x22u8; 480 * 4 * 4]); + assert!(jitter.pull(&mut out, 480)); + assert!(out.iter().any(|&b| b != 0)); } - pub fn start_for_device(_rate: u32, _channels: u16, _device_id: Option<&str>) -> io::Result { - Ok(Player) + + #[test] + fn jitter_caps_overflow_to_target_depth() { + let mut jitter = Jitter::new(48_000, 2); + jitter.push(&vec![0x33u8; jitter.max_bytes + 4096]); + assert!(jitter.pcm.len() <= jitter.max_bytes); + assert!(jitter.pcm.len() >= jitter.target_bytes); + } + + #[test] + fn interpolate_frame_s16le_averages_per_channel_samples() { + let mut a = Vec::new(); + a.extend_from_slice(&1000i16.to_le_bytes()); + a.extend_from_slice(&(-2000i16).to_le_bytes()); + + let mut b = Vec::new(); + b.extend_from_slice(&3000i16.to_le_bytes()); + b.extend_from_slice(&(-1000i16).to_le_bytes()); + + let mut out = [0u8; 4]; + interpolate_frame_s16le(&a, &b, &mut out); + assert_eq!(i16::from_le_bytes([out[0], out[1]]), 2000); + assert_eq!(i16::from_le_bytes([out[2], out[3]]), -1500); + } + + #[test] + fn player_starts_accepts_pushes_and_drops_cleanly() { + let player = Player::start(48_000, 2).expect("player should start"); + player.push(vec![0u8; 1920]); + drop(player); } - pub fn push(&self, _data: Vec) {} } diff --git a/desktop/app/src/linux/browser.rs b/desktop/app/src/linux/browser.rs index c859dd88..a39c48b9 100644 --- a/desktop/app/src/linux/browser.rs +++ b/desktop/app/src/linux/browser.rs @@ -1,11 +1,22 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#![allow(dead_code, unused_variables, non_camel_case_types, clippy::upper_case_acronyms)] +//! Find the window that owns a media session on Linux, and read the page +//! address and the `