From 5fd3f5956487f9ecec6a6d47fa0e97b7a75151c8 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:05:27 +0400 Subject: [PATCH] feat(linux): Linux platform foundation, Qt6 windowing, and packaging (Part 1) 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 | 29 + Cargo.lock | 3 + core/nectarlink-cli/Cargo.toml | 4 + core/nectarlink-cli/src/main.rs | 348 ++- desktop/app/Cargo.toml | 3 + desktop/app/build.rs | 138 +- desktop/app/cpp/native_window.cpp | 30 +- desktop/app/src/bridge/app.rs | 15 +- desktop/app/src/bridge/media.rs | 10 +- desktop/app/src/core_host.rs | 92 +- desktop/app/src/i18n.rs | 30 +- desktop/app/src/linux/audio.rs | 36 + desktop/app/src/linux/audio_out.rs | 19 + desktop/app/src/linux/autostart.rs | 20 + desktop/app/src/linux/ble_beacon.rs | 9 + desktop/app/src/linux/browser.rs | 61 + desktop/app/src/linux/bt_hfp.rs | 44 + desktop/app/src/linux/clipboard.rs | 72 + desktop/app/src/linux/doctor.rs | 39 + desktop/app/src/linux/elevated.rs | 32 + desktop/app/src/linux/h264.rs | 48 + desktop/app/src/linux/http.rs | 25 + desktop/app/src/linux/icon.rs | 25 + desktop/app/src/linux/image.rs | 36 + desktop/app/src/linux/input.rs | 20 + desktop/app/src/linux/jumplist.rs | 9 + desktop/app/src/linux/media_sessions.rs | 14 + desktop/app/src/linux/mod.rs | 317 +++ desktop/app/src/linux/mp4.rs | 44 + desktop/app/src/linux/net.rs | 151 ++ desktop/app/src/linux/share_target.rs | 50 + desktop/app/src/linux/shell.rs | 56 + desktop/app/src/linux/shortcut.rs | 41 + desktop/app/src/linux/single_instance.rs | 21 + desktop/app/src/linux/smtc.rs | 17 + desktop/app/src/linux/sound.rs | 5 + desktop/app/src/linux/toast.rs | 51 + desktop/app/src/linux/tray.rs | 67 + desktop/app/src/linux/vcam.rs | 37 + desktop/app/src/linux/vmic.rs | 23 + desktop/app/src/linux/wallpaper.rs | 14 + desktop/app/src/linux/widget.rs | 12 + desktop/app/src/linux/wifi.rs | 23 + desktop/app/src/linux/wol.rs | 47 + desktop/app/src/main.rs | 4 + desktop/app/src/media_file.rs | 2 + desktop/app/src/mirror.rs | 14 + desktop/app/src/pc_audio_stream.rs | 707 +++--- desktop/app/src/pc_mirror.rs | 1873 +++++++------- desktop/app/src/play_on_phone.rs | 660 ++--- desktop/app/src/recordings.rs | 55 +- desktop/app/src/setup.rs | 65 +- desktop/app/src/storage.rs | 2188 +++++++++-------- desktop/app/src/updater.rs | 173 +- desktop/packaging/linux/build-appimage.sh | 173 ++ desktop/packaging/linux/build-deb.sh | 207 ++ desktop/packaging/linux/build-tarball.sh | 215 ++ desktop/packaging/linux/nectarlink.desktop | 35 + .../linux/org.nectarlink.desktop.metainfo.xml | 38 + desktop/vcam/Cargo.toml | 7 +- desktop/vcam/src/lib.rs | 10 + desktop/vcam/src/shm.rs | 177 ++ scripts/check.ps1 | 13 +- spikes/s1-qt-rust/cpp/frame_notifier.h | 4 + spikes/s1-qt-rust/cpp/video_surface.cpp | 40 +- spikes/s1-qt-rust/cpp/window_helpers.cpp | 3 + spikes/s1-qt-rust/src/video.rs | 67 +- 67 files changed, 6187 insertions(+), 2730 deletions(-) create mode 100644 desktop/app/src/linux/audio.rs create mode 100644 desktop/app/src/linux/audio_out.rs create mode 100644 desktop/app/src/linux/autostart.rs create mode 100644 desktop/app/src/linux/ble_beacon.rs create mode 100644 desktop/app/src/linux/browser.rs create mode 100644 desktop/app/src/linux/bt_hfp.rs create mode 100644 desktop/app/src/linux/clipboard.rs create mode 100644 desktop/app/src/linux/doctor.rs create mode 100644 desktop/app/src/linux/elevated.rs create mode 100644 desktop/app/src/linux/h264.rs create mode 100644 desktop/app/src/linux/http.rs create mode 100644 desktop/app/src/linux/icon.rs create mode 100644 desktop/app/src/linux/image.rs create mode 100644 desktop/app/src/linux/input.rs create mode 100644 desktop/app/src/linux/jumplist.rs create mode 100644 desktop/app/src/linux/media_sessions.rs create mode 100644 desktop/app/src/linux/mod.rs create mode 100644 desktop/app/src/linux/mp4.rs create mode 100644 desktop/app/src/linux/net.rs create mode 100644 desktop/app/src/linux/share_target.rs create mode 100644 desktop/app/src/linux/shell.rs create mode 100644 desktop/app/src/linux/shortcut.rs create mode 100644 desktop/app/src/linux/single_instance.rs create mode 100644 desktop/app/src/linux/smtc.rs create mode 100644 desktop/app/src/linux/sound.rs create mode 100644 desktop/app/src/linux/toast.rs create mode 100644 desktop/app/src/linux/tray.rs create mode 100644 desktop/app/src/linux/vcam.rs create mode 100644 desktop/app/src/linux/vmic.rs create mode 100644 desktop/app/src/linux/wallpaper.rs create mode 100644 desktop/app/src/linux/widget.rs create mode 100644 desktop/app/src/linux/wifi.rs create mode 100644 desktop/app/src/linux/wol.rs create mode 100755 desktop/packaging/linux/build-appimage.sh create mode 100755 desktop/packaging/linux/build-deb.sh create mode 100755 desktop/packaging/linux/build-tarball.sh create mode 100644 desktop/packaging/linux/nectarlink.desktop create mode 100644 desktop/packaging/linux/org.nectarlink.desktop.metainfo.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86f9c814..efa503cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,35 @@ jobs: path: target/package/*-setup.exe retention-days: 14 + desktop-linux: + name: Linux app (x64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install the pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@v2 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgl1-mesa-dev libxkbcommon-dev libvulkan-dev libx11-dev + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: linux + target: desktop + arch: linux_gcc_64 + cache: true + aqtsource: git+https://github.com/miurahr/aqtinstall.git + - name: Clippy + run: cargo clippy --locked -p nectarlink-desktop -p nectarlink-vcam -p s1-qt-rust --all-targets -- -D warnings + - name: Tests + run: cargo test --locked -p nectarlink-desktop -p nectarlink-vcam + - name: Release build + run: cargo build --locked --release -p nectarlink-desktop -p nectarlink-vcam + android: name: Android app runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 671a8fc7..0bf629b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,6 +2334,7 @@ dependencies = [ "clap", "dirs", "hostname", + "libc", "nectarlink-core", "qrcode", "serde", @@ -2392,6 +2393,7 @@ dependencies = [ "dirs", "embed-resource", "hostname", + "libc", "material-colors", "nectarlink-core", "nectarlink-vcam", @@ -2442,6 +2444,7 @@ version = "0.1.4" dependencies = [ "ab_glyph", "embed-resource", + "libc", "windows", "windows-core", ] diff --git a/core/nectarlink-cli/Cargo.toml b/core/nectarlink-cli/Cargo.toml index fc5c9768..cd694694 100644 --- a/core/nectarlink-cli/Cargo.toml +++ b/core/nectarlink-cli/Cargo.toml @@ -31,6 +31,10 @@ windows = { workspace = true, features = [ "Win32_System_Threading", ] } +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + + [dev-dependencies] tempfile.workspace = true diff --git a/core/nectarlink-cli/src/main.rs b/core/nectarlink-cli/src/main.rs index e07f3504..71693db6 100644 --- a/core/nectarlink-cli/src/main.rs +++ b/core/nectarlink-cli/src/main.rs @@ -5460,7 +5460,7 @@ fn default_command_title(first_token: &str) -> String { } enum TaskTransport { - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] DesktopDir(PathBuf), Standalone { node: Node, @@ -5470,12 +5470,14 @@ enum TaskTransport { impl TaskTransport { async fn send(&self, device_query: Option<&str>, task: &TaskNotify) -> Result<()> { - // Only the desktop app's queue (Windows) picks the phone by name here. - #[cfg(not(windows))] + // Only the desktop app's queue (Windows / Linux) picks the phone by name here. + #[cfg(not(any(windows, target_os = "linux")))] let _ = device_query; match self { #[cfg(windows)] TaskTransport::DesktopDir(dir) => win_watch::queue_desktop_task_notify(dir, device_query, task), + #[cfg(target_os = "linux")] + TaskTransport::DesktopDir(dir) => linux_watch::queue_desktop_task_notify(dir, device_query, task), TaskTransport::Standalone { node, targets } => { let mut sent_any = false; let mut last_err = None; @@ -5496,24 +5498,31 @@ impl TaskTransport { async fn shutdown(self) { match self { TaskTransport::Standalone { node, .. } => node.shutdown().await, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] TaskTransport::DesktopDir(_) => {} } } } async fn connect_task_transport(cli: &Cli, device_query: Option<&str>) -> Result { - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] { let desktop_dir = match &cli.data_dir { Some(dir) => Some(dir.clone()), None => dirs::data_local_dir().map(|d| d.join("Nectarlink")), }; + #[cfg(windows)] if let Some(dir) = desktop_dir && win_watch::is_desktop_running(&dir) { return Ok(TaskTransport::DesktopDir(dir)); } + #[cfg(target_os = "linux")] + if let Some(dir) = desktop_dir + && linux_watch::is_desktop_running(&dir) + { + return Ok(TaskTransport::DesktopDir(dir)); + } } let node = Box::pin(start_node(cli)).await?; let targets = match device_query { @@ -5624,9 +5633,61 @@ async fn run_notify_when( ); return Ok(0); } - #[cfg(not(windows))] + #[cfg(target_os = "linux")] + { + let pid: u32 = target[0].parse()?; + let watched = linux_watch::open_process(pid)?; + let title = title_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| watched.name.clone()); + let started = watched.started_epoch_ms; + let initial_elapsed = (now_unix_ms().max(0) as u64).saturating_sub(started); + let task_id = format!("pid-{pid}-{started}"); + let transport = connect_task_transport(cli, device).await?; + let running = TaskNotify { + id: task_id.clone(), + title: title.clone(), + active: true, + elapsed_ms: initial_elapsed, + exit_code: None, + }; + transport.send(device, &running).await?; + let mut wait_handle = tokio::task::spawn_blocking(move || linux_watch::wait_process(watched)); + let elapsed = tokio::select! { + res = &mut wait_handle => res.context("process watch task panicked")?, + _ = tokio::signal::ctrl_c() => { + let dismissed = TaskNotify { + id: task_id, + title, + active: false, + elapsed_ms: 0, + exit_code: None, + }; + let _ = transport.send(device, &dismissed).await; + transport.shutdown().await; + return Ok(130); + } + }; + let total_elapsed = elapsed.as_millis() as u64; + let finished = TaskNotify { + id: task_id, + title: title.clone(), + active: false, + elapsed_ms: total_elapsed, + exit_code: Some(0), + }; + let took = TaskNotify::format_took(total_elapsed); + let send_res = transport.send(device, &finished).await; + transport.shutdown().await; + send_res?; + println!("{title} finished ({took})."); + return Ok(0); + } + #[cfg(not(any(windows, target_os = "linux")))] { - bail!("watching a process by PID is only supported on Windows"); + bail!("watching a process by PID is only supported on Windows and Linux"); } } @@ -5841,6 +5902,197 @@ mod win_watch { } } +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +mod linux_watch { + use std::{ + fs, + io::Write, + os::unix::{ + io::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + net::UnixStream, + }, + path::Path, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + }; + + use anyhow::{Context, Result, anyhow}; + use nectarlink_core::TaskNotify; + use serde::Serialize; + + static REQUEST_SEQ: AtomicU64 = AtomicU64::new(0); + + #[derive(Serialize)] + #[serde(tag = "kind", rename_all = "camelCase")] + enum DesktopRequest<'a> { + TaskNotify { + #[serde(skip_serializing_if = "Option::is_none")] + device: Option<&'a str>, + task: &'a TaskNotify, + }, + } + + pub fn is_desktop_running(data_dir: &Path) -> bool { + let lock_path = data_dir.join("instance.lock"); + if let Ok(file) = fs::File::open(&lock_path) { + // SAFETY: `file.as_raw_fd()` is a valid open file descriptor for the duration of this block. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + // SAFETY: Releasing the non-blocking probe lock immediately before closing `file`. + unsafe { + let _ = libc::flock(file.as_raw_fd(), libc::LOCK_UN); + } + } else { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EWOULDBLOCK) + || err.raw_os_error() == Some(libc::EAGAIN) + || err.kind() == std::io::ErrorKind::WouldBlock + { + return true; + } + } + } + let sock_path = data_dir.join("instance.sock"); + UnixStream::connect(sock_path).is_ok() + } + + pub fn queue_desktop_task_notify(data_dir: &Path, device: Option<&str>, task: &TaskNotify) -> Result<()> { + let dir = data_dir.join("requests"); + fs::create_dir_all(&dir).context("can't create desktop requests directory")?; + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + let seq = REQUEST_SEQ.fetch_add(1, Ordering::Relaxed); + let name = format!("{nanos:024}-{}-{seq:04}", std::process::id()); + let partial = dir.join(format!("{name}.tmp")); + let final_path = dir.join(format!("{name}.json")); + let payload = serde_json::to_vec(&DesktopRequest::TaskNotify { device, task })?; + let mut file = fs::File::create(&partial)?; + file.write_all(&payload)?; + file.sync_all()?; + drop(file); + fs::rename(&partial, &final_path)?; + + let sock_path = data_dir.join("instance.sock"); + if let Ok(mut stream) = UnixStream::connect(&sock_path) { + let _ = writeln!(stream, "{}", final_path.display()); + let _ = stream.flush(); + } + Ok(()) + } + + #[derive(Debug)] + pub struct WatchedProcess { + pub pid: u32, + pub name: String, + pub started_epoch_ms: u64, + pidfd: Option, + } + + fn parse_proc_stat_comm(stat_content: &str) -> Option { + let open = stat_content.find('(')?; + let close = stat_content.rfind(')')?; + if close <= open + 1 { + return None; + } + let comm = stat_content[open + 1..close].trim(); + (!comm.is_empty()).then(|| comm.to_owned()) + } + + fn parse_started_epoch_ms(stat_content: &str) -> Option { + let close = stat_content.rfind(')')?; + let after_comm = stat_content.get(close + 1..)?; + // Field 22 in `/proc//stat` is `starttime` (19th 0-indexed token after `(comm)`). + let starttime_ticks: u64 = after_comm.split_whitespace().nth(19)?.parse().ok()?; + + let proc_stat = fs::read_to_string("/proc/stat").ok()?; + let btime_secs: u64 = proc_stat + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .split_whitespace() + .next()? + .parse() + .ok()?; + + // SAFETY: `sysconf(_SC_CLK_TCK)` is a thread-safe POSIX query with no pointer arguments. + let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if clk_tck <= 0 { + return None; + } + let offset_ms = (u128::from(starttime_ticks) * 1000) / (clk_tck as u128); + Some(btime_secs.saturating_mul(1000).saturating_add(offset_ms as u64)) + } + + pub fn open_process(pid: u32) -> Result { + let stat_path = format!("/proc/{pid}/stat"); + let stat_content = fs::read_to_string(&stat_path) + .map_err(|_| anyhow!("cannot open PID {pid}: process does not exist"))?; + + let name = fs::read_to_string(format!("/proc/{pid}/comm")) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .or_else(|| parse_proc_stat_comm(&stat_content)) + .unwrap_or_else(|| format!("PID {pid}")); + + let started_epoch_ms = parse_started_epoch_ms(&stat_content).unwrap_or(0); + + let pidfd = if (pid as libc::pid_t) > 0 { + // SAFETY: `SYS_pidfd_open` with flags=0 returns a new file descriptor on success or -1 on error. + let raw = unsafe { libc::syscall(libc::SYS_pidfd_open, pid as libc::pid_t, 0 as libc::c_uint) }; + if raw >= 0 { + // SAFETY: `raw` is a valid, freshly opened file descriptor returned by `pidfd_open`. + Some(unsafe { OwnedFd::from_raw_fd(raw as RawFd) }) + } else { + None + } + } else { + None + }; + + Ok(WatchedProcess { pid, name, started_epoch_ms, pidfd }) + } + + fn proc_stat_alive(pid: u32) -> bool { + let Ok(stat_content) = fs::read_to_string(format!("/proc/{pid}/stat")) else { + return false; + }; + let Some(close) = stat_content.rfind(')') else { + return false; + }; + let state = stat_content.get(close + 1..).and_then(|s| s.split_whitespace().next()).unwrap_or(""); + !matches!(state, "Z" | "X" | "x") + } + + pub fn wait_process(proc: WatchedProcess) -> Duration { + let started_wait = Instant::now(); + if let Some(fd) = proc.pidfd.as_ref() { + let mut pfd = libc::pollfd { fd: fd.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + loop { + // SAFETY: `pfd` is a valid single-element `pollfd` referencing an open `pidfd`. + let rc = unsafe { libc::poll(&mut pfd, 1, -1) }; + if rc >= 0 { + break; + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::EINTR) { + break; + } + } + } else { + while proc_stat_alive(proc.pid) { + std::thread::sleep(Duration::from_millis(200)); + } + } + let waited = started_wait.elapsed(); + if proc.started_epoch_ms == 0 { + waited + } else { + let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64); + Duration::from_millis(now_ms.saturating_sub(proc.started_epoch_ms)).max(waited) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -6004,4 +6256,86 @@ mod tests { assert_eq!(json["device"], "pixel"); assert_eq!(json["task"]["id"], "test-1"); } + + #[cfg(target_os = "linux")] + #[test] + fn linux_watch_opens_self_and_rejects_invalid_pid() { + let me = linux_watch::open_process(std::process::id()).unwrap(); + assert_eq!(me.pid, std::process::id()); + assert!(!me.name.is_empty()); + assert!(me.started_epoch_ms > 1_700_000_000_000); + + let err = linux_watch::open_process(u32::MAX).unwrap_err(); + assert!(err.to_string().contains("process does not exist"), "unexpected error: {err}"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_watch_waits_for_short_lived_child() { + let mut child = std::process::Command::new("sh").args(["-c", "sleep 0.1"]).spawn().unwrap(); + let watched = linux_watch::open_process(child.id()).unwrap(); + assert!(!watched.name.is_empty()); + let wait_start = std::time::Instant::now(); + let elapsed = linux_watch::wait_process(watched); + let wall = wait_start.elapsed(); + let _ = child.wait(); + assert!(elapsed >= Duration::from_millis(50), "expected >= 50ms elapsed, got {elapsed:?}"); + assert!(wall < Duration::from_secs(5), "wait_process took too long: {wall:?}"); + } + + #[cfg(target_os = "linux")] + #[test] + #[allow(unsafe_code)] + fn linux_desktop_dir_lock_detection_and_request_queueing() { + use std::{ + io::Read, + os::unix::{io::AsRawFd, net::UnixListener}, + }; + + let dir = tempfile::tempdir().unwrap(); + assert!(!linux_watch::is_desktop_running(dir.path())); + + let lock_file = std::fs::File::create(dir.path().join("instance.lock")).unwrap(); + // Unlocked file alone does not indicate a running instance. + assert!(!linux_watch::is_desktop_running(dir.path())); + + // SAFETY: Acquiring an exclusive flock on `instance.lock` to simulate a running desktop instance. + let rc = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!(rc, 0); + assert!(linux_watch::is_desktop_running(dir.path())); + + let listener = UnixListener::bind(dir.path().join("instance.sock")).unwrap(); + let task = TaskNotify { + id: "test-linux-1".into(), + title: "cargo build".into(), + active: true, + elapsed_ms: 0, + exit_code: None, + }; + linux_watch::queue_desktop_task_notify(dir.path(), Some("pixel"), &task).unwrap(); + + let (mut stream, _) = listener.accept().unwrap(); + let mut notified_path = String::new(); + stream.read_to_string(&mut notified_path).unwrap(); + assert!(notified_path.ends_with(".json\n"), "unexpected socket payload: {notified_path:?}"); + + let entries: Vec<_> = std::fs::read_dir(dir.path().join("requests")) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .collect(); + assert_eq!(entries.len(), 1); + assert_eq!(notified_path.trim(), entries[0].to_string_lossy()); + let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&entries[0]).unwrap()).unwrap(); + assert_eq!(json["kind"], "taskNotify"); + assert_eq!(json["device"], "pixel"); + assert_eq!(json["task"]["id"], "test-linux-1"); + + // SAFETY: Unlocking `instance.lock` and removing `instance.sock`. + unsafe { + let _ = libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN); + } + drop(listener); + let _ = std::fs::remove_file(dir.path().join("instance.sock")); + assert!(!linux_watch::is_desktop_running(dir.path())); + } } diff --git a/desktop/app/Cargo.toml b/desktop/app/Cargo.toml index dc2710b6..0eaaa449 100644 --- a/desktop/app/Cargo.toml +++ b/desktop/app/Cargo.toml @@ -114,6 +114,9 @@ windows = { workspace = true, features = [ "Win32_Media_MediaFoundation", ] } +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + [build-dependencies] cxx-qt-build = { workspace = true, features = ["link_qt_object_files"] } embed-resource.workspace = true diff --git a/desktop/app/build.rs b/desktop/app/build.rs index 91478e66..121cbf86 100644 --- a/desktop/app/build.rs +++ b/desktop/app/build.rs @@ -237,6 +237,120 @@ END embed_resource::compile(&rc_path, embed_resource::NONE).manifest_required().expect("compile resources"); } +fn find_lrelease() -> Option { + if let Ok(custom) = std::env::var("LRELEASE") { + let p = PathBuf::from(custom); + if p.is_file() { + return Some(p); + } + } + if let Some(qmake) = std::env::var_os("QMAKE").map(PathBuf::from) + && let Some(bin_dir) = qmake.parent() + { + for name in ["lrelease", "lrelease.exe", "lrelease-qt6"] { + let candidate = bin_dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + for candidate in [ + "/usr/lib/qt6/bin/lrelease", + "/usr/lib64/qt6/bin/lrelease", + "/usr/bin/lrelease-qt6", + "/usr/bin/lrelease", + ] { + let p = PathBuf::from(candidate); + if p.is_file() { + return Some(p); + } + } + None +} + +fn offered_languages(manifest_dir: &Path) -> std::collections::HashSet { + let mut set = std::collections::HashSet::new(); + let i18n_rs = manifest_dir.join("src").join("i18n.rs"); + println!("cargo:rerun-if-changed={}", i18n_rs.display()); + let Ok(src) = fs::read_to_string(&i18n_rs) else { + return set; + }; + let Some((_, rest)) = src.split_once("pub const LANGUAGES") else { + return set; + }; + let Some((table, _)) = rest.split_once("];") else { + return set; + }; + for part in table.split('(').skip(1) { + if let Some(after_quote) = part.strip_prefix('"') + && let Some((code, _)) = after_quote.split_once('"') + && !code.is_empty() + { + set.insert(code.to_string()); + } + } + set +} + +fn compile_translations() { + let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else { + return; + }; + let manifest_path = Path::new(&manifest_dir); + let offered = offered_languages(manifest_path); + let i18n_dir = manifest_path.join("i18n"); + println!("cargo:rerun-if-changed={}", i18n_dir.display()); + let Ok(entries) = fs::read_dir(&i18n_dir) else { + return; + }; + let ts_files: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("ts")) + .collect(); + for ts in &ts_files { + println!("cargo:rerun-if-changed={}", ts.display()); + } + if ts_files.is_empty() { + return; + } + let Some(lrelease) = find_lrelease() else { + println!( + "cargo:warning=lrelease not found; skipping .qm compilation (install qt6-l10n-tools or set LRELEASE)" + ); + return; + }; + let Ok(out_dir) = std::env::var("OUT_DIR") else { + return; + }; + let out_path = PathBuf::from(out_dir); + let profile_dir = out_path.ancestors().nth(3).map(Path::to_path_buf); + + for ts in ts_files { + let Some(stem) = ts.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Some(lang) = stem.strip_prefix("nectarlink_") else { + continue; + }; + if !offered.contains(lang) { + continue; + } + let qm_name = format!("{stem}.qm"); + let out_qm = out_path.join(&qm_name); + let compiled = std::process::Command::new(&lrelease) + .arg("-silent") + .arg(&ts) + .arg("-qm") + .arg(&out_qm) + .status() + .is_ok_and(|s| s.success()); + if compiled && let Some(ref target_dir) = profile_dir { + let _ = fs::copy(&out_qm, target_dir.join(&qm_name)); + } + } +} + fn main() { println!("cargo:rerun-if-changed=src/mark.rs"); for qml in QML.iter().chain(QML_SINGLETONS.iter()) { @@ -245,6 +359,9 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { windows_resources(); } + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { + compile_translations(); + } 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))); @@ -283,8 +400,21 @@ fn main() { .build(); // The C++ window code calls DWM, Shell property store, and user32 directly. - println!("cargo:rustc-link-lib=dwmapi"); - println!("cargo:rustc-link-lib=ole32"); - println!("cargo:rustc-link-lib=shell32"); - println!("cargo:rustc-link-lib=user32"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + println!("cargo:rustc-link-lib=dwmapi"); + println!("cargo:rustc-link-lib=ole32"); + println!("cargo:rustc-link-lib=shell32"); + 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-arg=-Wl,-rpath,$ORIGIN:$ORIGIN/lib:$ORIGIN/../lib:$ORIGIN/../lib/nectarlink/lib" + ); + } + println!("cargo:rerun-if-changed=cpp/app_helpers.h"); + println!("cargo:rerun-if-changed=cpp/app_helpers.cpp"); + println!("cargo:rerun-if-changed=cpp/native_window.h"); + println!("cargo:rerun-if-changed=cpp/native_window.cpp"); + println!("cargo:rerun-if-changed=cpp/video_view.h"); + println!("cargo:rerun-if-changed=cpp/video_view.cpp"); } diff --git a/desktop/app/cpp/native_window.cpp b/desktop/app/cpp/native_window.cpp index cd675489..1731550b 100644 --- a/desktop/app/cpp/native_window.cpp +++ b/desktop/app/cpp/native_window.cpp @@ -8,6 +8,7 @@ #include +#ifdef _WIN32 #define WIN32_LEAN_AND_MEAN #include #include @@ -50,12 +51,19 @@ void trimD3DResources(QQuickWindow *window) } } // namespace +#else +namespace { +void trimD3DResources(QQuickWindow * /*window*/) {} +} // namespace +#endif 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); // 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. @@ -142,6 +150,7 @@ void NativeWindow::bringToFront() showNormal(); raise(); requestActivate(); +#ifdef _WIN32 if (!handle()) return; const HWND hwnd = hwndOf(this); @@ -160,14 +169,19 @@ void NativeWindow::bringToFront() SetForegroundWindow(hwnd); if (attached) AttachThreadInput(thisThread, foregroundThread, FALSE); +#endif } void NativeWindow::flash() { +#ifdef _WIN32 if (!handle()) return; FLASHWINFO info { sizeof(FLASHWINFO), hwndOf(this), FLASHW_TRAY | FLASHW_TIMERNOFG, 0, 0 }; FlashWindowEx(&info); +#else + alert(0); +#endif } void NativeWindow::releaseAndTeardown() @@ -208,6 +222,7 @@ bool NativeWindow::event(QEvent *event) void NativeWindow::applyFrame() { +#ifdef _WIN32 if (!handle()) return; const HWND hwnd = hwndOf(this); @@ -222,6 +237,7 @@ void NativeWindow::applyFrame() DwmSetWindowAttribute(hwnd, kSystemBackdropType, &backdrop, sizeof(backdrop)); const COLORREF none = kColorNone; DwmSetWindowAttribute(hwnd, kCaptionColor, &none, sizeof(none)); +#endif } bool NativeWindow::itemContains(const QQuickItem *item, const QPointF &scenePoint) const @@ -233,6 +249,7 @@ bool NativeWindow::itemContains(const QQuickItem *item, const QPointF &scenePoin long NativeWindow::hitTest(int screenX, int screenY) const { +#ifdef _WIN32 const HWND hwnd = hwndOf(this); POINT pt { screenX, screenY }; ScreenToClient(hwnd, &pt); @@ -264,10 +281,16 @@ long NativeWindow::hitTest(int screenX, int screenY) const return HTCLIENT; } return HTCAPTION; +#else + Q_UNUSED(screenX); + Q_UNUSED(screenY); + return 0; +#endif } bool NativeWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result) { +#ifdef _WIN32 if (eventType != "windows_generic_MSG") return QQuickWindow::nativeEvent(eventType, message, result); const MSG *msg = static_cast(message); @@ -325,5 +348,10 @@ 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/src/bridge/app.rs b/desktop/app/src/bridge/app.rs index 9c405931..bc299ae7 100644 --- a/desktop/app/src/bridge/app.rs +++ b/desktop/app/src/bridge/app.rs @@ -1770,7 +1770,7 @@ impl qobject::AppController { pub fn logs_url(&self) -> QString { let dir = crate::logging::logs_dir(&core_host::host().data_dir); - QString::from(&format!("file:///{}", dir.to_string_lossy().replace('\\', "/"))) + QString::from(&crate::icons::file_url(&dir)) } pub fn open_phone_storage(&self, device: &QString) { @@ -1967,9 +1967,16 @@ impl qobject::AppController { .map(|p| String::from(&p)) .filter(|p| !p.is_empty()) .or_else(|| { - raw.strip_prefix("file:///") - .or_else(|| raw.strip_prefix("file://")) - .map(|s| s.replace('/', "\\")) + #[cfg(windows)] + { + raw.strip_prefix("file:///") + .or_else(|| raw.strip_prefix("file://")) + .map(|s| s.replace('/', "\\")) + } + #[cfg(not(windows))] + { + raw.strip_prefix("file://").map(str::to_owned) + } }); if let Some(local_str) = local { let pb = std::path::PathBuf::from(&local_str); diff --git a/desktop/app/src/bridge/media.rs b/desktop/app/src/bridge/media.rs index bae92dba..61b2677d 100644 --- a/desktop/app/src/bridge/media.rs +++ b/desktop/app/src/bridge/media.rs @@ -410,9 +410,11 @@ mod tests { assert_eq!(get("positionAt").value::(), Some(1_500.0)); assert_eq!(get("canPause").value::(), Some(true)); assert_eq!(get("canPlay").value::(), Some(false)); - assert_eq!( - get("art").value::().map(|s| String::from(&s)), - Some("file:///C:/data/cache/art/a.jpg".into()) - ); + #[cfg(windows)] + let expected_art = "file:///C:/data/cache/art/a.jpg".to_string(); + #[cfg(not(windows))] + let expected_art = + String::from(&QUrl::from_local_file(&QString::from(r"C:\data\cache\art\a.jpg")).to_qstring()); + assert_eq!(get("art").value::().map(|s| String::from(&s)), Some(expected_art)); } } diff --git a/desktop/app/src/core_host.rs b/desktop/app/src/core_host.rs index 2a508970..04d30654 100644 --- a/desktop/app/src/core_host.rs +++ b/desktop/app/src/core_host.rs @@ -40,6 +40,7 @@ pub fn host() -> &'static CoreHost { } /// The running host's data directory, if the host has been started. +#[allow(dead_code)] pub fn try_data_dir() -> Option<&'static std::path::Path> { HOST.get().map(|h| h.data_dir.as_path()) } @@ -99,31 +100,39 @@ pub fn start(data_dir: PathBuf, platform: Arc) -> std::io::Result< /// Computes the PC's advertised capabilities, advertising capture (`mirror.capture`) /// and input injection (`mirror.input`, `input.inject`) only when enabled for at /// least one paired device (or actively sharing). -pub fn compute_capabilities(node: Option<&Node>) -> Vec { +pub fn compute_capabilities( + #[cfg_attr(not(windows), allow(unused_variables))] node: Option<&Node>, +) -> Vec { let mut caps = vec![ - "media.control".into(), "pc.power".into(), - nectarlink_core::PC_WAKE.into(), nectarlink_core::PHOTOS_SHOW.into(), nectarlink_core::CAMERA_CONTINUITY.into(), nectarlink_core::CALLS_SHOW.into(), nectarlink_core::CONTACTS_SHOW.into(), nectarlink_core::SMS_SHOW.into(), - nectarlink_core::MIRROR_VIEW.into(), - nectarlink_core::MIRROR_LISTEN.into(), - nectarlink_core::DECK_ACTIONS.into(), - nectarlink_core::PC_AUDIO.into(), - nectarlink_core::RECORDER.into(), nectarlink_core::TOGGLES_SHOW.into(), - nectarlink_core::STORAGE_MOUNT.into(), - nectarlink_core::STORAGE_READ.into(), - nectarlink_core::STORAGE_WRITE.into(), - nectarlink_core::WEBCAM_VIRTUAL.into(), - nectarlink_core::MEDIA_STREAM.into(), - nectarlink_core::MIC_LISTEN.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()); + 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()); + caps.push(nectarlink_core::MEDIA_STREAM.into()); + caps.push(nectarlink_core::MIC_LISTEN.into()); + } + #[cfg(windows)] let mut pc_mirror_enabled = crate::pc_mirror::is_active(); + #[cfg(windows)] let mut remote_input_enabled = false; + #[cfg(windows)] if let Some(node) = node && let Ok(devices) = node.paired_devices() { @@ -140,9 +149,11 @@ pub fn compute_capabilities(node: Option<&Node>) -> Vec { } } } + #[cfg(windows)] if pc_mirror_enabled { caps.push(nectarlink_core::MIRROR_CAPTURE.into()); } + #[cfg(windows)] if remote_input_enabled { caps.push(nectarlink_core::MIRROR_INPUT.into()); caps.push(nectarlink_core::INPUT_INJECT.into()); @@ -361,7 +372,7 @@ fn this_device() -> DeviceInfo { DeviceInfo { name, kind: if crate::win::is_laptop() { DeviceKind::Laptop } else { DeviceKind::Desktop }, - os: "windows".into(), + os: std::env::consts::OS.into(), os_ver: crate::win::os_version(), model: None, accent: None, @@ -392,3 +403,54 @@ pub fn shutdown() { } } } + +#[cfg(all(test, not(windows)))] +mod tests { + use super::*; + + #[test] + fn macos_advertises_only_supported_capabilities() { + let caps = compute_capabilities(None); + // Ensure unsupported features on macOS are never advertised + assert!(!caps.contains(&"mirror.view".into()), "macOS must not advertise mirror.view"); + assert!(!caps.contains(&"mirror.listen".into()), "macOS must not advertise mirror.listen"); + assert!(!caps.contains(&"media.control".into()), "macOS must not advertise media.control"); + assert!( + !caps.contains(&nectarlink_core::STORAGE_MOUNT.into()), + "macOS must not advertise storage.mount" + ); + assert!( + !caps.contains(&nectarlink_core::STORAGE_READ.into()), + "macOS must not advertise storage.read" + ); + assert!( + !caps.contains(&nectarlink_core::STORAGE_WRITE.into()), + "macOS must not advertise storage.write" + ); + assert!( + !caps.contains(&nectarlink_core::WEBCAM_VIRTUAL.into()), + "macOS must not advertise camera.virtual" + ); + assert!(!caps.contains(&nectarlink_core::PC_AUDIO.into()), "macOS must not advertise pc.audio"); + assert!(!caps.contains(&nectarlink_core::RECORDER.into()), "macOS must not advertise recorder"); + assert!( + !caps.contains(&nectarlink_core::DECK_ACTIONS.into()), + "macOS must not advertise deck.actions" + ); + assert!( + !caps.contains(&nectarlink_core::MEDIA_STREAM.into()), + "macOS must not advertise media.stream" + ); + assert!(!caps.contains(&nectarlink_core::MIC_LISTEN.into()), "macOS must not advertise mic.listen"); + assert!(!caps.contains(&nectarlink_core::PC_WAKE.into()), "macOS must not advertise pc.wake"); + + // Ensure supported features on macOS are advertised + assert!(caps.contains(&"pc.power".into())); + assert!(caps.contains(&nectarlink_core::PHOTOS_SHOW.into())); + assert!(caps.contains(&nectarlink_core::CAMERA_CONTINUITY.into())); + assert!(caps.contains(&nectarlink_core::CALLS_SHOW.into())); + assert!(caps.contains(&nectarlink_core::CONTACTS_SHOW.into())); + assert!(caps.contains(&nectarlink_core::SMS_SHOW.into())); + assert!(caps.contains(&nectarlink_core::TOGGLES_SHOW.into())); + } +} diff --git a/desktop/app/src/i18n.rs b/desktop/app/src/i18n.rs index 344d16c1..9a8f7b12 100644 --- a/desktop/app/src/i18n.rs +++ b/desktop/app/src/i18n.rs @@ -30,13 +30,39 @@ fn exe_dir() -> Option { std::env::current_exe().ok().and_then(|exe| exe.parent().map(Path::to_path_buf)) } +fn has_qm_files(dir: &Path) -> bool { + std::fs::read_dir(dir).is_ok_and(|entries| { + entries.flatten().any(|e| e.path().extension().and_then(|s| s.to_str()) == Some("qm")) + }) +} + +fn select_translation_dir(exe_dir: &Path) -> PathBuf { + if has_qm_files(exe_dir) { + return exe_dir.to_path_buf(); + } + #[cfg(target_os = "linux")] + { + if let Some(rel_share) = exe_dir.parent().map(|p| p.join("share").join("nectarlink")) + && has_qm_files(&rel_share) + { + return rel_share; + } + let sys_share = Path::new("/usr/share/nectarlink"); + if has_qm_files(sys_share) { + return sys_share.to_path_buf(); + } + } + exe_dir.to_path_buf() +} + /// Uses `language` ("" for the system's) from now on, retranslating the /// open windows. Call on the Qt thread, after the `QGuiApplication` exists. pub fn install(language: &str) { let language = if is_known(language) { language } else { "" }; - let Some(dir) = exe_dir() else { return }; + let Some(exe) = exe_dir() else { return }; + let dir = select_translation_dir(&exe); let installed = crate::bridge::native::ffi::install_translator(&dir.to_string_lossy(), language); - tracing::debug!(language, installed, "translator"); + tracing::debug!(language, dir = %dir.display(), installed, "translator"); } /// `text` (from `context`) in the app's language. Use [`tr!`], which also diff --git a/desktop/app/src/linux/audio.rs b/desktop/app/src/linux/audio.rs new file mode 100644 index 00000000..7f4ee91f --- /dev/null +++ b/desktop/app/src/linux/audio.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::AudioOutputDevice; + +pub fn speaker_state() -> Option<(u8, bool)> { + None +} +pub fn set_speaker_volume(_level: u8) -> Result<(), String> { + Ok(()) +} +pub fn set_speaker_mute(_muted: bool) -> Result<(), String> { + Ok(()) +} +pub fn speaker_volume_up() -> Result<(u8, bool), String> { + Ok((50, false)) +} +pub fn speaker_volume_down() -> Result<(u8, bool), String> { + Ok((50, false)) +} +pub fn toggle_speaker_mute() -> Result { + Ok(false) +} +pub fn mic_muted() -> Option { + None +} +pub fn toggle_mic_mute() -> Result { + Ok(false) +} +pub fn output_devices() -> Vec { + Vec::new() +} +pub fn set_default_output_device(_id: &str) -> Result<(), String> { + Ok(()) +} +pub fn watch(_changed: fn()) {} diff --git a/desktop/app/src/linux/audio_out.rs b/desktop/app/src/linux/audio_out.rs new file mode 100644 index 00000000..21aee306 --- /dev/null +++ b/desktop/app/src/linux/audio_out.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::io; + +pub struct Player; + +impl Player { + pub fn start(_rate: u32, _channels: u16) -> io::Result { + Ok(Player) + } + pub fn start_on(_device: Option, _rate: u32, _channels: u16) -> io::Result { + Ok(Player) + } + pub fn start_for_device(_rate: u32, _channels: u16, _device_id: Option<&str>) -> io::Result { + Ok(Player) + } + pub fn push(&self, _data: Vec) {} +} diff --git a/desktop/app/src/linux/autostart.rs b/desktop/app/src/linux/autostart.rs new file mode 100644 index 00000000..248b5fbf --- /dev/null +++ b/desktop/app/src/linux/autostart.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{io, path::Path}; + +pub fn command(exe: &Path) -> String { + format!("\"{}\" --minimized", exe.display()) +} +pub fn set(_enabled: bool, _exe: &Path) -> io::Result<()> { + Ok(()) +} +pub fn is_enabled() -> bool { + false +} +pub fn set_enabled(_enabled: bool) -> Result<(), String> { + Ok(()) +} +pub fn is_installed(_exe: &Path) -> bool { + false +} diff --git a/desktop/app/src/linux/ble_beacon.rs b/desktop/app/src/linux/ble_beacon.rs new file mode 100644 index 00000000..d167ec00 --- /dev/null +++ b/desktop/app/src/linux/ble_beacon.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::DeviceId; + +pub fn start(_device_id: DeviceId) { + let _ = crate::automations::TRIGGER_PHONE_WALKED_AWAY; +} +pub fn stop() {} diff --git a/desktop/app/src/linux/browser.rs b/desktop/app/src/linux/browser.rs new file mode 100644 index 00000000..c859dd88 --- /dev/null +++ b/desktop/app/src/linux/browser.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables, non_camel_case_types, clippy::upper_case_acronyms)] + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct HWND(pub *mut core::ffi::c_void); +unsafe impl Send for HWND {} +unsafe impl Sync for HWND {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RECT { + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Window { + pub hwnd: HWND, + pub program: String, + pub title: String, +} + +pub fn programs_for(_player: &str) -> Vec { + Vec::new() +} +pub fn is_browser(_program: &str) -> bool { + false +} +pub fn windows() -> Vec { + Vec::new() +} +pub fn window_for(_player: &str, _title: &str) -> Option { + None +} +pub fn use_physical_pixels() {} + +pub struct Automation; + +impl Automation { + pub fn new() -> Result { + Err("not supported on Linux yet".into()) + } + pub fn page_url(&self, _window: &Window) -> Option { + None + } + pub fn player_rect(&self, _window: &Window) -> Option { + None + } + pub fn page_rect(&self, _window: &Window) -> Option { + None + } +} + +pub fn is_minimized(_hwnd: HWND) -> bool { + false +} +pub fn restore_behind(_hwnd: HWND) {} +pub fn frame_of(_hwnd: HWND) -> Option { + None +} diff --git a/desktop/app/src/linux/bt_hfp.rs b/desktop/app/src/linux/bt_hfp.rs new file mode 100644 index 00000000..b0e56678 --- /dev/null +++ b/desktop/app/src/linux/bt_hfp.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HfpStatus { + pub available: bool, + pub connected: bool, + pub device_name: String, + pub detail: String, +} + +pub(crate) fn parse_bluetoothctl_devices(output: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for raw_line in output.lines() { + let line = raw_line.trim(); + let Some(rest) = line.strip_prefix("Device ") else { + continue; + }; + 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 { + continue; + }; + let name = parts.next().map(str::trim).unwrap_or_default().to_owned(); + out.push((mac.to_owned(), name)); + } + out +} + +pub(crate) fn parse_bluetoothctl_connected(info_output: &str) -> bool { + info_output.lines().any(|line| { + let trimmed = line.trim(); + trimmed.strip_prefix("Connected:").map(|v| v.trim().eq_ignore_ascii_case("yes")).unwrap_or(false) + }) +} + +pub fn probe_status(_phone_name: Option<&str>) -> HfpStatus { + HfpStatus::default() +} +pub fn connect_audio(_phone_name: Option<&str>) -> Result { + Ok(HfpStatus::default()) +} +pub fn disconnect_audio() {} +pub fn open_bluetooth_settings() {} diff --git a/desktop/app/src/linux/clipboard.rs b/desktop/app/src/linux/clipboard.rs new file mode 100644 index 00000000..436f10d6 --- /dev/null +++ b/desktop/app/src/linux/clipboard.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{path::PathBuf, sync::Mutex}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Image { + Png(Vec), + Bitmap(Vec), +} + +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()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Clip { + Text { text: String, html: Option, rtf: Option }, + Image(Image), + Private, + Empty, +} + +static FALLBACK_TEXT: Mutex> = Mutex::new(None); + +pub fn start(_on_copy: F) {} + +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, + } +} + +pub fn read_for_paste() -> Clip { + read() +} + +pub fn read_image_png() -> Option> { + 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(()) +} + +pub fn write_rich(text: &str, _html: Option<&str>, _rtf: Option<&str>) -> Result<(), String> { + write(text) +} + +pub fn write_private(text: &str) -> Result<(), String> { + write(text) +} + +pub fn write_image(_mime: &str, _bytes: &[u8]) -> Result<(), String> { + Ok(()) +} + +pub fn read_files() -> Option> { + None +} + +pub fn write_files(_paths: &[PathBuf]) -> bool { + false +} diff --git a/desktop/app/src/linux/doctor.rs b/desktop/app/src/linux/doctor.rs new file mode 100644 index 00000000..857233c5 --- /dev/null +++ b/desktop/app/src/linux/doctor.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{io, path::Path}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Firewall { + pub on: bool, + pub allowed: bool, + pub blocked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Network { + pub name: String, + pub trusted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Adapters { + pub local_address: bool, + pub vpn: bool, +} + +pub fn firewall(_exe: &Path) -> io::Result { + Ok(Firewall { on: false, allowed: true, blocked: false }) +} +pub fn networks() -> io::Result> { + Ok(Vec::new()) +} +pub fn adapters() -> Adapters { + Adapters { local_address: true, vpn: false } +} +pub fn fix_firewall(_exe: &Path) -> io::Result { + Ok(false) +} +pub fn open_firewall_settings() -> bool { + false +} diff --git a/desktop/app/src/linux/elevated.rs b/desktop/app/src/linux/elevated.rs new file mode 100644 index 00000000..0c64444b --- /dev/null +++ b/desktop/app/src/linux/elevated.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Linux privilege check (stub). + +use std::path::Path; + +#[derive(Debug)] +pub enum Error { + Declined, + Other(String), +} + +#[derive(Debug)] +pub struct Process; + +impl Process { + pub fn exit_code(&self) -> Option { + Some(1) + } +} + +pub fn is_elevated() -> bool { + // SAFETY: geteuid has no preconditions and is always safe to call on Linux. + unsafe { libc::geteuid() == 0 } +} + +pub fn start(_program: &Path, _params: &str) -> Result { + Err(Error::Other("elevated execution not supported on Linux yet".into())) +} + +pub fn run_elevated(_exe: &Path, _args: &[&str]) -> Result { + Err("elevated execution not supported on Linux yet".into()) +} diff --git a/desktop/app/src/linux/h264.rs b/desktop/app/src/linux/h264.rs new file mode 100644 index 00000000..91d1e3b7 --- /dev/null +++ b/desktop/app/src/linux/h264.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::io; + +#[derive(Debug, Clone)] +pub struct Picture { + pub width: u32, + pub height: u32, + pub stride: usize, + pub plane_rows: usize, + pub nv12: Vec, + pub colors: Colors, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Colors { + pub bt601: bool, + pub full_range: bool, +} + +pub struct Decoder; + +impl Decoder { + pub fn new() -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + } + pub fn new_for_codec(_codec: &str) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + } + pub fn new_gpu(_codec: &str) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + } + pub fn on_gpu(&self) -> bool { + false + } + pub fn codec(&self) -> &str { + "h264" + } + pub fn decode(&mut self, _data: &[u8], _time_us: u64) -> io::Result> { + Ok(Vec::new()) + } + pub fn flush(&mut self) {} +} + +pub fn to_bgrx(_picture: &Picture, _width: u32, _height: u32) -> Vec { + Vec::new() +} diff --git a/desktop/app/src/linux/http.rs b/desktop/app/src/linux/http.rs new file mode 100644 index 00000000..01e2273f --- /dev/null +++ b/desktop/app/src/linux/http.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::Path; + +pub fn get(_url: &str, _limit: usize) -> Result, String> { + Err("not supported on Linux yet".into()) +} +pub fn get_retrying(_url: &str, _limit: usize) -> Result, String> { + Err("not supported on Linux yet".into()) +} +pub fn download( + _url: &str, + _file: &Path, + _limit: u64, + _progress: impl FnMut(u64, u64), +) -> Result<(), String> { + Err("not supported on Linux yet".into()) +} +pub fn get_json(_url: &str) -> Result { + Err("not supported on Linux yet".into()) +} +pub fn download_to_file(_url: &str, _dest: &Path, _on_progress: impl FnMut(u64, u64)) -> Result<(), String> { + Err("not supported on Linux yet".into()) +} diff --git a/desktop/app/src/linux/icon.rs b/desktop/app/src/linux/icon.rs new file mode 100644 index 00000000..49762cbf --- /dev/null +++ b/desktop/app/src/linux/icon.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use std::fs; +use std::io::Result; +use std::path::Path; + +use crate::mark; + +use super::image::{Bitmap, encode_png}; + +pub fn write_png(path: &Path, size: u32) -> Result<()> { + let rgba = mark::render(size as usize); + let mut bgra = Vec::with_capacity(rgba.len()); + for px in rgba.as_chunks::<4>().0 { + bgra.push(px[2]); + bgra.push(px[1]); + bgra.push(px[0]); + bgra.push(px[3]); + } + 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); + } + fs::write(path, png) +} diff --git a/desktop/app/src/linux/image.rs b/desktop/app/src/linux/image.rs new file mode 100644 index 00000000..73167dd1 --- /dev/null +++ b/desktop/app/src/linux/image.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::io; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bitmap { + pub width: u32, + pub height: u32, + pub bgra: Vec, +} + +pub fn encode_png(_bitmap: &Bitmap) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) +} +pub fn encode_jpeg(_bitmap: &Bitmap, _quality: u8) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) +} +pub fn decode(_bytes: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) +} +pub fn scale_to(bitmap: &Bitmap, _max: u32) -> Bitmap { + bitmap.clone() +} +pub fn crop_square(bitmap: &Bitmap) -> Bitmap { + bitmap.clone() +} +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")) +} +pub fn bmp_from_dib(_dib: &[u8]) -> Option> { + None +} +pub fn dibv5(_bitmap: &Bitmap) -> Vec { + Vec::new() +} diff --git a/desktop/app/src/linux/input.rs b/desktop/app/src/linux/input.rs new file mode 100644 index 00000000..9396fb0f --- /dev/null +++ b/desktop/app/src/linux/input.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::{ButtonAction, KeyMod, MouseButton, SlideAction, TouchAction}; + +pub fn is_available() -> bool { + 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 +} diff --git a/desktop/app/src/linux/jumplist.rs b/desktop/app/src/linux/jumplist.rs new file mode 100644 index 00000000..18aae30f --- /dev/null +++ b/desktop/app/src/linux/jumplist.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::Path; + +pub const ARG: &str = "--task"; + +pub fn register(_custom_data_dir: Option<&Path>) {} +pub fn unregister() {} diff --git a/desktop/app/src/linux/media_sessions.rs b/desktop/app/src/linux/media_sessions.rs new file mode 100644 index 00000000..0a8f18a3 --- /dev/null +++ b/desktop/app/src/linux/media_sessions.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::{MediaAction, MediaError, MediaPlayer}; + +pub fn says_video(_id: &str) -> bool { + false +} +pub fn start) + Send + Sync + 'static>(_on_change: F) {} +pub fn command(_player: &str, _action: MediaAction, _position: Option) -> Result<(), MediaError> { + Ok(()) +} +pub fn pause_for_call() {} +pub fn resume_after_call() {} diff --git a/desktop/app/src/linux/mod.rs b/desktop/app/src/linux/mod.rs new file mode 100644 index 00000000..f1538cb6 --- /dev/null +++ b/desktop/app/src/linux/mod.rs @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Linux platform integration (`desktop/app/src/linux/`), matching the public +//! API of `desktop/app/src/win/`. + +#![allow(dead_code, unsafe_code)] + +pub mod audio; +pub mod audio_out; +pub mod autostart; +pub mod ble_beacon; +pub mod browser; +pub mod bt_hfp; +pub mod clipboard; +pub mod doctor; +pub mod elevated; +pub mod h264; +pub mod http; +pub mod icon; +pub mod image; +pub mod input; +pub mod jumplist; +pub mod media_sessions; +pub mod mp4; +pub mod net; +pub mod share_target; +pub mod shell; +pub mod shortcut; +pub mod single_instance; +pub mod smtc; +pub mod sound; +pub mod toast; +pub mod tray; +pub mod vcam; +pub mod vmic; +pub mod wallpaper; +pub mod widget; +pub mod wifi; +pub mod wol; + +use std::{fs, io::Read, process::Command}; + +/// A stable machine identifier from `/etc/machine-id` or `/var/lib/dbus/machine-id`. +pub fn machine_guid() -> Option { + for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] { + if let Ok(contents) = fs::read_to_string(path) { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_owned()); + } + } + } + None +} + +/// Human-readable Linux distribution and kernel version (e.g. `"Ubuntu 24.04 LTS (6.8.0)"`). +pub fn os_version() -> String { + let distro = fs::read_to_string("/etc/os-release") + .ok() + .and_then(|text| parse_os_release_pretty_name(&text)) + .unwrap_or_else(|| "Linux".to_owned()); + let kernel = fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()); + match kernel { + Some(k) => format!("{distro} ({k})"), + None => distro, + } +} + +fn parse_os_release_pretty_name(contents: &str) -> Option { + for line in contents.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("PRETTY_NAME=") { + let unquoted = rest.trim().trim_matches('"').trim_matches('\'').trim(); + if !unquoted.is_empty() { + return Some(unquoted.to_owned()); + } + } + } + None +} + +/// Returns a synthetic build number (`22621`) so Windows 11 feature gates in shared UI stay enabled. +pub fn os_build_number() -> u32 { + 22621 +} + +/// Whether this PC is a laptop (battery present in `/sys/class/power_supply` or mobile DMI chassis). +pub fn is_laptop() -> bool { + if let Ok(entries) = fs::read_dir("/sys/class/power_supply") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("BAT") { + return true; + } + if let Ok(kind) = fs::read_to_string(entry.path().join("type")) + && kind.trim().eq_ignore_ascii_case("Battery") + { + return true; + } + } + } + if let Ok(chassis) = fs::read_to_string("/sys/class/dmi/id/chassis_type") { + let c = chassis.trim(); + // 8 = Portable, 9 = Laptop, 10 = Notebook, 14 = Sub Notebook, + // 30 = Tablet, 31 = Convertible, 32 = Detachable + if matches!(c, "8" | "9" | "10" | "14" | "30" | "31" | "32") { + return true; + } + } + false +} + +/// Whether power-saver mode is active (`/sys/firmware/acpi/platform_profile` or `powerprofilesctl`). +pub fn power_saver() -> bool { + if let Ok(profile) = fs::read_to_string("/sys/firmware/acpi/platform_profile") + && profile.trim().eq_ignore_ascii_case("low-power") + { + return true; + } + if let Ok(output) = Command::new("powerprofilesctl").arg("get").output() + && output.status.success() + { + let text = String::from_utf8_lossy(&output.stdout); + if text.trim().eq_ignore_ascii_case("power-saver") { + return true; + } + } + false +} + +/// Parses GTK_THEME, gsettings color-scheme, and kdeglobals ColorScheme to decide dark mode. +pub(crate) fn parse_system_dark_sources( + gtk_theme: Option<&str>, + gsettings_color_scheme: Option<&str>, + kdeglobals: Option<&str>, +) -> bool { + if let Some(gtk) = gtk_theme { + let lower = gtk.to_ascii_lowercase(); + if lower.contains("dark") { + return true; + } + if lower.contains("light") { + return false; + } + } + if let Some(gs) = gsettings_color_scheme { + let text = gs.to_ascii_lowercase(); + if text.contains("prefer-dark") { + return true; + } + if text.contains("prefer-light") || text.contains("default") { + return false; + } + } + if let Some(kde) = kdeglobals { + for line in kde.lines() { + if let Some(scheme) = line.trim().strip_prefix("ColorScheme=") { + return scheme.to_ascii_lowercase().contains("dark"); + } + } + } + true +} + +/// Whether the desktop prefers a dark color scheme. +pub fn system_dark() -> bool { + let gtk_theme = std::env::var("GTK_THEME").ok(); + let gsettings = Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "color-scheme"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + let kdeglobals = dirs::config_dir().and_then(|c| fs::read_to_string(c.join("kdeglobals")).ok()); + parse_system_dark_sources(gtk_theme.as_deref(), gsettings.as_deref(), kdeglobals.as_deref()) +} + +/// Whether desktop animations are turned off. +pub fn reduce_motion() -> bool { + if let Ok(output) = + Command::new("gsettings").args(["get", "org.gnome.desktop.interface", "enable-animations"]).output() + && output.status.success() + { + let text = String::from_utf8_lossy(&output.stdout); + if text.trim() == "false" { + return true; + } + } + false +} + +/// Returns the usable desktop work area `(x, y, width, height)` in physical pixels. +pub fn work_area() -> Option<(i32, i32, i32, i32)> { + if let Ok(output) = Command::new("xprop").args(["-root", "_NET_WORKAREA"]).output() + && output.status.success() + { + let text = String::from_utf8_lossy(&output.stdout); + if let Some((_, rhs)) = text.split_once('=') { + let nums: Vec = + rhs.split(',').filter_map(|p| p.trim().parse::().ok()).take(4).collect(); + if let [x, y, w, h] = nums.as_slice() + && *w > 0 + && *h > 0 + { + return Some((*x, *y, *w, *h)); + } + } + } + Some((0, 0, 1920, 1080)) +} + +/// Fills `buf` with cryptographically strong random bytes from `/dev/urandom`. +pub fn secure_random(buf: &mut [u8]) -> bool { + if buf.is_empty() { + return true; + } + let Ok(mut file) = fs::File::open("/dev/urandom") else { + return false; + }; + file.read_exact(buf).is_ok() +} + +/// Runs `f` on the current thread (no-op COM wrapper on Linux). +pub fn with_com(f: impl FnOnce() -> T) -> T { + f() +} + +/// Milliseconds from UTC to local time at `at_ms` (Unix epoch ms). +/// East of UTC is positive. +pub fn local_offset_ms(at_ms: i64) -> i64 { + let secs = at_ms.div_euclid(1_000) as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + // SAFETY: `localtime_r` is thread-safe and writes into caller-provided `tm`. + let res = unsafe { libc::localtime_r(&secs, &mut tm) }; + if res.is_null() { + return 0; + } + (tm.tm_gmtoff as i64).saturating_mul(1_000) +} + +/// Bytes a normal user can still write to the drive holding `path`, or +/// `None` if `path` doesn't exist yet. +pub fn free_space(path: &std::path::Path) -> Option { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + // SAFETY: `c_path` is a valid NUL-terminated string and `stat` is valid writable memory. + if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 { + return None; + } + let block_size = if stat.f_frsize > 0 { stat.f_frsize as u64 } else { stat.f_bsize as u64 }; + Some((stat.f_bavail as u64).saturating_mul(block_size)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_os_info_without_panicking() { + assert!(!os_version().is_empty()); + assert_eq!(os_build_number(), 22621); + let _ = machine_guid(); + let _ = is_laptop(); + let _ = power_saver(); + let _ = system_dark(); + let _ = reduce_motion(); + assert!(work_area().is_some()); + } + + #[test] + fn parses_os_release_pretty_name() { + let sample = "NAME=\"Ubuntu\"\nPRETTY_NAME=\"Ubuntu 24.04.1 LTS\"\nID=ubuntu\n"; + assert_eq!(parse_os_release_pretty_name(sample).as_deref(), Some("Ubuntu 24.04.1 LTS")); + } + + #[test] + fn parses_system_dark_across_gtk_gnome_and_kde_sources() { + assert!(parse_system_dark_sources(Some("Adwaita-dark"), Some("'prefer-light'"), None)); + assert!(!parse_system_dark_sources(Some("Adwaita-light"), Some("'prefer-dark'"), None)); + assert!(parse_system_dark_sources(None, Some("'prefer-dark'\n"), None)); + assert!(!parse_system_dark_sources(None, Some("'prefer-light'\n"), None)); + assert!(!parse_system_dark_sources(None, Some("'default'\n"), None)); + assert!(parse_system_dark_sources(None, None, Some("[General]\nColorScheme=BreezeDark\n"))); + assert!(!parse_system_dark_sources(None, None, Some("[General]\nColorScheme=BreezeLight\n"))); + assert!(parse_system_dark_sources(None, None, None)); + } + + #[test] + fn fills_secure_random_bytes() { + let mut buf = [0u8; 32]; + assert!(secure_random(&mut buf)); + assert!(buf.iter().any(|&b| b != 0)); + } + + #[test] + fn runs_closure_with_com() { + assert_eq!(with_com(|| 42), 42); + } + + #[test] + fn local_offset_ms_returns_reasonable_timezone_offset() { + let offset = local_offset_ms(1_700_000_000_000); + assert!((-14 * 3_600_000..=14 * 3_600_000).contains(&offset)); + } + + #[test] + fn free_space_queries_existing_directory() { + let temp = std::env::temp_dir(); + assert!(free_space(&temp).is_some()); + assert!(free_space(std::path::Path::new("/nonexistent/nectarlink/path/xyz")).is_none()); + } +} diff --git a/desktop/app/src/linux/mp4.rs b/desktop/app/src/linux/mp4.rs new file mode 100644 index 00000000..a20fab51 --- /dev/null +++ b/desktop/app/src/linux/mp4.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{path::PathBuf, time::Instant}; + +pub fn local_timestamp_filename(prefix: &str, ext: &str) -> String { + format!("{prefix}.{ext}") +} + +pub fn merge_sps_pps(_dest: &mut Vec, _incoming: &[u8]) {} + +pub struct MirrorRecorder { + started: Instant, +} + +impl MirrorRecorder { + pub fn new( + _path: PathBuf, + _width: u32, + _height: u32, + _fps: u32, + _audio_cfg: Option<(u32, u8)>, + ) -> MirrorRecorder { + MirrorRecorder { started: Instant::now() } + } + pub fn started_at(&self) -> Instant { + self.started + } + pub fn started_epoch_ms(&self) -> i64 { + 0 + } + pub fn set_sps_pps(&mut self, _sps_pps: &[u8]) {} + pub fn on_config(&mut self, _width: u32, _height: u32) {} + pub fn on_audio_config(&mut self, _rate: u32, _channels: u8) {} + pub fn on_packet(&mut self, _keyframe: bool, _time_us: u64, _data: &[u8]) -> Result<(), String> { + Ok(()) + } + pub fn on_audio(&mut self, _time_us: u64, _pcm: &[u8]) -> Result<(), String> { + Ok(()) + } + pub fn finish(self) -> Result { + Err("not supported on Linux yet".into()) + } +} diff --git a/desktop/app/src/linux/net.rs b/desktop/app/src/linux/net.rs new file mode 100644 index 00000000..d95170c7 --- /dev/null +++ b/desktop/app/src/linux/net.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Watching for IP address and network route changes on Linux via a Netlink +//! `NETLINK_ROUTE` socket (with a `/proc/net/route` fallback), debounced so a +//! single network transition triggers one callback. + +use std::{ + fs, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +const DEBOUNCE: Duration = Duration::from_millis(500); + +const AF_NETLINK: i32 = 16; +const SOCK_RAW: i32 = 3; +const SOCK_CLOEXEC: i32 = 0o2000000; +const NETLINK_ROUTE: i32 = 0; +const SOL_SOCKET: i32 = 1; +const SO_RCVTIMEO: i32 = 20; +const RTMGRP_LINK: u32 = 1; +const RTMGRP_IPV4_IFADDR: u32 = 0x10; +const RTMGRP_IPV4_ROUTE: u32 = 0x40; +const RTMGRP_IPV6_IFADDR: u32 = 0x100; + +#[repr(C)] +struct SockAddrNl { + nl_family: u16, + nl_pad: u16, + nl_pid: u32, + nl_groups: u32, +} + +#[repr(C)] +struct Timeval { + tv_sec: i64, + tv_usec: i64, +} + +unsafe extern "C" { + fn socket(domain: i32, ty: i32, protocol: i32) -> i32; + fn setsockopt(fd: i32, level: i32, optname: i32, optval: *const std::ffi::c_void, optlen: u32) -> i32; + fn bind(fd: i32, addr: *const std::ffi::c_void, addrlen: u32) -> i32; + fn recv(fd: i32, buf: *mut std::ffi::c_void, len: usize, flags: i32) -> isize; + fn close(fd: i32) -> i32; +} + +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}") +} + +/// Calls `callback` whenever an IP address or route is added or removed on the PC. +/// Replacing an earlier watch stops the previous background thread. +pub fn watch(callback: impl Fn() + Send + Sync + 'static) { + stop(); + let stop_flag = Arc::new(AtomicBool::new(false)); + *STOP_FLAG.lock().unwrap_or_else(|e| e.into_inner()) = Some(stop_flag.clone()); + + let _ = std::thread::Builder::new().name("net-watch".into()).spawn(move || { + // Try opening an AF_NETLINK route socket subscribed to link + IPv4/IPv6 address groups. + // SAFETY: creating a standard non-privileged NETLINK_ROUTE socket with a receive timeout. + let fd = unsafe { socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE) }; + if fd >= 0 { + let bound = unsafe { + let addr = SockAddrNl { + nl_family: AF_NETLINK as u16, + nl_pad: 0, + nl_pid: 0, + nl_groups: RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR | RTMGRP_IPV4_ROUTE, + }; + let tv = Timeval { tv_sec: 1, tv_usec: 0 }; + let _ = setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + (&raw const tv).cast(), + std::mem::size_of::() as u32, + ); + bind(fd, (&raw const addr).cast(), std::mem::size_of::() as u32) == 0 + }; + if bound { + let mut buf = [0u8; 4096]; + let mut last_fired: Option = None; + while !stop_flag.load(Ordering::Relaxed) { + // SAFETY: `buf` is a live stack buffer and `fd` is our open socket. + let n = unsafe { recv(fd, buf.as_mut_ptr().cast(), buf.len(), 0) }; + if n > 0 && !stop_flag.load(Ordering::Relaxed) { + let now = Instant::now(); + if last_fired.is_none_or(|prev| now.duration_since(prev) >= DEBOUNCE) { + last_fired = Some(now); + callback(); + } + } + } + // SAFETY: closing our own socket fd. + unsafe { + let _ = close(fd); + } + return; + } + // SAFETY: closing our own socket fd when bind failed. + unsafe { + let _ = close(fd); + } + } + + // Fallback: poll `/proc/net/route` and `/proc/net/if_inet6`. + let mut last_snapshot = proc_network_snapshot(); + while !stop_flag.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_secs(2)); + if stop_flag.load(Ordering::Relaxed) { + break; + } + let current = proc_network_snapshot(); + if current != last_snapshot { + last_snapshot = current; + callback(); + } + } + }); +} + +/// Alias for [`watch`]. +pub fn watch_address_changes(on_change: impl Fn() + Send + Sync + 'static) { + watch(on_change); +} + +/// Stops watching for network address changes. +pub fn stop() { + if let Some(flag) = STOP_FLAG.lock().unwrap_or_else(|e| e.into_inner()).take() { + flag.store(true, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_and_stops_net_watcher_cleanly() { + watch(|| {}); + stop(); + } +} diff --git a/desktop/app/src/linux/share_target.rs b/desktop/app/src/linux/share_target.rs new file mode 100644 index 00000000..f1351ed3 --- /dev/null +++ b/desktop/app/src/linux/share_target.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::{Path, PathBuf}; +use tokio::sync::mpsc::UnboundedSender; + +pub const ARG: &str = "--share"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SharedPayload { + pub files: Vec, + pub url: Option, + pub text: Option, +} + +impl SharedPayload { + pub fn is_empty(&self) -> bool { + self.files.is_empty() && self.url.is_none() && self.text.is_none() + } +} + +pub fn register_listener(_tx: UnboundedSender) -> Result<(), String> { + Ok(()) +} +pub fn take_activated_share(_data_dir: &Path) -> Option { + None +} +pub fn parse_targets(targets: &[String]) -> SharedPayload { + let mut payload = SharedPayload::default(); + 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 payload.url.is_none() { + payload.url = Some(trimmed.to_owned()); + } + } else { + payload.files.push(PathBuf::from(trimmed)); + } + } + payload +} diff --git a/desktop/app/src/linux/shell.rs b/desktop/app/src/linux/shell.rs new file mode 100644 index 00000000..8d09c4fe --- /dev/null +++ b/desktop/app/src/linux/shell.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::Path; + +pub fn open_url(_url: &str) -> Result<(), String> { + Ok(()) +} +pub fn open_mailto(_address: &str) -> Result<(), String> { + Ok(()) +} +pub fn open_maps(_query: &str) -> Result<(), String> { + Ok(()) +} +pub fn is_url_scheme_registered(_scheme: &str) -> bool { + false +} +pub fn open_file(_path: &Path) -> Result<(), String> { + Ok(()) +} +pub fn open_folder(_path: &Path) -> Result<(), String> { + Ok(()) +} +pub fn select_in_explorer(_path: &Path) -> Result<(), String> { + Ok(()) +} +pub fn run(_program: &Path, _arguments: &str) -> Result<(), String> { + Ok(()) +} +pub fn lock() -> Result<(), String> { + Ok(()) +} +pub fn lock_workstation() -> Result<(), String> { + Ok(()) +} +pub fn sleep() -> Result<(), String> { + Ok(()) +} +pub fn sleep_system() -> Result<(), String> { + Ok(()) +} +pub fn take_screenshot() -> Result<(), String> { + Ok(()) +} +pub fn launch_app(_path: &Path) -> Result<(), String> { + Ok(()) +} +pub fn launch_executable(_path: &Path, _args: &[&str]) -> Result<(), String> { + Ok(()) +} +pub fn run_command(_command: &str) -> Result<(), String> { + Ok(()) +} +pub fn recycle_file(path: &Path) -> Result<(), String> { + std::fs::remove_file(path).map_err(|e| e.to_string()) +} diff --git a/desktop/app/src/linux/shortcut.rs b/desktop/app/src/linux/shortcut.rs new file mode 100644 index 00000000..35762b32 --- /dev/null +++ b/desktop/app/src/linux/shortcut.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{ + io, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shortcut { + pub target: PathBuf, + pub arguments: String, + pub description: String, +} + +pub fn write(path: &Path, shortcut: &Shortcut) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, format!("{}\n{}", shortcut.target.display(), shortcut.arguments)) +} + +pub fn target(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + text.lines().next().map(PathBuf::from) +} + +pub fn arguments(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + text.lines().nth(1).map(str::to_owned) +} + +pub fn startup_dir() -> Option { + None +} +pub fn send_to_dir() -> Option { + None +} +pub fn recent_dir() -> Option { + None +} diff --git a/desktop/app/src/linux/single_instance.rs b/desktop/app/src/linux/single_instance.rs new file mode 100644 index 00000000..711e51f3 --- /dev/null +++ b/desktop/app/src/linux/single_instance.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{io, path::Path}; + +pub struct PrimaryInstance; + +pub enum Instance { + Primary(PrimaryInstance), + Secondary, +} + +pub fn acquire(_data_dir: &Path) -> io::Result { + Ok(Instance::Primary(PrimaryInstance)) +} + +pub fn wake(_data_dir: &Path) {} + +impl PrimaryInstance { + pub fn watch(&'static self, _on_activate: impl Fn() + Send + 'static) {} +} diff --git a/desktop/app/src/linux/smtc.rs b/desktop/app/src/linux/smtc.rs new file mode 100644 index 00000000..8fb2ba63 --- /dev/null +++ b/desktop/app/src/linux/smtc.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::{DeviceId, MediaAction}; + +use crate::state::PlayerView; + +#[derive(Debug, Clone)] +pub struct Shown { + pub device: DeviceId, + pub device_name: String, + pub view: PlayerView, +} + +pub fn start) + Send + Sync + 'static>(_on_command: F) {} + +pub fn show(_current: Option) {} diff --git a/desktop/app/src/linux/sound.rs b/desktop/app/src/linux/sound.rs new file mode 100644 index 00000000..4deb3b68 --- /dev/null +++ b/desktop/app/src/linux/sound.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +pub fn start_ringing() {} +pub fn stop_ringing() {} diff --git a/desktop/app/src/linux/toast.rs b/desktop/app/src/linux/toast.rs new file mode 100644 index 00000000..00d807c6 --- /dev/null +++ b/desktop/app/src/linux/toast.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::PathBuf; + +pub const AUMID: &str = "Nectarlink.Desktop"; +pub const CALL_ANSWER: &str = "answer"; +pub const CALL_DECLINE: &str = "decline"; +pub const CALL_END: &str = "end"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToastEvent { + Opened { device: String, key: String }, + Action { device: String, key: String, action: String }, + Reply { device: String, key: String, action: String, text: String }, + Dismissed { device: String, key: String }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Toast { + pub device: String, + pub key: String, + pub title: String, + pub body: String, + pub attribution: String, + pub icon: Option, + pub image: Option, + pub actions: Vec<(String, String)>, + pub reply: Option<(String, String)>, + pub silent: bool, + pub progress: Option, + pub call: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Progress { + pub status: String, + pub value: Option, + pub label: String, +} + +pub fn enabled() -> bool { + false +} +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() {} diff --git a/desktop/app/src/linux/tray.rs b/desktop/app/src/linux/tray.rs new file mode 100644 index 00000000..e8429986 --- /dev/null +++ b/desktop/app/src/linux/tray.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::io; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayEvent { + Open, + CommandPalette, + Shelf, + TakePhoto, + ScanDocument, + FindPhone, + OpenLinkOnPhone, + Quit, + Resumed, + AppearanceChanged, +} + +#[derive(Debug, Clone)] +pub struct MenuLabels { + pub open: String, + pub command_palette: String, + pub shelf: String, + pub take_photo: String, + pub scan_document: String, + pub find_phone: String, + pub open_link: String, + pub quit: String, +} + +pub const MOD_ALT: u32 = 0x0001; +pub const MOD_CONTROL: u32 = 0x0002; +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 +} +pub fn is_window_alive_and_visible(_hwnd: isize) -> bool { + false +} +pub fn focus_window(_hwnd: isize) -> bool { + false +} +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)> { + None +} +pub fn update_hotkeys(_photo_spec: &str, _scan_spec: &str, _palette_spec: &str, _shelf_spec: &str) {} + +pub struct Tray; + +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) {} +} diff --git a/desktop/app/src/linux/vcam.rs b/desktop/app/src/linux/vcam.rs new file mode 100644 index 00000000..8f1d6cda --- /dev/null +++ b/desktop/app/src/linux/vcam.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::PathBuf; + +pub const CLSID_STR: &str = "{8E14549A-DB61-4309-AFA1-3578E927E935}"; +pub const FRIENDLY_NAME: &str = "NectarLink Virtual Camera"; +pub const REGISTER_ARG: &str = "--register-vcam"; +pub const UNREGISTER_ARG: &str = "--unregister-vcam"; + +pub fn dll_path() -> Option { + None +} +pub fn is_registered() -> bool { + false +} +pub fn ensure_registered() -> bool { + false +} +pub fn register_hklm() -> Result<(), String> { + Err("not supported on Linux yet".into()) +} +pub fn unregister_hklm() -> Result<(), String> { + Ok(()) +} +pub fn run_elevated(_arg: &'static str, on_done: impl FnOnce(bool) + Send + 'static) { + on_done(false); +} + +#[derive(Debug)] +pub struct VirtualCameraHandle; + +impl VirtualCameraHandle { + pub fn start() -> Result { + Err("not supported on Linux yet".into()) + } +} diff --git a/desktop/app/src/linux/vmic.rs b/desktop/app/src/linux/vmic.rs new file mode 100644 index 00000000..ad98bd0d --- /dev/null +++ b/desktop/app/src/linux/vmic.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +pub fn is_available() -> bool { + false +} +pub fn cleanup_stale_sources() {} + +pub struct VirtualMic; + +impl VirtualMic { + pub fn new_for_phone(_rate: u32, _input_channels: u8, _phone_name: Option<&str>) -> Self { + Self + } + pub fn is_active(&self) -> bool { + false + } + pub fn set_gain_db(&mut self, _gain_db: f32) {} + pub fn set_muted(&mut self, _muted: bool) {} + pub fn push_pcm(&mut self, _pcm: &[u8]) -> Result { + Ok(0.0) + } +} diff --git a/desktop/app/src/linux/wallpaper.rs b/desktop/app/src/linux/wallpaper.rs new file mode 100644 index 00000000..4b9be79b --- /dev/null +++ b/desktop/app/src/linux/wallpaper.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::Path; + +pub fn seed() -> Option { + None +} +pub fn seed_from_path(_path: &Path) -> Option { + None +} +pub fn artwork_scheme_from_path(_path: &Path) -> Option { + None +} diff --git a/desktop/app/src/linux/widget.rs b/desktop/app/src/linux/widget.rs new file mode 100644 index 00000000..2bebeaf1 --- /dev/null +++ b/desktop/app/src/linux/widget.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{path::Path, process::ExitCode}; + +pub const ARG: &str = "--widget-provider"; +pub const WIDGET_ID: &str = "NectarlinkPhoneStatus"; + +pub fn write_state_snapshot(_data_dir: &Path) {} +pub fn run_provider(_data_dir: &Path) -> ExitCode { + ExitCode::SUCCESS +} diff --git a/desktop/app/src/linux/wifi.rs b/desktop/app/src/linux/wifi.rs new file mode 100644 index 00000000..b9030a8a --- /dev/null +++ b/desktop/app/src/linux/wifi.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Linux Wi-Fi scan and connection helper for Instant Hotspot (stub). + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reach { + NotInRange, + InRange, + Connected, +} + +pub fn saved_networks() -> Vec { + Vec::new() +} + +pub fn scan() {} + +pub fn reach(_profile: &str) -> Reach { + Reach::NotInRange +} + +pub fn connect(_profile: &str) -> Result<(), String> { + Err("Wi-Fi hotspot connection not supported on Linux yet".into()) +} diff --git a/desktop/app/src/linux/wol.rs b/desktop/app/src/linux/wol.rs new file mode 100644 index 00000000..82ca300b --- /dev/null +++ b/desktop/app/src/linux/wol.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WakeSupport { + Enabled, + Disabled, + Unknown, +} + +impl WakeSupport { + pub fn as_str(self) -> &'static str { + match self { + WakeSupport::Enabled => "enabled", + WakeSupport::Disabled => "disabled", + WakeSupport::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WakeAdapter { + pub label: String, + pub wired: bool, + pub mac: String, + pub broadcasts: Vec, + pub support: WakeSupport, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WakeStatus { + pub info: nectarlink_core::PcWakeInfo, + pub adapters: Vec, +} + +impl WakeStatus { + pub fn primary(&self) -> Option<&WakeAdapter> { + self.adapters.first() + } + pub fn state_str(&self) -> &'static str { + "none" + } +} + +pub fn query_wake_status() -> WakeStatus { + WakeStatus { info: nectarlink_core::PcWakeInfo::default(), adapters: Vec::new() } +} diff --git a/desktop/app/src/main.rs b/desktop/app/src/main.rs index 29971f08..89b820e2 100644 --- a/desktop/app/src/main.rs +++ b/desktop/app/src/main.rs @@ -56,6 +56,10 @@ mod storage; mod transfers; mod updater; mod webcam; +#[cfg(windows)] +mod win; +#[cfg(target_os = "linux")] +#[path = "linux/mod.rs"] mod win; use std::{ diff --git a/desktop/app/src/media_file.rs b/desktop/app/src/media_file.rs index 98685884..c5a0ae8f 100644 --- a/desktop/app/src/media_file.rs +++ b/desktop/app/src/media_file.rs @@ -4,6 +4,8 @@ //! shortcut to every file opened lately (the Recent folder), and the //! player's title or its window's names the file. +#![cfg_attr(not(windows), allow(dead_code))] + use std::path::{Path, PathBuf}; use crate::win::{browser, shortcut}; diff --git a/desktop/app/src/mirror.rs b/desktop/app/src/mirror.rs index c2991991..8099443c 100644 --- a/desktop/app/src/mirror.rs +++ b/desktop/app/src/mirror.rs @@ -1354,6 +1354,7 @@ pub fn leave_window() { } /// Records text that arrived from a remote device so focus/hover sync never echoes it back. +#[allow(dead_code)] pub fn note_remote_clipboard(text: &str) { let normalized = text.replace("\r\n", "\n"); state(|s| { @@ -1404,10 +1405,13 @@ pub fn sync_clipboard_on_focus(window: Window) { } /// Standard XInput / Xbox left thumbstick deadzone. +#[allow(dead_code)] pub const LEFT_THUMB_DEADZONE: i16 = 7849; /// Standard XInput / Xbox right thumbstick deadzone. +#[allow(dead_code)] pub const RIGHT_THUMB_DEADZONE: i16 = 8689; /// Standard XInput / Xbox trigger threshold. +#[allow(dead_code)] pub const TRIGGER_THRESHOLD: u8 = 30; /// Normalized controller snapshot forwarded via [`nectarlink_core::MirrorInput::Gamepad`]. @@ -1437,12 +1441,14 @@ impl GamepadState { } } +#[allow(dead_code)] fn deadzone_axis(val: i16, deadzone: i16) -> i16 { if val.saturating_abs() <= deadzone { 0 } else { val } } /// Applies stick deadzones and trigger thresholds so idle controller noise does /// not generate redundant [`nectarlink_core::MirrorInput::Gamepad`] frames. +#[allow(dead_code)] pub fn normalize_gamepad_state(raw: GamepadState) -> GamepadState { GamepadState { buttons: raw.buttons, @@ -1455,6 +1461,7 @@ pub fn normalize_gamepad_state(raw: GamepadState) -> GamepadState { } } +#[cfg(windows)] #[allow(unsafe_code)] fn poll_xinput_gamepad() -> Option { use windows::Win32::UI::Input::XboxController::{XINPUT_STATE, XInputGetState}; @@ -1477,6 +1484,7 @@ fn poll_xinput_gamepad() -> Option { None } +#[cfg(windows)] fn poll_winrt_gamepad() -> Option { use windows::Gaming::Input::{Gamepad, GamepadButtons}; let gamepads = Gamepad::Gamepads().ok()?; @@ -1542,10 +1550,16 @@ fn poll_winrt_gamepad() -> Option { })) } +#[cfg(windows)] fn poll_gamepad() -> Option { poll_xinput_gamepad().or_else(poll_winrt_gamepad) } +#[cfg(not(windows))] +fn poll_gamepad() -> Option { + None +} + fn ensure_gamepad_loop() { static RUNNING: AtomicBool = AtomicBool::new(false); if RUNNING.swap(true, Ordering::AcqRel) { diff --git a/desktop/app/src/pc_audio_stream.rs b/desktop/app/src/pc_audio_stream.rs index 03e7efb1..5cd0a97b 100644 --- a/desktop/app/src/pc_audio_stream.rs +++ b/desktop/app/src/pc_audio_stream.rs @@ -4,394 +4,425 @@ //! 32-bit float stereo samples to 16-bit signed little-endian PCM, and streams //! them to connected phones over NectarLink's low-latency audio stream. -#![allow(unsafe_code)] - -use std::{ - sync::atomic::{AtomicBool, Ordering}, - time::Duration, -}; - -use windows::Win32::{ - Media::Audio::{ - AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK, - IAudioCaptureClient, IAudioClient, IMMDeviceEnumerator, MMDeviceEnumerator, WAVEFORMATEX, eConsole, - eRender, - }, - System::{ - Com::{CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree}, - Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, - }, -}; - -use crate::core_host; - -static ENABLED: AtomicBool = AtomicBool::new(false); -static WORKER_RUNNING: AtomicBool = AtomicBool::new(false); -/// The phone that last turned "Phone speaker" on. Audio goes there while it's online; -/// when the PC turned it on (or that phone is away), to the first phone that is. -static TARGET: std::sync::Mutex> = std::sync::Mutex::new(None); - -/// The phone the sound goes to first, if one asked. -pub fn target() -> Option { - *TARGET.lock().unwrap_or_else(|e| e.into_inner()) -} +#[cfg(windows)] +mod imp { -/// Records which phone asked to be the speaker. -pub fn set_target(phone: Option) { - *TARGET.lock().unwrap_or_else(|e| e.into_inner()) = phone; -} + #![allow(unsafe_code)] -/// Returns whether PC-to-phone audio streaming is currently enabled. -pub fn is_enabled() -> bool { - ENABLED.load(Ordering::SeqCst) -} + use std::{ + sync::atomic::{AtomicBool, Ordering}, + time::Duration, + }; -/// Enables or disables PC-to-phone audio loopback streaming. -pub fn set_enabled(enabled: bool) { - let prev = ENABLED.swap(enabled, Ordering::SeqCst); - if enabled && !prev && core_host::node().is_some() { - ensure_worker(); + use windows::Win32::{ + Media::Audio::{ + AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK, + IAudioCaptureClient, IAudioClient, IMMDeviceEnumerator, MMDeviceEnumerator, WAVEFORMATEX, + eConsole, eRender, + }, + System::{ + Com::{CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree}, + Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, + }, + }; + + use crate::core_host; + + static ENABLED: AtomicBool = AtomicBool::new(false); + static WORKER_RUNNING: AtomicBool = AtomicBool::new(false); + /// The phone that last turned "Phone speaker" on. Audio goes there while it's online; + /// when the PC turned it on (or that phone is away), to the first phone that is. + static TARGET: std::sync::Mutex> = std::sync::Mutex::new(None); + + /// The phone the sound goes to first, if one asked. + pub fn target() -> Option { + *TARGET.lock().unwrap_or_else(|e| e.into_inner()) } -} -/// Converts interleaved stereo `f32` samples (`[-1.0, 1.0]`) into 16-bit -/// signed little-endian PCM bytes (`pcm_s16le`). -#[cfg(test)] -pub fn f32_stereo_to_i16le(samples: &[f32]) -> Vec { - let mut out = Vec::with_capacity(samples.len() * 2); - for &sample in samples { - let clean = if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 }; - let scaled = (clean * 32767.0).round() as i16; - out.extend_from_slice(&scaled.to_le_bytes()); + /// Records which phone asked to be the speaker. + pub fn set_target(phone: Option) { + *TARGET.lock().unwrap_or_else(|e| e.into_inner()) = phone; } - out -} -#[derive(Clone, Copy)] -struct Biquad { - b0: f32, - b1: f32, - b2: f32, - a1: f32, - a2: f32, - z1_l: f32, - z2_l: f32, - z1_r: f32, - z2_r: f32, -} + /// Returns whether PC-to-phone audio streaming is currently enabled. + pub fn is_enabled() -> bool { + ENABLED.load(Ordering::SeqCst) + } -impl Biquad { - fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32) -> Self { - let a = 10.0_f32.powf(gain_db / 40.0); - let w0 = 2.0 * std::f32::consts::PI * (freq / sample_rate.max(8_000.0)).clamp(0.001, 0.45); - let cos_w0 = w0.cos(); - let sin_w0 = w0.sin(); - let alpha = sin_w0 / 2.0 * std::f32::consts::SQRT_2; - let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha; - - let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha); - let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0); - let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha); - let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha; - let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0); - let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha; - - Self { - b0: b0 / a0, - b1: b1 / a0, - b2: b2 / a0, - a1: a1 / a0, - a2: a2 / a0, - z1_l: 0.0, - z2_l: 0.0, - z1_r: 0.0, - z2_r: 0.0, + /// Enables or disables PC-to-phone audio loopback streaming. + pub fn set_enabled(enabled: bool) { + let prev = ENABLED.swap(enabled, Ordering::SeqCst); + if enabled && !prev && core_host::node().is_some() { + ensure_worker(); } } - fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32) -> Self { - let a = 10.0_f32.powf(gain_db / 40.0); - let w0 = 2.0 * std::f32::consts::PI * (freq / sample_rate.max(8_000.0)).clamp(0.001, 0.45); - let cos_w0 = w0.cos(); - let sin_w0 = w0.sin(); - let alpha = sin_w0 / 2.0 * std::f32::consts::SQRT_2; - let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha; - - let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha); - let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0); - let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha); - let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha; - let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0); - let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha; - - Self { - b0: b0 / a0, - b1: b1 / a0, - b2: b2 / a0, - a1: a1 / a0, - a2: a2 / a0, - z1_l: 0.0, - z2_l: 0.0, - z1_r: 0.0, - z2_r: 0.0, + /// Converts interleaved stereo `f32` samples (`[-1.0, 1.0]`) into 16-bit + /// signed little-endian PCM bytes (`pcm_s16le`). + #[cfg(test)] + pub fn f32_stereo_to_i16le(samples: &[f32]) -> Vec { + let mut out = Vec::with_capacity(samples.len() * 2); + for &sample in samples { + let clean = if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 }; + let scaled = (clean * 32767.0).round() as i16; + out.extend_from_slice(&scaled.to_le_bytes()); } + out } - #[inline] - fn process_stereo(&mut self, l: f32, r: f32) -> (f32, f32) { - let out_l = self.b0 * l + self.z1_l; - self.z1_l = self.b1 * l - self.a1 * out_l + self.z2_l; - self.z2_l = self.b2 * l - self.a2 * out_l; - - let out_r = self.b0 * r + self.z1_r; - self.z1_r = self.b1 * r - self.a1 * out_r + self.z2_r; - self.z2_r = self.b2 * r - self.a2 * out_r; - (out_l, out_r) + #[derive(Clone, Copy)] + struct Biquad { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + z1_l: f32, + z2_l: f32, + z1_r: f32, + z2_r: f32, } -} -struct SpeakerEnhancer { - low_shelf: Biquad, - high_shelf: Biquad, -} + impl Biquad { + fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32) -> Self { + let a = 10.0_f32.powf(gain_db / 40.0); + let w0 = 2.0 * std::f32::consts::PI * (freq / sample_rate.max(8_000.0)).clamp(0.001, 0.45); + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / 2.0 * std::f32::consts::SQRT_2; + let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha; + + let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha); + let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0); + let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha); + let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha; + let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0); + let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha; + + Self { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + z1_l: 0.0, + z2_l: 0.0, + z1_r: 0.0, + z2_r: 0.0, + } + } -impl SpeakerEnhancer { - fn new(sample_rate: u32) -> Self { - let sr = sample_rate as f32; - Self { low_shelf: Biquad::low_shelf(sr, 155.0, 3.5), high_shelf: Biquad::high_shelf(sr, 3800.0, 1.5) } - } + fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32) -> Self { + let a = 10.0_f32.powf(gain_db / 40.0); + let w0 = 2.0 * std::f32::consts::PI * (freq / sample_rate.max(8_000.0)).clamp(0.001, 0.45); + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / 2.0 * std::f32::consts::SQRT_2; + let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha; + + let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha); + let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0); + let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha); + let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha; + let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0); + let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha; + + Self { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + z1_l: 0.0, + z2_l: 0.0, + z1_r: 0.0, + z2_r: 0.0, + } + } - #[inline] - fn soft_limit(x: f32) -> f32 { - if !x.is_finite() { - return 0.0; + #[inline] + fn process_stereo(&mut self, l: f32, r: f32) -> (f32, f32) { + let out_l = self.b0 * l + self.z1_l; + self.z1_l = self.b1 * l - self.a1 * out_l + self.z2_l; + self.z2_l = self.b2 * l - self.a2 * out_l; + + let out_r = self.b0 * r + self.z1_r; + self.z1_r = self.b1 * r - self.a1 * out_r + self.z2_r; + self.z2_r = self.b2 * r - self.a2 * out_r; + (out_l, out_r) } - let boosted = x * 1.12; - (boosted / (1.0 + 0.18 * boosted.abs())).clamp(-1.0, 1.0) } - fn encode_stereo_f32(&mut self, floats: &[f32], channels: usize) -> Vec { - let frames = floats.len() / channels.max(1); - let mut out = Vec::with_capacity(frames * 4); - for frame in floats.chunks_exact(channels.max(1)) { - let l_in = if frame[0].is_finite() { frame[0] } else { 0.0 }; - let r_in = if channels > 1 && frame[1].is_finite() { frame[1] } else { l_in }; - let (l1, r1) = self.low_shelf.process_stereo(l_in, r_in); - let (l2, r2) = self.high_shelf.process_stereo(l1, r1); - let l_i16 = (Self::soft_limit(l2) * 32767.0).round() as i16; - let r_i16 = (Self::soft_limit(r2) * 32767.0).round() as i16; - out.extend_from_slice(&l_i16.to_le_bytes()); - out.extend_from_slice(&r_i16.to_le_bytes()); - } - out + struct SpeakerEnhancer { + low_shelf: Biquad, + high_shelf: Biquad, } - fn encode_stereo_i16(&mut self, samples: &[i16], channels: usize) -> Vec { - let frames = samples.len() / channels.max(1); - let mut out = Vec::with_capacity(frames * 4); - for frame in samples.chunks_exact(channels.max(1)) { - let l_in = (frame[0] as f32) / 32768.0; - let r_in = if channels > 1 { (frame[1] as f32) / 32768.0 } else { l_in }; - let (l1, r1) = self.low_shelf.process_stereo(l_in, r_in); - let (l2, r2) = self.high_shelf.process_stereo(l1, r1); - let l_i16 = (Self::soft_limit(l2) * 32767.0).round() as i16; - let r_i16 = (Self::soft_limit(r2) * 32767.0).round() as i16; - out.extend_from_slice(&l_i16.to_le_bytes()); - out.extend_from_slice(&r_i16.to_le_bytes()); + impl SpeakerEnhancer { + fn new(sample_rate: u32) -> Self { + let sr = sample_rate as f32; + Self { + low_shelf: Biquad::low_shelf(sr, 155.0, 3.5), + high_shelf: Biquad::high_shelf(sr, 3800.0, 1.5), + } } - out - } -} -fn ensure_worker() { - if WORKER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { - return; - } - std::thread::Builder::new() - .name("nectarlink-pc-audio-loopback".into()) - .spawn(|| { - run_loopback_worker(); - WORKER_RUNNING.store(false, Ordering::SeqCst); - }) - .ok(); -} + #[inline] + fn soft_limit(x: f32) -> f32 { + if !x.is_finite() { + return 0.0; + } + let boosted = x * 1.12; + (boosted / (1.0 + 0.18 * boosted.abs())).clamp(-1.0, 1.0) + } -fn run_loopback_worker() { - // SAFETY: Initialize COM and elevate thread priority for low-latency audio capture. - unsafe { - let _ = CoInitializeEx(None, COINIT_MULTITHREADED); - let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); - } + fn encode_stereo_f32(&mut self, floats: &[f32], channels: usize) -> Vec { + let frames = floats.len() / channels.max(1); + let mut out = Vec::with_capacity(frames * 4); + for frame in floats.chunks_exact(channels.max(1)) { + let l_in = if frame[0].is_finite() { frame[0] } else { 0.0 }; + let r_in = if channels > 1 && frame[1].is_finite() { frame[1] } else { l_in }; + let (l1, r1) = self.low_shelf.process_stereo(l_in, r_in); + let (l2, r2) = self.high_shelf.process_stereo(l1, r1); + let l_i16 = (Self::soft_limit(l2) * 32767.0).round() as i16; + let r_i16 = (Self::soft_limit(r2) * 32767.0).round() as i16; + out.extend_from_slice(&l_i16.to_le_bytes()); + out.extend_from_slice(&r_i16.to_le_bytes()); + } + out + } - while is_enabled() { - if let Err(e) = capture_and_stream_once() { - tracing::debug!(error = %e, "PC audio loopback stream cycle ended"); - std::thread::sleep(Duration::from_millis(500)); + fn encode_stereo_i16(&mut self, samples: &[i16], channels: usize) -> Vec { + let frames = samples.len() / channels.max(1); + let mut out = Vec::with_capacity(frames * 4); + for frame in samples.chunks_exact(channels.max(1)) { + let l_in = (frame[0] as f32) / 32768.0; + let r_in = if channels > 1 { (frame[1] as f32) / 32768.0 } else { l_in }; + let (l1, r1) = self.low_shelf.process_stereo(l_in, r_in); + let (l2, r2) = self.high_shelf.process_stereo(l1, r1); + let l_i16 = (Self::soft_limit(l2) * 32767.0).round() as i16; + let r_i16 = (Self::soft_limit(r2) * 32767.0).round() as i16; + out.extend_from_slice(&l_i16.to_le_bytes()); + out.extend_from_slice(&r_i16.to_le_bytes()); + } + out } } -} -fn capture_and_stream_once() -> Result<(), String> { - let node = core_host::node().ok_or_else(|| "core node not running".to_owned())?; - let peers: Vec<_> = node - .paired_devices() - .unwrap_or_default() - .into_iter() - .filter(|d| matches!(d.link, nectarlink_core::LinkState::Online { .. })) - .map(|d| d.id) - .collect(); - let wanted = *TARGET.lock().unwrap_or_else(|e| e.into_inner()); - let Some(&peer) = wanted.as_ref().filter(|w| peers.contains(w)).or_else(|| peers.first()) else { - std::thread::sleep(Duration::from_millis(500)); - return Ok(()); - }; + fn ensure_worker() { + if WORKER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { + return; + } + std::thread::Builder::new() + .name("nectarlink-pc-audio-loopback".into()) + .spawn(|| { + run_loopback_worker(); + WORKER_RUNNING.store(false, Ordering::SeqCst); + }) + .ok(); + } - // SAFETY: WASAPI loopback initialization on the default render endpoint. - let (client, capture, rate, channels, bits_per_sample) = unsafe { - let enumerator: IMMDeviceEnumerator = - CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL).map_err(|e| e.to_string())?; - let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole).map_err(|e| e.to_string())?; - let client: IAudioClient = device.Activate(CLSCTX_ALL, None).map_err(|e| e.to_string())?; - let mix_ptr: *mut WAVEFORMATEX = client.GetMixFormat().map_err(|e| e.to_string())?; - if mix_ptr.is_null() { - return Err("GetMixFormat returned null".into()); + fn run_loopback_worker() { + // SAFETY: Initialize COM and elevate thread priority for low-latency audio capture. + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); } - let mix = *mix_ptr; - let rate = mix.nSamplesPerSec.clamp(8_000, 96_000); - let channels = mix.nChannels.max(1) as usize; - let bits = mix.wBitsPerSample; - // 10 ms shared loopback buffer in 100-ns units = 100_000. - let init_res = client.Initialize( - AUDCLNT_SHAREMODE_SHARED, - AUDCLNT_STREAMFLAGS_LOOPBACK, - 100_000, - 0, - mix_ptr, - None, - ); - CoTaskMemFree(Some(mix_ptr.cast())); - init_res.map_err(|e| e.to_string())?; - let capture: IAudioCaptureClient = client.GetService().map_err(|e| e.to_string())?; - client.Start().map_err(|e| e.to_string())?; - (client, capture, rate, channels, bits) - }; - let mut enhancer = SpeakerEnhancer::new(rate); - let (tx, mut rx) = tokio::sync::mpsc::channel::<(u32, u8, Vec)>(64); - let send_node = node.clone(); - core_host::spawn(async move { - while let Some((r, ch, pcm)) = rx.recv().await { - // Slice into 960-byte (5 ms at 48 kHz stereo) sub-MTU chunks so each - // chunk travels in a single QUIC datagram without HOL blocking. - for chunk in pcm.chunks(960) { - if send_node.send_pc_audio_pcm(&peer, r, ch, chunk.to_vec()).await.is_err() { - return; - } + while is_enabled() { + if let Err(e) = capture_and_stream_once() { + tracing::debug!(error = %e, "PC audio loopback stream cycle ended"); + std::thread::sleep(Duration::from_millis(500)); } } - }); - - // Keep sending zero-filled PCM for ~500 ms after audible audio stops so brief - // pauses between words or beats never starve the phone's AudioTrack ringbuffer. - let mut silence_tail_packets: u32 = 0; - - 'capture: while is_enabled() { - std::thread::sleep(Duration::from_millis(2)); - // Another phone asked for the sound (it watches the PC's playback): - // start over for that one. - if target() != wanted && target().is_some_and(|t| peers.contains(&t)) { - break 'capture; - } - // SAFETY: Drain available capture packets from `IAudioCaptureClient`. - unsafe { - loop { - let Ok(packet_frames) = capture.GetNextPacketSize() else { break }; - if packet_frames == 0 { - break; - } - let mut data_ptr: *mut u8 = std::ptr::null_mut(); - let mut frames_to_read: u32 = 0; - let mut flags: u32 = 0; - if capture.GetBuffer(&mut data_ptr, &mut frames_to_read, &mut flags, None, None).is_err() { - break; + } + + fn capture_and_stream_once() -> Result<(), String> { + let node = core_host::node().ok_or_else(|| "core node not running".to_owned())?; + let peers: Vec<_> = node + .paired_devices() + .unwrap_or_default() + .into_iter() + .filter(|d| matches!(d.link, nectarlink_core::LinkState::Online { .. })) + .map(|d| d.id) + .collect(); + let wanted = *TARGET.lock().unwrap_or_else(|e| e.into_inner()); + let Some(&peer) = wanted.as_ref().filter(|w| peers.contains(w)).or_else(|| peers.first()) else { + std::thread::sleep(Duration::from_millis(500)); + return Ok(()); + }; + + // SAFETY: WASAPI loopback initialization on the default render endpoint. + let (client, capture, rate, channels, bits_per_sample) = unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL).map_err(|e| e.to_string())?; + let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole).map_err(|e| e.to_string())?; + let client: IAudioClient = device.Activate(CLSCTX_ALL, None).map_err(|e| e.to_string())?; + let mix_ptr: *mut WAVEFORMATEX = client.GetMixFormat().map_err(|e| e.to_string())?; + if mix_ptr.is_null() { + return Err("GetMixFormat returned null".into()); + } + let mix = *mix_ptr; + let rate = mix.nSamplesPerSec.clamp(8_000, 96_000); + let channels = mix.nChannels.max(1) as usize; + let bits = mix.wBitsPerSample; + // 10 ms shared loopback buffer in 100-ns units = 100_000. + let init_res = client.Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK, + 100_000, + 0, + mix_ptr, + None, + ); + CoTaskMemFree(Some(mix_ptr.cast())); + init_res.map_err(|e| e.to_string())?; + let capture: IAudioCaptureClient = client.GetService().map_err(|e| e.to_string())?; + client.Start().map_err(|e| e.to_string())?; + (client, capture, rate, channels, bits) + }; + + let mut enhancer = SpeakerEnhancer::new(rate); + let (tx, mut rx) = tokio::sync::mpsc::channel::<(u32, u8, Vec)>(64); + let send_node = node.clone(); + core_host::spawn(async move { + while let Some((r, ch, pcm)) = rx.recv().await { + // Slice into 960-byte (5 ms at 48 kHz stereo) sub-MTU chunks so each + // chunk travels in a single QUIC datagram without HOL blocking. + for chunk in pcm.chunks(960) { + if send_node.send_pc_audio_pcm(&peer, r, ch, chunk.to_vec()).await.is_err() { + return; + } } - let silent = (flags & (AUDCLNT_BUFFERFLAGS_SILENT.0 as u32)) != 0 || data_ptr.is_null(); - let frames = frames_to_read as usize; - let pcm_bytes = if silent { - if silence_tail_packets > 0 { - silence_tail_packets -= 1; - vec![0u8; frames * 4] - } else { - Vec::new() + } + }); + + // Keep sending zero-filled PCM for ~500 ms after audible audio stops so brief + // pauses between words or beats never starve the phone's AudioTrack ringbuffer. + let mut silence_tail_packets: u32 = 0; + + 'capture: while is_enabled() { + std::thread::sleep(Duration::from_millis(2)); + // Another phone asked for the sound (it watches the PC's playback): + // start over for that one. + if target() != wanted && target().is_some_and(|t| peers.contains(&t)) { + break 'capture; + } + // SAFETY: Drain available capture packets from `IAudioCaptureClient`. + unsafe { + loop { + let Ok(packet_frames) = capture.GetNextPacketSize() else { break }; + if packet_frames == 0 { + break; } - } else if bits_per_sample == 32 { - let floats = std::slice::from_raw_parts(data_ptr.cast::(), frames * channels); - let has_signal = floats.iter().any(|&s| s.is_finite() && s.abs() > 1e-5); - if has_signal { - silence_tail_packets = 50; - enhancer.encode_stereo_f32(floats, channels) - } else if silence_tail_packets > 0 { - silence_tail_packets -= 1; - vec![0u8; frames * 4] - } else { - Vec::new() + let mut data_ptr: *mut u8 = std::ptr::null_mut(); + let mut frames_to_read: u32 = 0; + let mut flags: u32 = 0; + if capture.GetBuffer(&mut data_ptr, &mut frames_to_read, &mut flags, None, None).is_err() + { + break; } - } else if bits_per_sample == 16 { - let i16s = std::slice::from_raw_parts(data_ptr.cast::(), frames * channels); - let has_signal = i16s.iter().any(|&s| s.unsigned_abs() > 2); - if has_signal { - silence_tail_packets = 50; - enhancer.encode_stereo_i16(i16s, channels) - } else if silence_tail_packets > 0 { - silence_tail_packets -= 1; - vec![0u8; frames * 4] + let silent = (flags & (AUDCLNT_BUFFERFLAGS_SILENT.0 as u32)) != 0 || data_ptr.is_null(); + let frames = frames_to_read as usize; + let pcm_bytes = if silent { + if silence_tail_packets > 0 { + silence_tail_packets -= 1; + vec![0u8; frames * 4] + } else { + Vec::new() + } + } else if bits_per_sample == 32 { + let floats = std::slice::from_raw_parts(data_ptr.cast::(), frames * channels); + let has_signal = floats.iter().any(|&s| s.is_finite() && s.abs() > 1e-5); + if has_signal { + silence_tail_packets = 50; + enhancer.encode_stereo_f32(floats, channels) + } else if silence_tail_packets > 0 { + silence_tail_packets -= 1; + vec![0u8; frames * 4] + } else { + Vec::new() + } + } else if bits_per_sample == 16 { + let i16s = std::slice::from_raw_parts(data_ptr.cast::(), frames * channels); + let has_signal = i16s.iter().any(|&s| s.unsigned_abs() > 2); + if has_signal { + silence_tail_packets = 50; + enhancer.encode_stereo_i16(i16s, channels) + } else if silence_tail_packets > 0 { + silence_tail_packets -= 1; + vec![0u8; frames * 4] + } else { + Vec::new() + } } else { Vec::new() + }; + let _ = capture.ReleaseBuffer(frames_to_read); + + if !pcm_bytes.is_empty() + && matches!( + tx.try_send((rate, 2, pcm_bytes)), + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) + ) + { + break 'capture; } - } else { - Vec::new() - }; - let _ = capture.ReleaseBuffer(frames_to_read); - - if !pcm_bytes.is_empty() - && matches!( - tx.try_send((rate, 2, pcm_bytes)), - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) - ) - { - break 'capture; } } } + + // SAFETY: Stop the WASAPI client before dropping. + unsafe { + let _ = client.Stop(); + } + Ok(()) } - // SAFETY: Stop the WASAPI client before dropping. - unsafe { - let _ = client.Stop(); + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn f32_stereo_to_i16le_clamps_and_encodes_little_endian() { + let samples = [0.0_f32, 1.0, -1.0, 2.5, -3.0, f32::NAN]; + let bytes = f32_stereo_to_i16le(&samples); + assert_eq!(bytes.len(), samples.len() * 2); + let decoded: Vec = bytes.as_chunks::<2>().0.iter().map(|&c| i16::from_le_bytes(c)).collect(); + assert_eq!(decoded, vec![0, 32767, -32767, 32767, -32767, 0]); + } + + #[test] + fn set_enabled_toggles_atomic_state_cleanly() { + set_enabled(false); + assert!(!is_enabled()); + set_enabled(true); + assert!(is_enabled()); + set_enabled(false); + assert!(!is_enabled()); + } } - Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn f32_stereo_to_i16le_clamps_and_encodes_little_endian() { - let samples = [0.0_f32, 1.0, -1.0, 2.5, -3.0, f32::NAN]; - let bytes = f32_stereo_to_i16le(&samples); - assert_eq!(bytes.len(), samples.len() * 2); - let decoded: Vec = bytes.as_chunks::<2>().0.iter().map(|&c| i16::from_le_bytes(c)).collect(); - assert_eq!(decoded, vec![0, 32767, -32767, 32767, -32767, 0]); +#[cfg(not(windows))] +mod imp { + #![allow(dead_code)] + + pub fn target() -> Option { + None + } + + pub fn set_target(_phone: Option) {} + + pub fn is_enabled() -> bool { + false } - #[test] - fn set_enabled_toggles_atomic_state_cleanly() { - set_enabled(false); - assert!(!is_enabled()); - set_enabled(true); - assert!(is_enabled()); - set_enabled(false); - assert!(!is_enabled()); + pub fn set_enabled(_enabled: bool) {} + + pub fn f32_stereo_to_i16le(_samples: &[f32]) -> Vec { + Vec::new() } } + +pub use imp::*; diff --git a/desktop/app/src/pc_mirror.rs b/desktop/app/src/pc_mirror.rs index d6f7f6ca..218b4123 100644 --- a/desktop/app/src/pc_mirror.rs +++ b/desktop/app/src/pc_mirror.rs @@ -4,1044 +4,1103 @@ //! streams it over `mirror` / `video` (`session = 0`) to a paired phone, while //! injecting touch, scroll, key, and text input back into Windows (`docs/PLAN.md` §8.6). -#![allow(unsafe_code)] - -use std::{ - mem::{ManuallyDrop, size_of}, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - time::{Duration, Instant}, -}; - -use nectarlink_core::{ - DeviceId, KeyMod, MirrorConfig, MirrorInput, MirrorSend, MirrorStart, PacketKind, mirror_keys, -}; -use windows::Win32::{ - Foundation::{POINT, RECT}, - Graphics::Gdi::{ - BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleBitmap, CreateCompatibleDC, DESKTOPHORZRES, - DESKTOPVERTRES, DeleteDC, DeleteObject, GetDC, GetDIBits, GetDeviceCaps, HALFTONE, ReleaseDC, - SRCCOPY, SelectObject, SetStretchBltMode, StretchBlt, - }, - Media::MediaFoundation::{ - CODECAPI_AVEncCommonMeanBitRate, CODECAPI_AVEncMPVGOPSize, CODECAPI_AVEncVideoForceKeyFrame, - CODECAPI_AVLowLatencyMode, ICodecAPI, IMFMediaBuffer, IMFMediaType, IMFSample, IMFTransform, - MF_E_NOTACCEPTING, MF_E_TRANSFORM_NEED_MORE_INPUT, MF_E_TRANSFORM_STREAM_CHANGE, MF_LOW_LATENCY, - MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, - MF_MT_MPEG_SEQUENCE_HEADER, MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, MFCreateMediaType, - MFCreateMemoryBuffer, MFCreateSample, MFMediaType_Video, MFSampleExtension_CleanPoint, - MFT_CATEGORY_VIDEO_ENCODER, MFT_MESSAGE_COMMAND_FLUSH, MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, - MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_OUTPUT_DATA_BUFFER, - MFT_OUTPUT_STREAM_PROVIDES_SAMPLES, MFVideoFormat_H264, MFVideoFormat_NV12, - MFVideoInterlace_Progressive, - }, - System::Variant::VARIANT, - UI::WindowsAndMessaging::{ - CURSOR_SHOWING, CURSORINFO, DI_NORMAL, DrawIconEx, GetCursorInfo, GetSystemMetrics, SM_CXSCREEN, - SM_CYSCREEN, - }, -}; - -use crate::{ - core_host, - win::{ - self, - h264::{create_mft, ensure_mf_started}, - }, -}; - -struct ActiveSession { - peer: DeviceId, - stop: Arc, - force_keyframe: Arc, - /// Play on phone: the phone watches a player, and its input is ignored. - watching: bool, -} +#[cfg(windows)] +mod imp { -static ACTIVE: Mutex> = Mutex::new(None); + #![allow(unsafe_code)] -/// How long Play on phone looks for the player's window before streaming -/// the screen instead. -const FIND_WINDOW: Duration = Duration::from_secs(5); + use std::{ + mem::{ManuallyDrop, size_of}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, + }; -/// The lowest bitrate the stream backs off to on a slow link. -const MIN_BITRATE: u32 = 600_000; + use nectarlink_core::{ + DeviceId, KeyMod, MirrorConfig, MirrorInput, MirrorSend, MirrorStart, PacketKind, mirror_keys, + }; + use windows::Win32::{ + Foundation::{POINT, RECT}, + Graphics::Gdi::{ + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleBitmap, CreateCompatibleDC, DESKTOPHORZRES, + DESKTOPVERTRES, DeleteDC, DeleteObject, GetDC, GetDIBits, GetDeviceCaps, HALFTONE, ReleaseDC, + SRCCOPY, SelectObject, SetStretchBltMode, StretchBlt, + }, + Media::MediaFoundation::{ + CODECAPI_AVEncCommonMeanBitRate, CODECAPI_AVEncMPVGOPSize, CODECAPI_AVEncVideoForceKeyFrame, + CODECAPI_AVLowLatencyMode, ICodecAPI, IMFMediaBuffer, IMFMediaType, IMFSample, IMFTransform, + MF_E_NOTACCEPTING, MF_E_TRANSFORM_NEED_MORE_INPUT, MF_E_TRANSFORM_STREAM_CHANGE, MF_LOW_LATENCY, + MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, + MF_MT_MPEG_SEQUENCE_HEADER, MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, MFCreateMediaType, + MFCreateMemoryBuffer, MFCreateSample, MFMediaType_Video, MFSampleExtension_CleanPoint, + MFT_CATEGORY_VIDEO_ENCODER, MFT_MESSAGE_COMMAND_FLUSH, MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, + MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_OUTPUT_DATA_BUFFER, + MFT_OUTPUT_STREAM_PROVIDES_SAMPLES, MFVideoFormat_H264, MFVideoFormat_NV12, + MFVideoInterlace_Progressive, + }, + System::Variant::VARIANT, + UI::WindowsAndMessaging::{ + CURSOR_SHOWING, CURSORINFO, DI_NORMAL, DrawIconEx, GetCursorInfo, GetSystemMetrics, SM_CXSCREEN, + SM_CYSCREEN, + }, + }; -/// What a sharing session sent in the last stats window. -#[derive(Default)] -struct Stats { - frames: u64, - keyframes: u64, - bytes: u64, - dropped: u64, - rebuilds: u64, -} + use crate::{ + core_host, + win::{ + self, + h264::{create_mft, ensure_mf_started}, + }, + }; -/// Returns the device currently viewing this PC's screen, if any. -pub fn active_peer() -> Option { - ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|s| s.peer) -} + struct ActiveSession { + peer: DeviceId, + stop: Arc, + force_keyframe: Arc, + /// Play on phone: the phone watches a player, and its input is ignored. + watching: bool, + } -/// Whether this PC's screen is currently being shared with a phone. -pub fn is_active() -> bool { - active_peer().is_some() -} + static ACTIVE: Mutex> = Mutex::new(None); -/// Whether a phone watches one of this PC's players (Play on phone). -pub fn is_watching() -> bool { - ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|s| s.watching) -} + /// How long Play on phone looks for the player's window before streaming + /// the screen instead. + const FIND_WINDOW: Duration = Duration::from_secs(5); -/// Stops a phone watching one of this PC's players; screen sharing goes on. -pub fn stop_watching() { - let peer = - ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().filter(|s| s.watching).map(|s| s.peer); - if let Some(peer) = peer { - stop_for(&peer); + /// The lowest bitrate the stream backs off to on a slow link. + const MIN_BITRATE: u32 = 600_000; + + /// What a sharing session sent in the last stats window. + #[derive(Default)] + struct Stats { + frames: u64, + keyframes: u64, + bytes: u64, + dropped: u64, + rebuilds: u64, } -} -/// Starts or replaces the active PC-to-phone screen mirror stream for `peer`. -pub fn start(peer: DeviceId, options: MirrorStart) { - stop_all(); - let stop = Arc::new(AtomicBool::new(false)); - let force_keyframe = Arc::new(AtomicBool::new(false)); - { - let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(ActiveSession { - peer, - stop: Arc::clone(&stop), - force_keyframe: Arc::clone(&force_keyframe), - watching: options.watch.is_some(), - }); + /// Returns the device currently viewing this PC's screen, if any. + pub fn active_peer() -> Option { + ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|s| s.peer) } - core_host::host().hub.changed(crate::state::Changes::REMOTE); - core_host::refresh_capabilities(); - std::thread::Builder::new() - .name("nectarlink-pc-mirror".into()) - .spawn(move || run_capture_loop(peer, options, stop, force_keyframe)) - .ok(); -} + /// Whether this PC's screen is currently being shared with a phone. + pub fn is_active() -> bool { + active_peer().is_some() + } -/// Stops PC-to-phone mirroring if `peer` matches the active viewer. -pub fn stop_for(peer: &DeviceId) { - let stopped = { - let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - if guard.as_ref().is_some_and(|s| &s.peer == peer) - && let Some(s) = guard.take() - { - s.stop.store(true, Ordering::SeqCst); - true - } else { - false - } - }; - if stopped { - win::input::release_held_buttons(); - crate::input_share::leave_remote(); - core_host::host().hub.changed(crate::state::Changes::REMOTE); - core_host::refresh_capabilities(); + /// Whether a phone watches one of this PC's players (Play on phone). + pub fn is_watching() -> bool { + ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|s| s.watching) } -} -/// Stops any active PC-to-phone mirroring session. -pub fn stop_all() { - let stopped = { - let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(s) = guard.take() { - s.stop.store(true, Ordering::SeqCst); - Some(s.peer) - } else { - None + /// Stops a phone watching one of this PC's players; screen sharing goes on. + pub fn stop_watching() { + let peer = + ACTIVE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().filter(|s| s.watching).map(|s| s.peer); + if let Some(peer) = peer { + stop_for(&peer); } - }; - if let Some(peer) = stopped { - win::input::release_held_buttons(); - crate::input_share::leave_remote(); - if let Some(node) = core_host::node() { - node.set_pc_mirror_session_allowed(peer, false); - core_host::spawn(async move { - node.mirror_stop(peer, nectarlink_core::MIRROR_SCREEN).await; + } + + /// Starts or replaces the active PC-to-phone screen mirror stream for `peer`. + pub fn start(peer: DeviceId, options: MirrorStart) { + stop_all(); + let stop = Arc::new(AtomicBool::new(false)); + let force_keyframe = Arc::new(AtomicBool::new(false)); + { + let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(ActiveSession { + peer, + stop: Arc::clone(&stop), + force_keyframe: Arc::clone(&force_keyframe), + watching: options.watch.is_some(), }); } core_host::host().hub.changed(crate::state::Changes::REMOTE); core_host::refresh_capabilities(); - } -} -/// Requests an immediate IDR keyframe for `peer`. -pub fn request_keyframe(peer: &DeviceId) { - let guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(s) = guard.as_ref() - && &s.peer == peer - { - s.force_keyframe.store(true, Ordering::SeqCst); + std::thread::Builder::new() + .name("nectarlink-pc-mirror".into()) + .spawn(move || run_capture_loop(peer, options, stop, force_keyframe)) + .ok(); } -} -/// Injects touch, scroll, key, or text input from a phone viewing the PC screen. -pub fn handle_input(peer: &DeviceId, session: u32, input: MirrorInput) { - if session != nectarlink_core::MIRROR_SCREEN { - return; + /// Stops PC-to-phone mirroring if `peer` matches the active viewer. + pub fn stop_for(peer: &DeviceId) { + let stopped = { + let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + if guard.as_ref().is_some_and(|s| &s.peer == peer) + && let Some(s) = guard.take() + { + s.stop.store(true, Ordering::SeqCst); + true + } else { + false + } + }; + if stopped { + win::input::release_held_buttons(); + crate::input_share::leave_remote(); + core_host::host().hub.changed(crate::state::Changes::REMOTE); + core_host::refresh_capabilities(); + } } - if active_peer() != Some(*peer) || is_watching() { - return; + + /// Stops any active PC-to-phone mirroring session. + pub fn stop_all() { + let stopped = { + let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(s) = guard.take() { + s.stop.store(true, Ordering::SeqCst); + Some(s.peer) + } else { + None + } + }; + if let Some(peer) = stopped { + win::input::release_held_buttons(); + crate::input_share::leave_remote(); + if let Some(node) = core_host::node() { + node.set_pc_mirror_session_allowed(peer, false); + core_host::spawn(async move { + node.mirror_stop(peer, nectarlink_core::MIRROR_SCREEN).await; + }); + } + core_host::host().hub.changed(crate::state::Changes::REMOTE); + core_host::refresh_capabilities(); + } } - let remote_input_on = core_host::node() - .and_then(|n| n.device_toggles(*peer).ok()) - .is_some_and(|toggles| toggles.iter().any(|(k, v)| *k == "remote_input" && *v)); - if !remote_input_on { - return; + + /// Requests an immediate IDR keyframe for `peer`. + pub fn request_keyframe(peer: &DeviceId) { + let guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(s) = guard.as_ref() + && &s.peer == peer + { + s.force_keyframe.store(true, Ordering::SeqCst); + } } - let _ = crate::input_share::enter_remote(*peer); - match input { - MirrorInput::Touch { action, x, y } => { - win::input::mirror_touch(action, x, y); + + /// Injects touch, scroll, key, or text input from a phone viewing the PC screen. + pub fn handle_input(peer: &DeviceId, session: u32, input: MirrorInput) { + if session != nectarlink_core::MIRROR_SCREEN { + return; } - MirrorInput::Scroll { x, y, dx, dy } => { - win::input::move_absolute(x, y); - win::input::scroll(dx, dy); + if active_peer() != Some(*peer) || is_watching() { + return; } - MirrorInput::Text { text } => { - win::input::type_text(&text); + let remote_input_on = core_host::node() + .and_then(|n| n.device_toggles(*peer).ok()) + .is_some_and(|toggles| toggles.iter().any(|(k, v)| *k == "remote_input" && *v)); + if !remote_input_on { + return; + } + let _ = crate::input_share::enter_remote(*peer); + match input { + MirrorInput::Touch { action, x, y } => { + win::input::mirror_touch(action, x, y); + } + MirrorInput::Scroll { x, y, dx, dy } => { + win::input::move_absolute(x, y); + win::input::scroll(dx, dy); + } + MirrorInput::Text { text } => { + win::input::type_text(&text); + } + MirrorInput::Key { key } => match key.as_str() { + mirror_keys::BACK => win::input::press_key("escape", &[]), + mirror_keys::HOME => win::input::press_key("a", &[KeyMod::Win]), + mirror_keys::RECENTS => win::input::press_key("task_view", &[]), + mirror_keys::ENTER => win::input::press_key("enter", &[]), + mirror_keys::BACKSPACE => win::input::press_key("backspace", &[]), + mirror_keys::DELETE => win::input::press_key("delete", &[]), + mirror_keys::LEFT => win::input::press_key("left", &[]), + mirror_keys::RIGHT => win::input::press_key("right", &[]), + mirror_keys::UP => win::input::press_key("up", &[]), + mirror_keys::DOWN => win::input::press_key("down", &[]), + mirror_keys::TAB => win::input::press_key("tab", &[]), + mirror_keys::NOTIFICATIONS => win::input::press_key("n", &[KeyMod::Win]), + _ => {} + }, + MirrorInput::Gamepad { .. } => {} } - MirrorInput::Key { key } => match key.as_str() { - mirror_keys::BACK => win::input::press_key("escape", &[]), - mirror_keys::HOME => win::input::press_key("a", &[KeyMod::Win]), - mirror_keys::RECENTS => win::input::press_key("task_view", &[]), - mirror_keys::ENTER => win::input::press_key("enter", &[]), - mirror_keys::BACKSPACE => win::input::press_key("backspace", &[]), - mirror_keys::DELETE => win::input::press_key("delete", &[]), - mirror_keys::LEFT => win::input::press_key("left", &[]), - mirror_keys::RIGHT => win::input::press_key("right", &[]), - mirror_keys::UP => win::input::press_key("up", &[]), - mirror_keys::DOWN => win::input::press_key("down", &[]), - mirror_keys::TAB => win::input::press_key("tab", &[]), - mirror_keys::NOTIFICATIONS => win::input::press_key("n", &[KeyMod::Win]), - _ => {} - }, - MirrorInput::Gamepad { .. } => {} } -} -pub fn on_event(event: &nectarlink_core::NodeEvent) { - match event { - nectarlink_core::NodeEvent::LinkChanged { - device, - link: nectarlink_core::LinkState::Offline { .. }, - } - | nectarlink_core::NodeEvent::DeviceRemoved(device) => { - stop_for(device); - } - nectarlink_core::NodeEvent::PcMirrorRequested { .. } => { - core_host::host().hub.update(|s| s.apply(event)); - crate::bridge::app::request_activation(); + pub fn on_event(event: &nectarlink_core::NodeEvent) { + match event { + nectarlink_core::NodeEvent::LinkChanged { + device, + link: nectarlink_core::LinkState::Offline { .. }, + } + | nectarlink_core::NodeEvent::DeviceRemoved(device) => { + stop_for(device); + } + nectarlink_core::NodeEvent::PcMirrorRequested { .. } => { + core_host::host().hub.update(|s| s.apply(event)); + crate::bridge::app::request_activation(); + } + _ => {} } - _ => {} } -} -/// Computes target encoder dimensions (multiples of 16) fitting within `max_size`. -pub(crate) fn target_dimensions(src_w: u32, src_h: u32, max_size: u32) -> (u32, u32) { - let max_edge = max_size.clamp(320, 1920) as f64; - let sw = src_w.max(64) as f64; - let sh = src_h.max(64) as f64; - let longest = sw.max(sh); - let scale = if longest > max_edge { max_edge / longest } else { 1.0 }; - let w = (((sw * scale).round() as u32) / 16).max(4) * 16; - let h = (((sh * scale).round() as u32) / 16).max(4) * 16; - (w, h) -} + /// Computes target encoder dimensions (multiples of 16) fitting within `max_size`. + pub(crate) fn target_dimensions(src_w: u32, src_h: u32, max_size: u32) -> (u32, u32) { + let max_edge = max_size.clamp(320, 1920) as f64; + let sw = src_w.max(64) as f64; + let sh = src_h.max(64) as f64; + let longest = sw.max(sh); + let scale = if longest > max_edge { max_edge / longest } else { 1.0 }; + let w = (((sw * scale).round() as u32) / 16).max(4) * 16; + let h = (((sh * scale).round() as u32) / 16).max(4) * 16; + (w, h) + } -/// Converts a top-down 32-bit BGRA image (`width * height * 4` bytes) into -/// BT.709 limited-range NV12 (`width * height * 3 / 2` bytes). -pub(crate) fn bgra_to_nv12(bgra: &[u8], width: usize, height: usize, nv12: &mut Vec) { - let y_size = width * height; - let uv_size = y_size / 2; - nv12.resize(y_size + uv_size, 0); - let (y_plane, uv_plane) = nv12.split_at_mut(y_size); - - for row in 0..height { - let src_row = row * width * 4; - let y_row = row * width; - for col in 0..width { - let px = src_row + col * 4; - let b = i32::from(bgra[px]); - let g = i32::from(bgra[px + 1]); - let r = i32::from(bgra[px + 2]); - let y = ((47 * r + 157 * g + 16 * b + 128) >> 8) + 16; - y_plane[y_row + col] = y.clamp(16, 235) as u8; + /// Converts a top-down 32-bit BGRA image (`width * height * 4` bytes) into + /// BT.709 limited-range NV12 (`width * height * 3 / 2` bytes). + pub(crate) fn bgra_to_nv12(bgra: &[u8], width: usize, height: usize, nv12: &mut Vec) { + let y_size = width * height; + let uv_size = y_size / 2; + nv12.resize(y_size + uv_size, 0); + let (y_plane, uv_plane) = nv12.split_at_mut(y_size); + + for row in 0..height { + let src_row = row * width * 4; + let y_row = row * width; + for col in 0..width { + let px = src_row + col * 4; + let b = i32::from(bgra[px]); + let g = i32::from(bgra[px + 1]); + let r = i32::from(bgra[px + 2]); + let y = ((47 * r + 157 * g + 16 * b + 128) >> 8) + 16; + y_plane[y_row + col] = y.clamp(16, 235) as u8; + } } - } - for row in (0..height).step_by(2) { - let src0 = row * width * 4; - let src1 = (row + 1).min(height - 1) * width * 4; - let uv_row = (row / 2) * width; - for col in (0..width).step_by(2) { - let p00 = src0 + col * 4; - let p01 = src0 + (col + 1).min(width - 1) * 4; - let p10 = src1 + col * 4; - let p11 = src1 + (col + 1).min(width - 1) * 4; - - let b = (i32::from(bgra[p00]) - + i32::from(bgra[p01]) - + i32::from(bgra[p10]) - + i32::from(bgra[p11]) - + 2) - >> 2; - let g = (i32::from(bgra[p00 + 1]) - + i32::from(bgra[p01 + 1]) - + i32::from(bgra[p10 + 1]) - + i32::from(bgra[p11 + 1]) - + 2) - >> 2; - let r = (i32::from(bgra[p00 + 2]) - + i32::from(bgra[p01 + 2]) - + i32::from(bgra[p10 + 2]) - + i32::from(bgra[p11 + 2]) - + 2) - >> 2; - - let u = ((-26 * r - 87 * g + 112 * b + 128) >> 8) + 128; - let v = ((112 * r - 102 * g - 10 * b + 128) >> 8) + 128; - uv_plane[uv_row + col] = u.clamp(16, 240) as u8; - uv_plane[uv_row + col + 1] = v.clamp(16, 240) as u8; + for row in (0..height).step_by(2) { + let src0 = row * width * 4; + let src1 = (row + 1).min(height - 1) * width * 4; + let uv_row = (row / 2) * width; + for col in (0..width).step_by(2) { + let p00 = src0 + col * 4; + let p01 = src0 + (col + 1).min(width - 1) * 4; + let p10 = src1 + col * 4; + let p11 = src1 + (col + 1).min(width - 1) * 4; + + let b = (i32::from(bgra[p00]) + + i32::from(bgra[p01]) + + i32::from(bgra[p10]) + + i32::from(bgra[p11]) + + 2) + >> 2; + let g = (i32::from(bgra[p00 + 1]) + + i32::from(bgra[p01 + 1]) + + i32::from(bgra[p10 + 1]) + + i32::from(bgra[p11 + 1]) + + 2) + >> 2; + let r = (i32::from(bgra[p00 + 2]) + + i32::from(bgra[p01 + 2]) + + i32::from(bgra[p10 + 2]) + + i32::from(bgra[p11 + 2]) + + 2) + >> 2; + + let u = ((-26 * r - 87 * g + 112 * b + 128) >> 8) + 128; + let v = ((112 * r - 102 * g - 10 * b + 128) >> 8) + 128; + uv_plane[uv_row + col] = u.clamp(16, 240) as u8; + uv_plane[uv_row + col + 1] = v.clamp(16, 240) as u8; + } } } -} -pub(crate) struct H264Encoder { - transform: IMFTransform, - /// The encoder's settings interface, when it has one: keyframes on demand and live - /// bitrate changes without rebuilding the encoder. - codec_api: Option, - width: u32, - height: u32, - provides_samples: bool, - output_size: u32, - seq_header: Vec, - sent_first_frame: bool, -} + pub(crate) struct H264Encoder { + transform: IMFTransform, + /// The encoder's settings interface, when it has one: keyframes on demand and live + /// bitrate changes without rebuilding the encoder. + codec_api: Option, + width: u32, + height: u32, + provides_samples: bool, + output_size: u32, + seq_header: Vec, + sent_first_frame: bool, + } -impl Drop for H264Encoder { - fn drop(&mut self) { - // SAFETY: flushes and ends streaming on the MFT before drop. - unsafe { - let _ = self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_FLUSH, 0); - let _ = self.transform.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0); + impl Drop for H264Encoder { + fn drop(&mut self) { + // SAFETY: flushes and ends streaming on the MFT before drop. + unsafe { + let _ = self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_FLUSH, 0); + let _ = self.transform.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0); + } } } -} -impl H264Encoder { - pub(crate) fn new(width: u32, height: u32, fps: u32, bitrate: u32) -> windows::core::Result { - ensure_mf_started()?; - // SAFETY: Media Foundation COM calls with valid parameters. - unsafe { - let transform = create_mft(MFT_CATEGORY_VIDEO_ENCODER, MFVideoFormat_NV12, MFVideoFormat_H264)?; - if let Ok(attrs) = transform.GetAttributes() { - let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1); - } + impl H264Encoder { + pub(crate) fn new(width: u32, height: u32, fps: u32, bitrate: u32) -> windows::core::Result { + ensure_mf_started()?; + // SAFETY: Media Foundation COM calls with valid parameters. + unsafe { + let transform = + create_mft(MFT_CATEGORY_VIDEO_ENCODER, MFVideoFormat_NV12, MFVideoFormat_H264)?; + if let Ok(attrs) = transform.GetAttributes() { + let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1); + } - let out_type: IMFMediaType = MFCreateMediaType()?; - out_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; - out_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?; - let _ = out_type.SetUINT32( - &windows::Win32::Media::MediaFoundation::MF_MT_MPEG2_PROFILE, - windows::Win32::Media::MediaFoundation::eAVEncH264VProfile_Base.0 as u32, - ); - out_type.SetUINT32(&MF_MT_AVG_BITRATE, bitrate.clamp(500_000, 20_000_000))?; - out_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; - out_type.SetUINT64(&MF_MT_FRAME_SIZE, (u64::from(width) << 32) | u64::from(height))?; - out_type.SetUINT64(&MF_MT_FRAME_RATE, (u64::from(fps.max(1)) << 32) | 1)?; - out_type.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, (1u64 << 32) | 1)?; - transform.SetOutputType(0, Some(&out_type), 0)?; - - let in_type: IMFMediaType = MFCreateMediaType()?; - in_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; - in_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?; - in_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; - in_type.SetUINT64(&MF_MT_FRAME_SIZE, (u64::from(width) << 32) | u64::from(height))?; - in_type.SetUINT64(&MF_MT_FRAME_RATE, (u64::from(fps.max(1)) << 32) | 1)?; - in_type.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, (1u64 << 32) | 1)?; - transform.SetInputType(0, Some(&in_type), 0)?; - - let mut seq_header = Vec::new(); - if let Ok(cur_out) = transform.GetOutputCurrentType(0) - && let Ok(len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) - && len > 0 - { - let mut buf = vec![0u8; len as usize]; - if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { - seq_header = buf; + let out_type: IMFMediaType = MFCreateMediaType()?; + out_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; + out_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?; + let _ = out_type.SetUINT32( + &windows::Win32::Media::MediaFoundation::MF_MT_MPEG2_PROFILE, + windows::Win32::Media::MediaFoundation::eAVEncH264VProfile_Base.0 as u32, + ); + out_type.SetUINT32(&MF_MT_AVG_BITRATE, bitrate.clamp(500_000, 20_000_000))?; + out_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; + out_type.SetUINT64(&MF_MT_FRAME_SIZE, (u64::from(width) << 32) | u64::from(height))?; + out_type.SetUINT64(&MF_MT_FRAME_RATE, (u64::from(fps.max(1)) << 32) | 1)?; + out_type.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, (1u64 << 32) | 1)?; + transform.SetOutputType(0, Some(&out_type), 0)?; + + let in_type: IMFMediaType = MFCreateMediaType()?; + in_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; + in_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?; + in_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; + in_type.SetUINT64(&MF_MT_FRAME_SIZE, (u64::from(width) << 32) | u64::from(height))?; + in_type.SetUINT64(&MF_MT_FRAME_RATE, (u64::from(fps.max(1)) << 32) | 1)?; + in_type.SetUINT64(&MF_MT_PIXEL_ASPECT_RATIO, (1u64 << 32) | 1)?; + transform.SetInputType(0, Some(&in_type), 0)?; + + let mut seq_header = Vec::new(); + if let Ok(cur_out) = transform.GetOutputCurrentType(0) + && let Ok(len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) + && len > 0 + { + let mut buf = vec![0u8; len as usize]; + if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { + seq_header = buf; + } } - } - let info = transform.GetOutputStreamInfo(0)?; - let provides_samples = (info.dwFlags & (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 as u32)) != 0; - let output_size = info.cbSize.max(256 * 1024); + let info = transform.GetOutputStreamInfo(0)?; + let provides_samples = (info.dwFlags & (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 as u32)) != 0; + let output_size = info.cbSize.max(256 * 1024); - // A keyframe every two seconds keeps late joiners and lost packets recoverable - // without rebuilding the encoder, and low-latency mode stops it holding frames back. - let codec_api: Option = windows::core::Interface::cast(&transform).ok(); - if let Some(api) = codec_api.as_ref() { - let _ = api.SetValue(&CODECAPI_AVLowLatencyMode, &VARIANT::from(true)); - let _ = api.SetValue(&CODECAPI_AVEncMPVGOPSize, &VARIANT::from(fps.max(1) * 2)); + // A keyframe every two seconds keeps late joiners and lost packets recoverable + // without rebuilding the encoder, and low-latency mode stops it holding frames back. + let codec_api: Option = windows::core::Interface::cast(&transform).ok(); + if let Some(api) = codec_api.as_ref() { + let _ = api.SetValue(&CODECAPI_AVLowLatencyMode, &VARIANT::from(true)); + let _ = api.SetValue(&CODECAPI_AVEncMPVGOPSize, &VARIANT::from(fps.max(1) * 2)); + } + + let _ = transform.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0); + let _ = transform.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0); + + Ok(Self { + transform, + codec_api, + width, + height, + provides_samples, + output_size, + seq_header, + sent_first_frame: false, + }) } + } - let _ = transform.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0); - let _ = transform.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0); - - Ok(Self { - transform, - codec_api, - width, - height, - provides_samples, - output_size, - seq_header, - sent_first_frame: false, + /// Asks for the next frame to be a keyframe. False when the encoder can't, so the + /// caller must rebuild it instead. + pub(crate) fn request_keyframe(&self) -> bool { + // SAFETY: ICodecAPI::SetValue with a valid GUID and VARIANT. + self.codec_api.as_ref().is_some_and(|api| { + unsafe { api.SetValue(&CODECAPI_AVEncVideoForceKeyFrame, &VARIANT::from(1u32)) }.is_ok() }) } - } - - /// Asks for the next frame to be a keyframe. False when the encoder can't, so the - /// caller must rebuild it instead. - pub(crate) fn request_keyframe(&self) -> bool { - // SAFETY: ICodecAPI::SetValue with a valid GUID and VARIANT. - self.codec_api.as_ref().is_some_and(|api| { - unsafe { api.SetValue(&CODECAPI_AVEncVideoForceKeyFrame, &VARIANT::from(1u32)) }.is_ok() - }) - } - - /// Changes the target bitrate in place. False when the encoder can't. - pub(crate) fn set_bitrate(&self, bitrate: u32) -> bool { - // SAFETY: ICodecAPI::SetValue with a valid GUID and VARIANT. - self.codec_api.as_ref().is_some_and(|api| { - unsafe { api.SetValue(&CODECAPI_AVEncCommonMeanBitRate, &VARIANT::from(bitrate)) }.is_ok() - }) - } - /// Feeds one NV12 frame (`width * height * 3 / 2` bytes) and drains encoded - /// Annex-B H.264 access units `(is_keyframe, bytes)`. - pub(crate) fn encode( - &mut self, - nv12: &[u8], - hns: i64, - dur_hns: i64, - ) -> windows::core::Result)>> { - let expected = (self.width as usize) * (self.height as usize) * 3 / 2; - if nv12.len() < expected { - return Ok(Vec::new()); + /// Changes the target bitrate in place. False when the encoder can't. + pub(crate) fn set_bitrate(&self, bitrate: u32) -> bool { + // SAFETY: ICodecAPI::SetValue with a valid GUID and VARIANT. + self.codec_api.as_ref().is_some_and(|api| { + unsafe { api.SetValue(&CODECAPI_AVEncCommonMeanBitRate, &VARIANT::from(bitrate)) }.is_ok() + }) } - // SAFETY: Media Foundation sample creation and MFT ProcessInput/ProcessOutput. - unsafe { - let sample = make_sample(&nv12[..expected], hns, dur_hns)?; - let mut out = Vec::new(); - match self.transform.ProcessInput(0, &sample, 0) { - Ok(()) => self.drain(&mut out)?, - Err(e) if e.code() == MF_E_NOTACCEPTING => { - self.drain(&mut out)?; - self.transform.ProcessInput(0, &sample, 0)?; - self.drain(&mut out)?; + + /// Feeds one NV12 frame (`width * height * 3 / 2` bytes) and drains encoded + /// Annex-B H.264 access units `(is_keyframe, bytes)`. + pub(crate) fn encode( + &mut self, + nv12: &[u8], + hns: i64, + dur_hns: i64, + ) -> windows::core::Result)>> { + let expected = (self.width as usize) * (self.height as usize) * 3 / 2; + if nv12.len() < expected { + return Ok(Vec::new()); + } + // SAFETY: Media Foundation sample creation and MFT ProcessInput/ProcessOutput. + unsafe { + let sample = make_sample(&nv12[..expected], hns, dur_hns)?; + let mut out = Vec::new(); + match self.transform.ProcessInput(0, &sample, 0) { + Ok(()) => self.drain(&mut out)?, + Err(e) if e.code() == MF_E_NOTACCEPTING => { + self.drain(&mut out)?; + self.transform.ProcessInput(0, &sample, 0)?; + self.drain(&mut out)?; + } + Err(e) => return Err(e), } - Err(e) => return Err(e), + Ok(out) } - Ok(out) } - } - unsafe fn drain(&mut self, out: &mut Vec<(bool, Vec)>) -> windows::core::Result<()> { - loop { - // SAFETY: MFT output draining with allocated or MFT-provided sample. - unsafe { - let allocated = if self.provides_samples { - None - } else { - let s: IMFSample = MFCreateSample()?; - let b: IMFMediaBuffer = MFCreateMemoryBuffer(self.output_size)?; - s.AddBuffer(&b)?; - Some(s) - }; - let mut data = MFT_OUTPUT_DATA_BUFFER { - dwStreamID: 0, - pSample: ManuallyDrop::new(allocated), - dwStatus: 0, - pEvents: ManuallyDrop::new(None), - }; - let mut status = 0u32; - let res = self.transform.ProcessOutput(0, std::slice::from_mut(&mut data), &mut status); - let sample = ManuallyDrop::take(&mut data.pSample); - let _ = ManuallyDrop::take(&mut data.pEvents); - - match res { - Ok(()) => { - if let Some(sample) = sample { - let clean = sample - .GetUINT32(&MFSampleExtension_CleanPoint) - .map(|v| v != 0) - .unwrap_or(false); - let buf: IMFMediaBuffer = sample.ConvertToContiguousBuffer()?; - let mut ptr = std::ptr::null_mut(); - let mut len = 0u32; - buf.Lock(&mut ptr, None, Some(&mut len))?; - let slice = std::slice::from_raw_parts(ptr, len as usize); - let is_idr = !self.sent_first_frame || clean || has_h264_idr(slice); - let mut bytes = Vec::new(); - if is_idr && !has_h264_sps(slice) { - if self.seq_header.is_empty() - && let Ok(cur_out) = self.transform.GetOutputCurrentType(0) - && let Ok(hdr_len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) - && hdr_len > 0 - { - let mut hdr = vec![0u8; hdr_len as usize]; - if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut hdr, None).is_ok() { - self.seq_header = hdr; + unsafe fn drain(&mut self, out: &mut Vec<(bool, Vec)>) -> windows::core::Result<()> { + loop { + // SAFETY: MFT output draining with allocated or MFT-provided sample. + unsafe { + let allocated = if self.provides_samples { + None + } else { + let s: IMFSample = MFCreateSample()?; + let b: IMFMediaBuffer = MFCreateMemoryBuffer(self.output_size)?; + s.AddBuffer(&b)?; + Some(s) + }; + let mut data = MFT_OUTPUT_DATA_BUFFER { + dwStreamID: 0, + pSample: ManuallyDrop::new(allocated), + dwStatus: 0, + pEvents: ManuallyDrop::new(None), + }; + let mut status = 0u32; + let res = self.transform.ProcessOutput(0, std::slice::from_mut(&mut data), &mut status); + let sample = ManuallyDrop::take(&mut data.pSample); + let _ = ManuallyDrop::take(&mut data.pEvents); + + match res { + Ok(()) => { + if let Some(sample) = sample { + let clean = sample + .GetUINT32(&MFSampleExtension_CleanPoint) + .map(|v| v != 0) + .unwrap_or(false); + let buf: IMFMediaBuffer = sample.ConvertToContiguousBuffer()?; + let mut ptr = std::ptr::null_mut(); + let mut len = 0u32; + buf.Lock(&mut ptr, None, Some(&mut len))?; + let slice = std::slice::from_raw_parts(ptr, len as usize); + let is_idr = !self.sent_first_frame || clean || has_h264_idr(slice); + let mut bytes = Vec::new(); + if is_idr && !has_h264_sps(slice) { + if self.seq_header.is_empty() + && let Ok(cur_out) = self.transform.GetOutputCurrentType(0) + && let Ok(hdr_len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) + && hdr_len > 0 + { + let mut hdr = vec![0u8; hdr_len as usize]; + if cur_out + .GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut hdr, None) + .is_ok() + { + self.seq_header = hdr; + } + } + if !self.seq_header.is_empty() { + bytes.extend_from_slice(&self.seq_header); } } - if !self.seq_header.is_empty() { - bytes.extend_from_slice(&self.seq_header); + bytes.extend_from_slice(slice); + buf.Unlock()?; + if !bytes.is_empty() { + self.sent_first_frame = true; + out.push((is_idr, bytes)); } } - bytes.extend_from_slice(slice); - buf.Unlock()?; - if !bytes.is_empty() { - self.sent_first_frame = true; - out.push((is_idr, bytes)); - } } - } - Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(()), - Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => { - if let Ok(cur_out) = self.transform.GetOutputAvailableType(0, 0) { - self.transform.SetOutputType(0, Some(&cur_out), 0)?; - if let Ok(len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) - && len > 0 - { - let mut buf = vec![0u8; len as usize]; - if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { - self.seq_header = buf; + Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(()), + Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => { + if let Ok(cur_out) = self.transform.GetOutputAvailableType(0, 0) { + self.transform.SetOutputType(0, Some(&cur_out), 0)?; + if let Ok(len) = cur_out.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) + && len > 0 + { + let mut buf = vec![0u8; len as usize]; + if cur_out.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() { + self.seq_header = buf; + } } } } + Err(e) => return Err(e), } - Err(e) => return Err(e), } } } } -} -unsafe fn make_sample(bytes: &[u8], hns: i64, dur_hns: i64) -> windows::core::Result { - // SAFETY: allocates an IMFMediaBuffer of `bytes.len()` and copies `bytes` into it. - unsafe { - let buf: IMFMediaBuffer = MFCreateMemoryBuffer(bytes.len() as u32)?; - let mut dst = std::ptr::null_mut(); - buf.Lock(&mut dst, None, None)?; - std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len()); - buf.Unlock()?; - buf.SetCurrentLength(bytes.len() as u32)?; - let sample: IMFSample = MFCreateSample()?; - sample.AddBuffer(&buf)?; - sample.SetSampleTime(hns)?; - sample.SetSampleDuration(dur_hns.max(1))?; - Ok(sample) + unsafe fn make_sample(bytes: &[u8], hns: i64, dur_hns: i64) -> windows::core::Result { + // SAFETY: allocates an IMFMediaBuffer of `bytes.len()` and copies `bytes` into it. + unsafe { + let buf: IMFMediaBuffer = MFCreateMemoryBuffer(bytes.len() as u32)?; + let mut dst = std::ptr::null_mut(); + buf.Lock(&mut dst, None, None)?; + std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len()); + buf.Unlock()?; + buf.SetCurrentLength(bytes.len() as u32)?; + let sample: IMFSample = MFCreateSample()?; + sample.AddBuffer(&buf)?; + sample.SetSampleTime(hns)?; + sample.SetSampleDuration(dur_hns.max(1))?; + Ok(sample) + } } -} -fn has_h264_idr(data: &[u8]) -> bool { - data.windows(4).any(|w| w[..3] == [0, 0, 1] && (w[3] & 0x1f) == 5) -} + fn has_h264_idr(data: &[u8]) -> bool { + data.windows(4).any(|w| w[..3] == [0, 0, 1] && (w[3] & 0x1f) == 5) + } -fn has_h264_sps(data: &[u8]) -> bool { - data.windows(4).any(|w| w[..3] == [0, 0, 1] && (w[3] & 0x1f) == 7) -} + fn has_h264_sps(data: &[u8]) -> bool { + data.windows(4).any(|w| w[..3] == [0, 0, 1] && (w[3] & 0x1f) == 7) + } -/// Captures the primary desktop monitor into `bgra` scaled to `(dst_w, dst_h)`, -/// with the pointer, and returns the current physical screen dimensions -/// `(src_w, src_h)`. With `region` (physical screen pixels), captures only -/// that, without the pointer (Play on phone), and returns its size. -fn capture_screen(region: Option, dst_w: u32, dst_h: u32, bgra: &mut Vec) -> Option<(u32, u32)> { - // SAFETY: Win32 GDI device context and bitmap calls with balanced cleanup. - unsafe { - let hdc_screen = GetDC(None); - if hdc_screen.is_invalid() { - return None; - } - let (src_x, src_y, src_w, src_h) = match region { - Some(r) => (r.left, r.top, (r.right - r.left).max(1) as u32, (r.bottom - r.top).max(1) as u32), - None => { - let phys_w = GetDeviceCaps(Some(hdc_screen), DESKTOPHORZRES); - let phys_h = GetDeviceCaps(Some(hdc_screen), DESKTOPVERTRES); - let w = if phys_w > 0 { phys_w } else { GetSystemMetrics(SM_CXSCREEN) }.max(1) as u32; - let h = if phys_h > 0 { phys_h } else { GetSystemMetrics(SM_CYSCREEN) }.max(1) as u32; - (0, 0, w, h) + /// Captures the primary desktop monitor into `bgra` scaled to `(dst_w, dst_h)`, + /// with the pointer, and returns the current physical screen dimensions + /// `(src_w, src_h)`. With `region` (physical screen pixels), captures only + /// that, without the pointer (Play on phone), and returns its size. + fn capture_screen( + region: Option, + dst_w: u32, + dst_h: u32, + bgra: &mut Vec, + ) -> Option<(u32, u32)> { + // SAFETY: Win32 GDI device context and bitmap calls with balanced cleanup. + unsafe { + let hdc_screen = GetDC(None); + if hdc_screen.is_invalid() { + return None; } - }; + let (src_x, src_y, src_w, src_h) = match region { + Some(r) => { + (r.left, r.top, (r.right - r.left).max(1) as u32, (r.bottom - r.top).max(1) as u32) + } + None => { + let phys_w = GetDeviceCaps(Some(hdc_screen), DESKTOPHORZRES); + let phys_h = GetDeviceCaps(Some(hdc_screen), DESKTOPVERTRES); + let w = if phys_w > 0 { phys_w } else { GetSystemMetrics(SM_CXSCREEN) }.max(1) as u32; + let h = if phys_h > 0 { phys_h } else { GetSystemMetrics(SM_CYSCREEN) }.max(1) as u32; + (0, 0, w, h) + } + }; - let hdc_mem = CreateCompatibleDC(Some(hdc_screen)); - if hdc_mem.is_invalid() { - let _ = ReleaseDC(None, hdc_screen); - return None; - } - let hbm = CreateCompatibleBitmap(hdc_screen, dst_w as i32, dst_h as i32); - if hbm.is_invalid() { - let _ = DeleteDC(hdc_mem); - let _ = ReleaseDC(None, hdc_screen); - return None; - } - let old = SelectObject(hdc_mem, hbm.into()); - let _ = SetStretchBltMode(hdc_mem, HALFTONE); - let ok = StretchBlt( - hdc_mem, - 0, - 0, - dst_w as i32, - dst_h as i32, - Some(hdc_screen), - src_x, - src_y, - src_w as i32, - src_h as i32, - SRCCOPY, - ) - .as_bool(); - - // Draw the mouse cursor at its scaled position so the phone user sees where they point. - let mut ci = CURSORINFO { - cbSize: size_of::() as u32, - flags: Default::default(), - hCursor: Default::default(), - ptScreenPos: POINT::default(), - }; - if region.is_none() && GetCursorInfo(&mut ci).is_ok() && (ci.flags.0 & CURSOR_SHOWING.0) != 0 { - let cx = ((ci.ptScreenPos.x as i64) * (dst_w as i64) / (src_w as i64)) as i32; - let cy = ((ci.ptScreenPos.y as i64) * (dst_h as i64) / (src_h as i64)) as i32; - let _ = DrawIconEx(hdc_mem, cx, cy, ci.hCursor.into(), 0, 0, 0, None, DI_NORMAL); - } + let hdc_mem = CreateCompatibleDC(Some(hdc_screen)); + if hdc_mem.is_invalid() { + let _ = ReleaseDC(None, hdc_screen); + return None; + } + let hbm = CreateCompatibleBitmap(hdc_screen, dst_w as i32, dst_h as i32); + if hbm.is_invalid() { + let _ = DeleteDC(hdc_mem); + let _ = ReleaseDC(None, hdc_screen); + return None; + } + let old = SelectObject(hdc_mem, hbm.into()); + let _ = SetStretchBltMode(hdc_mem, HALFTONE); + let ok = StretchBlt( + hdc_mem, + 0, + 0, + dst_w as i32, + dst_h as i32, + Some(hdc_screen), + src_x, + src_y, + src_w as i32, + src_h as i32, + SRCCOPY, + ) + .as_bool(); + + // Draw the mouse cursor at its scaled position so the phone user sees where they point. + let mut ci = CURSORINFO { + cbSize: size_of::() as u32, + flags: Default::default(), + hCursor: Default::default(), + ptScreenPos: POINT::default(), + }; + if region.is_none() && GetCursorInfo(&mut ci).is_ok() && (ci.flags.0 & CURSOR_SHOWING.0) != 0 { + let cx = ((ci.ptScreenPos.x as i64) * (dst_w as i64) / (src_w as i64)) as i32; + let cy = ((ci.ptScreenPos.y as i64) * (dst_h as i64) / (src_h as i64)) as i32; + let _ = DrawIconEx(hdc_mem, cx, cy, ci.hCursor.into(), 0, 0, 0, None, DI_NORMAL); + } - let needed = (dst_w as usize) * (dst_h as usize) * 4; - bgra.resize(needed, 0); - - let mut bmi = BITMAPINFO { - bmiHeader: BITMAPINFOHEADER { - biSize: size_of::() as u32, - biWidth: dst_w as i32, - biHeight: -(dst_h as i32), // Top-down DIB. - biPlanes: 1, - biBitCount: 32, - biCompression: BI_RGB.0, - biSizeImage: needed as u32, + let needed = (dst_w as usize) * (dst_h as usize) * 4; + bgra.resize(needed, 0); + + let mut bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: size_of::() as u32, + biWidth: dst_w as i32, + biHeight: -(dst_h as i32), // Top-down DIB. + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + biSizeImage: needed as u32, + ..Default::default() + }, ..Default::default() - }, - ..Default::default() - }; + }; + + let lines = GetDIBits( + hdc_mem, + hbm, + 0, + dst_h, + Some(bgra.as_mut_ptr().cast()), + &mut bmi, + windows::Win32::Graphics::Gdi::DIB_RGB_COLORS, + ); + + let _ = SelectObject(hdc_mem, old); + let _ = DeleteObject(hbm.into()); + let _ = DeleteDC(hdc_mem); + let _ = ReleaseDC(None, hdc_screen); - let lines = GetDIBits( - hdc_mem, - hbm, - 0, - dst_h, - Some(bgra.as_mut_ptr().cast()), - &mut bmi, - windows::Win32::Graphics::Gdi::DIB_RGB_COLORS, - ); - - let _ = SelectObject(hdc_mem, old); - let _ = DeleteObject(hbm.into()); - let _ = DeleteDC(hdc_mem); - let _ = ReleaseDC(None, hdc_screen); - - if ok && lines > 0 { Some((src_w, src_h)) } else { None } + if ok && lines > 0 { Some((src_w, src_h)) } else { None } + } } -} -fn run_capture_loop( - peer: DeviceId, - options: MirrorStart, - stop: Arc, - force_keyframe: Arc, -) { - let Some(node) = core_host::node() else { - cleanup_session(&stop); - return; - }; - let runtime = &core_host::host().runtime; - let Ok(sender) = runtime.block_on(node.mirror_open(peer)) else { - tracing::warn!(%peer, "couldn't open PC mirror stream to phone"); - cleanup_session(&stop); - return; - }; + fn run_capture_loop( + peer: DeviceId, + options: MirrorStart, + stop: Arc, + force_keyframe: Arc, + ) { + let Some(node) = core_host::node() else { + cleanup_session(&stop); + return; + }; + let runtime = &core_host::host().runtime; + let Ok(sender) = runtime.block_on(node.mirror_open(peer)) else { + tracing::warn!(%peer, "couldn't open PC mirror stream to phone"); + cleanup_session(&stop); + return; + }; - // Give the phone's PcMirrorView TextureView and MediaCodec a brief moment to - // mount before sending the initial MirrorConfig and IDR keyframe. - std::thread::sleep(Duration::from_millis(120)); - - let fps = options.fps.clamp(10, 60); - let max_bitrate = options.bitrate.clamp(500_000, 20_000_000); - // Lowered when the link can't keep up (frames dropped to hold the delay down), - // raised back once it has been clean for a while. - let mut bitrate = max_bitrate; - let frame_interval = Duration::from_micros(1_000_000 / u64::from(fps)); - let dur_hns = (10_000_000 / u64::from(fps)) as i64; - - let mut bgra = Vec::new(); - let mut nv12 = Vec::new(); - let mut stream_dims: Option<(u32, u32)> = None; - let mut encoder: Option = None; - let started = Instant::now(); - let mut last_reinit = Instant::now() - Duration::from_secs(5); - let mut last_drop: Option = None; - let mut last_rate_change = Instant::now(); - let mut stats = Stats::default(); - let mut stats_since = Instant::now(); - // Play on phone: the player's picture instead of the screen, and the - // PC's sound to that phone while it watches. The picture's place comes - // in physical pixels; so does this thread's capture. - if options.watch.is_some() { - win::browser::use_physical_pixels(); - } - let watched = options.watch.as_deref().map(crate::play_on_phone::Watched::follow); - let mut window_capture: Option = None; - let _sound = watched.is_some().then(|| SoundToPhone::start(peer)); - if watched.is_some() { - tracing::info!(%peer, fps, bitrate, "streaming a player to the phone"); - } else { - tracing::info!(%peer, fps, bitrate, "sharing this PC's screen"); - } + // Give the phone's PcMirrorView TextureView and MediaCodec a brief moment to + // mount before sending the initial MirrorConfig and IDR keyframe. + std::thread::sleep(Duration::from_millis(120)); - while !stop.load(Ordering::Relaxed) { - let tick_start = Instant::now(); + let fps = options.fps.clamp(10, 60); + let max_bitrate = options.bitrate.clamp(500_000, 20_000_000); + // Lowered when the link can't keep up (frames dropped to hold the delay down), + // raised back once it has been clean for a while. + let mut bitrate = max_bitrate; + let frame_interval = Duration::from_micros(1_000_000 / u64::from(fps)); + let dur_hns = (10_000_000 / u64::from(fps)) as i64; - let place = watched.as_ref().and_then(crate::play_on_phone::Watched::place); - if watched.is_some() && place.is_none() && started.elapsed() < FIND_WINDOW { - // Not found yet: nothing to show. - std::thread::sleep(frame_interval); - continue; + let mut bgra = Vec::new(); + let mut nv12 = Vec::new(); + let mut stream_dims: Option<(u32, u32)> = None; + let mut encoder: Option = None; + let started = Instant::now(); + let mut last_reinit = Instant::now() - Duration::from_secs(5); + let mut last_drop: Option = None; + let mut last_rate_change = Instant::now(); + let mut stats = Stats::default(); + let mut stats_since = Instant::now(); + // Play on phone: the player's picture instead of the screen, and the + // PC's sound to that phone while it watches. The picture's place comes + // in physical pixels; so does this thread's capture. + if options.watch.is_some() { + win::browser::use_physical_pixels(); } - if watched.is_some() && place.is_none() { - // The player's window can't be found. Not the screen instead: - // that would show the phone whatever else is on it. - tracing::info!(%peer, "can't find the player's window; stopping"); - crate::bridge::app::show_message(crate::i18n::tr!( - "PcMirror", - "Can't find the player's window. Play the video on this PC again, then turn on Play on phone." - )); - break; + let watched = options.watch.as_deref().map(crate::play_on_phone::Watched::follow); + let mut window_capture: Option = None; + let _sound = watched.is_some().then(|| SoundToPhone::start(peer)); + if watched.is_some() { + tracing::info!(%peer, fps, bitrate, "streaming a player to the phone"); + } else { + tracing::info!(%peer, fps, bitrate, "sharing this PC's screen"); } - if let Some(place) = place { - let r = place.on_screen; - let dims = - target_dimensions((r.right - r.left) as u32, (r.bottom - r.top) as u32, options.max_size); - if stream_dims != Some(dims) { - // A new size (the window moved to full screen, say): a new stream config. - stream_dims = Some(dims); - encoder = None; + + while !stop.load(Ordering::Relaxed) { + let tick_start = Instant::now(); + + let place = watched.as_ref().and_then(crate::play_on_phone::Watched::place); + if watched.is_some() && place.is_none() && started.elapsed() < FIND_WINDOW { + // Not found yet: nothing to show. + std::thread::sleep(frame_interval); + continue; } - // The window's own pixels, whatever is in front of it; the - // screen there when Windows can't capture the window. - if window_capture.as_ref().is_none_or(|c| c.window() != place.window()) { - window_capture = win::window_capture::WindowCapture::new(place.window()) - .inspect_err( - |e| tracing::info!(error = %e, "can't capture the window; capturing the screen"), - ) - .ok(); + if watched.is_some() && place.is_none() { + // The player's window can't be found. Not the screen instead: + // that would show the phone whatever else is on it. + tracing::info!(%peer, "can't find the player's window; stopping"); + crate::bridge::app::show_message(crate::i18n::tr!( + "PcMirror", + "Can't find the player's window. Play the video on this PC again, then turn on Play on phone." + )); + break; } - } - - let (dst_w, dst_h) = match stream_dims { - Some(dims) => dims, - None => { - let (src_w, src_h) = unsafe { - let hdc = GetDC(None); - let pw = if !hdc.is_invalid() { GetDeviceCaps(Some(hdc), DESKTOPHORZRES) } else { 0 }; - let ph = if !hdc.is_invalid() { GetDeviceCaps(Some(hdc), DESKTOPVERTRES) } else { 0 }; - if !hdc.is_invalid() { - let _ = ReleaseDC(None, hdc); - } - ( - if pw > 0 { pw as u32 } else { GetSystemMetrics(SM_CXSCREEN).max(640) as u32 }, - if ph > 0 { ph as u32 } else { GetSystemMetrics(SM_CYSCREEN).max(480) as u32 }, - ) - }; - let dims = target_dimensions(src_w, src_h, options.max_size); - stream_dims = Some(dims); - dims + if let Some(place) = place { + let r = place.on_screen; + let dims = + target_dimensions((r.right - r.left) as u32, (r.bottom - r.top) as u32, options.max_size); + if stream_dims != Some(dims) { + // A new size (the window moved to full screen, say): a new stream config. + stream_dims = Some(dims); + encoder = None; + } + // The window's own pixels, whatever is in front of it; the + // screen there when Windows can't capture the window. + if window_capture.as_ref().is_none_or(|c| c.window() != place.window()) { + window_capture = win::window_capture::WindowCapture::new(place.window()) + .inspect_err( + |e| tracing::info!(error = %e, "can't capture the window; capturing the screen"), + ) + .ok(); + } } - }; - // A keyframe on request comes from the running encoder. Rebuilding it (a new - // stream config plus a full keyframe) is the fallback for encoders that can't. - let requested_kf = force_keyframe.swap(false, Ordering::SeqCst); - let keyframe_in_place = requested_kf && encoder.as_ref().is_some_and(H264Encoder::request_keyframe); - let need_reinit = encoder.is_none() - || (requested_kf && !keyframe_in_place && last_reinit.elapsed() >= Duration::from_millis(1400)); - - if need_reinit { - match H264Encoder::new(dst_w, dst_h, fps, bitrate) { - Ok(enc) => { - let cfg = MirrorConfig { - codec: nectarlink_core::MIRROR_H264.into(), - width: dst_w, - height: dst_h, - session: nectarlink_core::MIRROR_SCREEN, + let (dst_w, dst_h) = match stream_dims { + Some(dims) => dims, + None => { + let (src_w, src_h) = unsafe { + let hdc = GetDC(None); + let pw = if !hdc.is_invalid() { GetDeviceCaps(Some(hdc), DESKTOPHORZRES) } else { 0 }; + let ph = if !hdc.is_invalid() { GetDeviceCaps(Some(hdc), DESKTOPVERTRES) } else { 0 }; + if !hdc.is_invalid() { + let _ = ReleaseDC(None, hdc); + } + ( + if pw > 0 { pw as u32 } else { GetSystemMetrics(SM_CXSCREEN).max(640) as u32 }, + if ph > 0 { ph as u32 } else { GetSystemMetrics(SM_CYSCREEN).max(480) as u32 }, + ) }; - if matches!(sender.send(PacketKind::Config, 0, cfg.to_cbor()), MirrorSend::Closed) { + let dims = target_dimensions(src_w, src_h, options.max_size); + stream_dims = Some(dims); + dims + } + }; + + // A keyframe on request comes from the running encoder. Rebuilding it (a new + // stream config plus a full keyframe) is the fallback for encoders that can't. + let requested_kf = force_keyframe.swap(false, Ordering::SeqCst); + let keyframe_in_place = + requested_kf && encoder.as_ref().is_some_and(H264Encoder::request_keyframe); + let need_reinit = encoder.is_none() + || (requested_kf + && !keyframe_in_place + && last_reinit.elapsed() >= Duration::from_millis(1400)); + + if need_reinit { + match H264Encoder::new(dst_w, dst_h, fps, bitrate) { + Ok(enc) => { + let cfg = MirrorConfig { + codec: nectarlink_core::MIRROR_H264.into(), + width: dst_w, + height: dst_h, + session: nectarlink_core::MIRROR_SCREEN, + }; + if matches!(sender.send(PacketKind::Config, 0, cfg.to_cbor()), MirrorSend::Closed) { + break; + } + encoder = Some(enc); + last_reinit = Instant::now(); + stats.rebuilds += 1; + } + Err(e) => { + tracing::warn!(error = %e, "PC mirror H.264 encoder init failed"); break; } - encoder = Some(enc); - last_reinit = Instant::now(); - stats.rebuilds += 1; - } - Err(e) => { - tracing::warn!(error = %e, "PC mirror H.264 encoder init failed"); - break; } } - } - let captured = match (place, window_capture.as_mut()) { - (Some(place), Some(capture)) => capture.capture(place.in_window, dst_w, dst_h, &mut bgra), - (Some(place), None) => capture_screen(Some(place.on_screen), dst_w, dst_h, &mut bgra).is_some(), - (None, _) => capture_screen(None, dst_w, dst_h, &mut bgra).is_some(), - }; - if captured && let Some(enc) = encoder.as_mut() { - bgra_to_nv12(&bgra, dst_w as usize, dst_h as usize, &mut nv12); - let time_us = started.elapsed().as_micros() as u64; - let hns = (time_us * 10) as i64; - match enc.encode(&nv12, hns, dur_hns) { - Ok(packets) => { - let mut send_failed = false; - for (is_keyframe, bytes) in packets { - let kind = if is_keyframe { PacketKind::Keyframe } else { PacketKind::Frame }; - let len = bytes.len(); - match sender.send(kind, time_us, bytes) { - MirrorSend::Queued => { - stats.frames += 1; - stats.bytes += len as u64; - if is_keyframe { - stats.keyframes += 1; + let captured = match (place, window_capture.as_mut()) { + (Some(place), Some(capture)) => capture.capture(place.in_window, dst_w, dst_h, &mut bgra), + (Some(place), None) => { + capture_screen(Some(place.on_screen), dst_w, dst_h, &mut bgra).is_some() + } + (None, _) => capture_screen(None, dst_w, dst_h, &mut bgra).is_some(), + }; + if captured && let Some(enc) = encoder.as_mut() { + bgra_to_nv12(&bgra, dst_w as usize, dst_h as usize, &mut nv12); + let time_us = started.elapsed().as_micros() as u64; + let hns = (time_us * 10) as i64; + match enc.encode(&nv12, hns, dur_hns) { + Ok(packets) => { + let mut send_failed = false; + for (is_keyframe, bytes) in packets { + let kind = if is_keyframe { PacketKind::Keyframe } else { PacketKind::Frame }; + let len = bytes.len(); + match sender.send(kind, time_us, bytes) { + MirrorSend::Queued => { + stats.frames += 1; + stats.bytes += len as u64; + if is_keyframe { + stats.keyframes += 1; + } } - } - MirrorSend::Dropped => stats.dropped += 1, - MirrorSend::NeedKeyframe => { - stats.dropped += 1; - last_drop = Some(Instant::now()); - force_keyframe.store(true, Ordering::SeqCst); - // The link is slower than the stream: ask for less, at most every 2 s. - if last_rate_change.elapsed() >= Duration::from_secs(2) { - let lower = (bitrate / 4 * 3).max(MIN_BITRATE); - if lower < bitrate && enc.set_bitrate(lower) { - bitrate = lower; - last_rate_change = Instant::now(); + MirrorSend::Dropped => stats.dropped += 1, + MirrorSend::NeedKeyframe => { + stats.dropped += 1; + last_drop = Some(Instant::now()); + force_keyframe.store(true, Ordering::SeqCst); + // The link is slower than the stream: ask for less, at most every 2 s. + if last_rate_change.elapsed() >= Duration::from_secs(2) { + let lower = (bitrate / 4 * 3).max(MIN_BITRATE); + if lower < bitrate && enc.set_bitrate(lower) { + bitrate = lower; + last_rate_change = Instant::now(); + } } } + MirrorSend::Closed => { + send_failed = true; + break; + } } - MirrorSend::Closed => { - send_failed = true; - break; + } + if send_failed { + break; + } + // Clean for 10 s: step the bitrate back up toward what the phone asked for. + let clean = last_drop.is_none_or(|t| t.elapsed() >= Duration::from_secs(10)); + if clean + && bitrate < max_bitrate + && last_rate_change.elapsed() >= Duration::from_secs(10) + { + let higher = (bitrate / 4 * 5).min(max_bitrate); + if enc.set_bitrate(higher) { + bitrate = higher; + last_rate_change = Instant::now(); } } } - if send_failed { - break; - } - // Clean for 10 s: step the bitrate back up toward what the phone asked for. - let clean = last_drop.is_none_or(|t| t.elapsed() >= Duration::from_secs(10)); - if clean && bitrate < max_bitrate && last_rate_change.elapsed() >= Duration::from_secs(10) - { - let higher = (bitrate / 4 * 5).min(max_bitrate); - if enc.set_bitrate(higher) { - bitrate = higher; - last_rate_change = Instant::now(); - } + Err(e) => { + tracing::warn!(error = %e, "PC mirror frame encode error; rebuilding the encoder"); + encoder = None; } } - Err(e) => { - tracing::warn!(error = %e, "PC mirror frame encode error; rebuilding the encoder"); - encoder = None; - } } - } - if stats_since.elapsed() >= Duration::from_secs(10) { - let secs = stats_since.elapsed().as_secs_f64(); - tracing::info!( - %peer, - fps = format!("{:.1}", stats.frames as f64 / secs), - kbps = (stats.bytes as f64 * 8.0 / secs / 1000.0) as u64, - keyframes = stats.keyframes, - dropped = stats.dropped, - rebuilds = stats.rebuilds, - bitrate, - "sharing this PC's screen" - ); - stats = Stats::default(); - stats_since = Instant::now(); - } + if stats_since.elapsed() >= Duration::from_secs(10) { + let secs = stats_since.elapsed().as_secs_f64(); + tracing::info!( + %peer, + fps = format!("{:.1}", stats.frames as f64 / secs), + kbps = (stats.bytes as f64 * 8.0 / secs / 1000.0) as u64, + keyframes = stats.keyframes, + dropped = stats.dropped, + rebuilds = stats.rebuilds, + bitrate, + "sharing this PC's screen" + ); + stats = Stats::default(); + stats_since = Instant::now(); + } - let elapsed = tick_start.elapsed(); - if let Some(rem) = frame_interval.checked_sub(elapsed) { - std::thread::sleep(rem); + let elapsed = tick_start.elapsed(); + if let Some(rem) = frame_interval.checked_sub(elapsed) { + std::thread::sleep(rem); + } } - } - tracing::info!(%peer, "stopped sharing this PC's screen"); - crate::input_share::leave_remote(); - sender.close(); - cleanup_session(&stop); -} - -/// Mute this PC's speakers while a phone watches (the setting). -static MUTE_WHILE_WATCHED: AtomicBool = AtomicBool::new(true); + tracing::info!(%peer, "stopped sharing this PC's screen"); + crate::input_share::leave_remote(); + sender.close(); + cleanup_session(&stop); + } -pub fn set_mute_while_watched(on: bool) { - MUTE_WHILE_WATCHED.store(on, Ordering::Relaxed); -} + /// Mute this PC's speakers while a phone watches (the setting). + static MUTE_WHILE_WATCHED: AtomicBool = AtomicBool::new(true); -/// The PC's sound going to a phone that watches one of its players (as -/// Phone speaker sends it), with the PC's speakers muted if the user wants -/// (capturing the sound doesn't depend on them), for as long as this lives; -/// then as before. -struct SoundToPhone { - was_on: bool, - was_target: Option, - /// This muted the speakers (they weren't already). - muted: bool, -} + pub fn set_mute_while_watched(on: bool) { + MUTE_WHILE_WATCHED.store(on, Ordering::Relaxed); + } -impl SoundToPhone { - fn start(peer: DeviceId) -> SoundToPhone { - let muted = MUTE_WHILE_WATCHED.load(Ordering::Relaxed) - && win::audio::speaker_state().is_some_and(|(_, muted)| !muted) - && win::audio::set_speaker_mute(true).is_ok(); - let sound = SoundToPhone { - was_on: crate::pc_audio_stream::is_enabled(), - was_target: crate::pc_audio_stream::target(), - muted, - }; - crate::pc_audio_stream::set_target(Some(peer)); - crate::pc_audio_stream::set_enabled(true); - sound + /// The PC's sound going to a phone that watches one of its players (as + /// Phone speaker sends it), with the PC's speakers muted if the user wants + /// (capturing the sound doesn't depend on them), for as long as this lives; + /// then as before. + struct SoundToPhone { + was_on: bool, + was_target: Option, + /// This muted the speakers (they weren't already). + muted: bool, } -} -impl Drop for SoundToPhone { - fn drop(&mut self) { - crate::pc_audio_stream::set_target(self.was_target); - crate::pc_audio_stream::set_enabled(self.was_on); - // Unmuted only if this muted it. - if self.muted { - let _ = win::audio::set_speaker_mute(false); + impl SoundToPhone { + fn start(peer: DeviceId) -> SoundToPhone { + let muted = MUTE_WHILE_WATCHED.load(Ordering::Relaxed) + && win::audio::speaker_state().is_some_and(|(_, muted)| !muted) + && win::audio::set_speaker_mute(true).is_ok(); + let sound = SoundToPhone { + was_on: crate::pc_audio_stream::is_enabled(), + was_target: crate::pc_audio_stream::target(), + muted, + }; + crate::pc_audio_stream::set_target(Some(peer)); + crate::pc_audio_stream::set_enabled(true); + sound } } -} -fn cleanup_session(stop: &Arc) { - let removed = { - let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); - if guard.as_ref().is_some_and(|s| Arc::ptr_eq(&s.stop, stop)) { - guard.take().is_some() - } else { - false + impl Drop for SoundToPhone { + fn drop(&mut self) { + crate::pc_audio_stream::set_target(self.was_target); + crate::pc_audio_stream::set_enabled(self.was_on); + // Unmuted only if this muted it. + if self.muted { + let _ = win::audio::set_speaker_mute(false); + } } - }; - if removed { - core_host::host().hub.changed(crate::state::Changes::REMOTE); - core_host::refresh_capabilities(); } -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encodes_nv12_frames_and_decodes_back_with_mft() { - let (w, h) = target_dimensions(1920, 1080, 1280); - assert_eq!(w % 16, 0); - assert_eq!(h % 16, 0); - - let mut enc = H264Encoder::new(w, h, 30, 4_000_000).expect("H264Encoder MFT"); - let mut dec = crate::win::h264::Decoder::new_for_codec("h264").expect("H264 Decoder MFT"); - - let mut bgra = vec![0u8; (w as usize) * (h as usize) * 4]; - for (i, px) in bgra.as_chunks_mut::<4>().0.iter_mut().enumerate() { - px[0] = (i % 255) as u8; - px[1] = ((i / 7) % 255) as u8; - px[2] = ((i / 13) % 255) as u8; - px[3] = 255; + fn cleanup_session(stop: &Arc) { + let removed = { + let mut guard = ACTIVE.lock().unwrap_or_else(|e| e.into_inner()); + if guard.as_ref().is_some_and(|s| Arc::ptr_eq(&s.stop, stop)) { + guard.take().is_some() + } else { + false + } + }; + if removed { + core_host::host().hub.changed(crate::state::Changes::REMOTE); + core_host::refresh_capabilities(); } - let mut nv12 = Vec::new(); - bgra_to_nv12(&bgra, w as usize, h as usize, &mut nv12); - - let mut decoded_any = false; - let mut produced_packets = 0usize; - for i in 0..8 { - let packets = enc.encode(&nv12, i * 333_333, 333_333).expect("encode frame"); - for (_keyframe, data) in packets { - produced_packets += 1; - let pics = dec.decode(&data, (i as u64) * 33_333).expect("decode packet"); - if let Some(pic) = pics.first() { - assert_eq!(pic.width, w); - assert_eq!(pic.height, h); - decoded_any = true; + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn encodes_nv12_frames_and_decodes_back_with_mft() { + let (w, h) = target_dimensions(1920, 1080, 1280); + assert_eq!(w % 16, 0); + assert_eq!(h % 16, 0); + + let mut enc = H264Encoder::new(w, h, 30, 4_000_000).expect("H264Encoder MFT"); + let mut dec = crate::win::h264::Decoder::new_for_codec("h264").expect("H264 Decoder MFT"); + + let mut bgra = vec![0u8; (w as usize) * (h as usize) * 4]; + for (i, px) in bgra.as_chunks_mut::<4>().0.iter_mut().enumerate() { + px[0] = (i % 255) as u8; + px[1] = ((i / 7) % 255) as u8; + px[2] = ((i / 13) % 255) as u8; + px[3] = 255; + } + let mut nv12 = Vec::new(); + bgra_to_nv12(&bgra, w as usize, h as usize, &mut nv12); + + let mut decoded_any = false; + let mut produced_packets = 0usize; + for i in 0..8 { + let packets = enc.encode(&nv12, i * 333_333, 333_333).expect("encode frame"); + for (_keyframe, data) in packets { + produced_packets += 1; + let pics = dec.decode(&data, (i as u64) * 33_333).expect("decode packet"); + if let Some(pic) = pics.first() { + assert_eq!(pic.width, w); + assert_eq!(pic.height, h); + decoded_any = true; + } } } + assert!(produced_packets > 0, "encoder must emit H.264 access units"); + assert!(decoded_any, "decoder must reconstruct at least one frame from H264Encoder"); + } + + /// With a Chromium browser showing a video on a page whose title has + /// `NECTARLINK_TEST_TAB` in it, in view: captures just the video, as + /// Play on phone does. + #[test] + #[ignore] + fn captures_a_browsers_video() { + use crate::win::browser::{Automation, window_for}; + let wanted = std::env::var("NECTARLINK_TEST_TAB").expect("NECTARLINK_TEST_TAB"); + // SAFETY: COM for this test's thread. + let _ = unsafe { + windows::Win32::System::Com::CoInitializeEx( + None, + windows::Win32::System::Com::COINIT_MULTITHREADED, + ) + }; + crate::win::browser::use_physical_pixels(); + let window = window_for("Chrome", &wanted).expect("the test tab's window"); + let rect = Automation::new().unwrap().video_rect(&window).expect("the video"); + let (sw, sh) = ((rect.right - rect.left) as u32, (rect.bottom - rect.top) as u32); + let (w, h) = target_dimensions(sw, sh, 1280); + let mut bgra = Vec::new(); + assert_eq!(capture_screen(Some(rect), w, h, &mut bgra), Some((sw, sh))); + let distinct: std::collections::HashSet<&[u8; 4]> = bgra.as_chunks::<4>().0.iter().collect(); + eprintln!("{rect:?} as {w}x{h}: {} colors", distinct.len()); + assert!(distinct.len() > 50, "a moving test pattern, not a blank"); + } + + #[test] + fn keyframes_come_on_request_without_rebuilding_the_encoder() { + let (w, h) = target_dimensions(1280, 720, 1280); + let mut enc = H264Encoder::new(w, h, 30, 2_000_000).expect("H264Encoder MFT"); + let mut nv12 = Vec::new(); + bgra_to_nv12(&vec![128u8; (w as usize) * (h as usize) * 4], w as usize, h as usize, &mut nv12); + + let keyframes_after = |enc: &mut H264Encoder, start: i64, count: i64| { + (start..start + count) + .flat_map(|i| enc.encode(&nv12, i * 333_333, 333_333).expect("encode frame")) + .filter(|(keyframe, data)| *keyframe && has_h264_idr(data)) + .count() + }; + // The first frame is a keyframe; the next few (well inside the 2 s GOP) are not. + assert_eq!(keyframes_after(&mut enc, 0, 1), 1, "the stream starts with a keyframe"); + assert_eq!(keyframes_after(&mut enc, 1, 5), 0, "no keyframes mid-GOP unprompted"); + assert!(enc.request_keyframe(), "the Windows H.264 encoder takes keyframe requests"); + assert_eq!(keyframes_after(&mut enc, 6, 2), 1, "a requested keyframe arrives promptly"); + assert!(enc.set_bitrate(1_000_000), "the bitrate changes in place"); } - assert!(produced_packets > 0, "encoder must emit H.264 access units"); - assert!(decoded_any, "decoder must reconstruct at least one frame from H264Encoder"); } +} - /// With a Chromium browser showing a video on a page whose title has - /// `NECTARLINK_TEST_TAB` in it, in view: captures just the video, as - /// Play on phone does. - #[test] - #[ignore] - fn captures_a_browsers_video() { - use crate::win::browser::{Automation, window_for}; - let wanted = std::env::var("NECTARLINK_TEST_TAB").expect("NECTARLINK_TEST_TAB"); - // SAFETY: COM for this test's thread. - let _ = unsafe { - windows::Win32::System::Com::CoInitializeEx( - None, - windows::Win32::System::Com::COINIT_MULTITHREADED, - ) - }; - crate::win::browser::use_physical_pixels(); - let window = window_for("Chrome", &wanted).expect("the test tab's window"); - let rect = Automation::new().unwrap().video_rect(&window).expect("the video"); - let (sw, sh) = ((rect.right - rect.left) as u32, (rect.bottom - rect.top) as u32); - let (w, h) = target_dimensions(sw, sh, 1280); - let mut bgra = Vec::new(); - assert_eq!(capture_screen(Some(rect), w, h, &mut bgra), Some((sw, sh))); - let distinct: std::collections::HashSet<&[u8; 4]> = bgra.as_chunks::<4>().0.iter().collect(); - eprintln!("{rect:?} as {w}x{h}: {} colors", distinct.len()); - assert!(distinct.len() > 50, "a moving test pattern, not a blank"); +#[cfg(not(windows))] +mod imp { + #![allow(dead_code)] + + use nectarlink_core::{DeviceId, MirrorInput, MirrorStart, NodeEvent}; + + pub fn active_peer() -> Option { + None } - #[test] - fn keyframes_come_on_request_without_rebuilding_the_encoder() { - let (w, h) = target_dimensions(1280, 720, 1280); - let mut enc = H264Encoder::new(w, h, 30, 2_000_000).expect("H264Encoder MFT"); - let mut nv12 = Vec::new(); - bgra_to_nv12(&vec![128u8; (w as usize) * (h as usize) * 4], w as usize, h as usize, &mut nv12); + pub fn is_active() -> bool { + false + } - let keyframes_after = |enc: &mut H264Encoder, start: i64, count: i64| { - (start..start + count) - .flat_map(|i| enc.encode(&nv12, i * 333_333, 333_333).expect("encode frame")) - .filter(|(keyframe, data)| *keyframe && has_h264_idr(data)) - .count() - }; - // The first frame is a keyframe; the next few (well inside the 2 s GOP) are not. - assert_eq!(keyframes_after(&mut enc, 0, 1), 1, "the stream starts with a keyframe"); - assert_eq!(keyframes_after(&mut enc, 1, 5), 0, "no keyframes mid-GOP unprompted"); - assert!(enc.request_keyframe(), "the Windows H.264 encoder takes keyframe requests"); - assert_eq!(keyframes_after(&mut enc, 6, 2), 1, "a requested keyframe arrives promptly"); - assert!(enc.set_bitrate(1_000_000), "the bitrate changes in place"); + pub fn is_watching() -> bool { + false } + + pub fn stop_watching() {} + + pub fn start(_peer: DeviceId, _options: MirrorStart) {} + + pub fn stop_for(_peer: &DeviceId) {} + + pub fn stop_all() {} + + pub fn request_keyframe(_peer: &DeviceId) {} + + pub fn handle_input(_peer: &DeviceId, _session: u32, _input: MirrorInput) {} + + pub fn on_event(_event: &NodeEvent) {} + + pub fn set_mute_while_watched(_on: bool) {} } + +pub use imp::*; diff --git a/desktop/app/src/play_on_phone.rs b/desktop/app/src/play_on_phone.rs index 55fd7565..79386b6f 100644 --- a/desktop/app/src/play_on_phone.rs +++ b/desktop/app/src/play_on_phone.rs @@ -4,347 +4,411 @@ //! the phone in its app and the PC pauses; anything else the phone watches //! streamed from the PC, which keeps playing it. -#![allow(unsafe_code)] - -use crate::i18n::tr; -use std::{ - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - time::{Duration, Instant}, -}; - -use nectarlink_core::{DeviceId, Error, FeatureState, LinkState, MediaAction, PlayOnPhone}; -use windows::Win32::{ - Foundation::{HWND, RECT}, - System::Com::{COINIT_MULTITHREADED, CoInitializeEx}, -}; - -use crate::{ - bridge::app::{describe, show_message}, - core_host, - win::browser::{self, Automation, Window}, -}; - -/// The phone to play on: the first one online that can watch a PC's -/// playback, else the first one online. -pub fn phone() -> Option<(DeviceId, String)> { - core_host::host().hub.read(|s| { - let online: Vec<_> = - s.devices.iter().filter(|d| matches!(d.link, LinkState::Online { .. })).collect(); - let watches = |id: &DeviceId| { - s.matrices.get(id).and_then(|m| m.state("media.play_on_phone")) == Some(FeatureState::Available) - }; - online.iter().find(|d| watches(&d.id)).or_else(|| online.first()).map(|d| (d.id, d.info.name.clone())) - }) -} +#[cfg(windows)] +mod imp { + + #![allow(unsafe_code)] + + use crate::i18n::tr; + use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, + }; + + use nectarlink_core::{DeviceId, Error, FeatureState, LinkState, MediaAction, PlayOnPhone}; + use windows::Win32::{ + Foundation::{HWND, RECT}, + System::Com::{COINIT_MULTITHREADED, CoInitializeEx}, + }; + + use crate::{ + bridge::app::{describe, show_message}, + core_host, + win::browser::{self, Automation, Window}, + }; -/// Apps (by player ID or name) that play music, never a video worth -/// watching on a phone, unless they say otherwise. -const MUSIC_APPS: &[&str] = &[ - "spotify", - "applemusic", - "itunes", - "zunemusic", - "foobar2000", - "musicbee", - "aimp", - "winamp", - "tidal", - "deezer", - "amazonmusic", - "youtube music", - "soundcloud", -]; - -/// Whether a player looks like it plays a video: it says so, or it is -/// anything but a music app (browsers say "music" for every video). -pub(crate) fn plays_video(id: &str, app: &str, says_video: bool) -> bool { - if says_video { - return true; + /// The phone to play on: the first one online that can watch a PC's + /// playback, else the first one online. + pub fn phone() -> Option<(DeviceId, String)> { + core_host::host().hub.read(|s| { + let online: Vec<_> = + s.devices.iter().filter(|d| matches!(d.link, LinkState::Online { .. })).collect(); + let watches = |id: &DeviceId| { + s.matrices.get(id).and_then(|m| m.state("media.play_on_phone")) + == Some(FeatureState::Available) + }; + online + .iter() + .find(|d| watches(&d.id)) + .or_else(|| online.first()) + .map(|d| (d.id, d.info.name.clone())) + }) } - let (id, app) = (id.to_ascii_lowercase(), app.to_ascii_lowercase()); - !MUSIC_APPS.iter().any(|m| id.contains(m) || app.contains(m)) -} -/// The player of this PC's to offer for Play on phone: the first one playing -/// a video. -pub fn watchable() -> Option { - crate::media::local_views() - .into_iter() - .map(|v| v.player) - .find(|p| p.playing && plays_video(&p.id, &p.app, crate::win::media_sessions::says_video(&p.id))) - .map(|p| p.id) -} + /// Apps (by player ID or name) that play music, never a video worth + /// watching on a phone, unless they say otherwise. + const MUSIC_APPS: &[&str] = &[ + "spotify", + "applemusic", + "itunes", + "zunemusic", + "foobar2000", + "musicbee", + "aimp", + "winamp", + "tidal", + "deezer", + "amazonmusic", + "youtube music", + "soundcloud", + ]; -/// When a phone was last asked to watch (its notification is up). -static ASKED: Mutex> = Mutex::new(None); -/// How long a phone has to accept before the toggle gives up waiting. -const ASK_FOR: Duration = Duration::from_secs(60); + /// Whether a player looks like it plays a video: it says so, or it is + /// anything but a music app (browsers say "music" for every video). + pub(crate) fn plays_video(id: &str, app: &str, says_video: bool) -> bool { + if says_video { + return true; + } + let (id, app) = (id.to_ascii_lowercase(), app.to_ascii_lowercase()); + !MUSIC_APPS.iter().any(|m| id.contains(m) || app.contains(m)) + } -/// Where Play on phone is: `"off"`, `"asking"` (the phone's notification -/// is up) or `"watching"`. -pub fn state() -> &'static str { - if crate::pc_mirror::is_watching() { - *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = None; - return "watching"; + /// The player of this PC's to offer for Play on phone: the first one playing + /// a video. + pub fn watchable() -> Option { + crate::media::local_views() + .into_iter() + .map(|v| v.player) + .find(|p| p.playing && plays_video(&p.id, &p.app, crate::win::media_sessions::says_video(&p.id))) + .map(|p| p.id) } - match *ASKED.lock().unwrap_or_else(|e| e.into_inner()) { - Some(at) if at.elapsed() < ASK_FOR => "asking", - _ => "off", + + /// When a phone was last asked to watch (its notification is up). + static ASKED: Mutex> = Mutex::new(None); + /// How long a phone has to accept before the toggle gives up waiting. + const ASK_FOR: Duration = Duration::from_secs(60); + + /// Where Play on phone is: `"off"`, `"asking"` (the phone's notification + /// is up) or `"watching"`. + pub fn state() -> &'static str { + if crate::pc_mirror::is_watching() { + *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = None; + return "watching"; + } + match *ASKED.lock().unwrap_or_else(|e| e.into_inner()) { + Some(at) if at.elapsed() < ASK_FOR => "asking", + _ => "off", + } } -} -fn asked() { - *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); - core_host::host().hub.changed(crate::state::Changes::MEDIA); - // The toggle stops waiting when the phone doesn't answer. - core_host::spawn(async { - tokio::time::sleep(ASK_FOR).await; + fn asked() { + *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); core_host::host().hub.changed(crate::state::Changes::MEDIA); - }); -} + // The toggle stops waiting when the phone doesn't answer. + core_host::spawn(async { + tokio::time::sleep(ASK_FOR).await; + core_host::host().hub.changed(crate::state::Changes::MEDIA); + }); + } -/// The Play on phone toggle: on plays the video playing now on the phone; -/// off stops the phone watching (or waiting to). -pub fn set_on(on: bool) { - if on { - match watchable() { - Some(player) => play(player), - None => core_host::host().hub.changed(crate::state::Changes::MEDIA), + /// The Play on phone toggle: on plays the video playing now on the phone; + /// off stops the phone watching (or waiting to). + pub fn set_on(on: bool) { + if on { + match watchable() { + Some(player) => play(player), + None => core_host::host().hub.changed(crate::state::Changes::MEDIA), + } + return; } - return; + *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = None; + crate::pc_mirror::stop_watching(); + core_host::host().hub.changed(crate::state::Changes::MEDIA); } - *ASKED.lock().unwrap_or_else(|e| e.into_inner()) = None; - crate::pc_mirror::stop_watching(); - core_host::host().hub.changed(crate::state::Changes::MEDIA); -} -/// What this PC's `player` plays: its title, and where it is, in seconds. -fn now_playing(player: &str) -> (String, Option) { - let views = crate::media::local_views(); - let Some(view) = views.into_iter().find(|v| v.player.id == player) else { - return (String::new(), None); - }; - let position = view.player.position.map(|ms| { - let since = - if view.player.playing { view.at.elapsed().map_or(0, |d| d.as_millis() as u64) } else { 0 }; - ((ms + since) / 1000) as u32 - }); - (view.player.title.unwrap_or_default(), position.filter(|&s| s > 0)) -} + /// What this PC's `player` plays: its title, and where it is, in seconds. + fn now_playing(player: &str) -> (String, Option) { + let views = crate::media::local_views(); + let Some(view) = views.into_iter().find(|v| v.player.id == player) else { + return (String::new(), None); + }; + let position = view.player.position.map(|ms| { + let since = + if view.player.playing { view.at.elapsed().map_or(0, |d| d.as_millis() as u64) } else { 0 }; + ((ms + since) / 1000) as u32 + }); + (view.player.title.unwrap_or_default(), position.filter(|&s| s > 0)) + } -/// Plays what this PC's `player` plays on a phone (the Play on phone -/// button on the PC's media card). -pub fn play(player: String) { - let Some((phone, name)) = phone() else { - return show_message(tr!("PlayOnPhone", "Connect your phone to play on it.")); - }; - let spawned = std::thread::Builder::new().name("play-on-phone".into()).spawn(move || { - // SAFETY: COM for this short-lived thread (UI Automation). - let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; - let (title, position) = now_playing(&player); - let window = browser::window_for(&player, &title); - // A video file in a desktop player: the phone plays the file itself. - let file = crate::media_file::file_for(&player, &title); - let url = window - .as_ref() - .filter(|w| browser::is_browser(&w.program)) - .and_then(|w| Automation::new().ok()?.page_url(w)); - let plan = nectarlink_core::play_on_phone::plan(url.as_deref(), position); - let Some(node) = core_host::node() else { return }; - core_host::spawn(async move { - if let Some(file) = file { - let shown = (!title.is_empty()).then_some(title); - let at = u64::from(position.unwrap_or(0)) * 1000; - match node.offer_watch_file(phone, player, shown, file, at).await { - Ok(()) => { - asked(); - show_message(tr!("PlayOnPhone", "Tap the notification on %1 to watch.", name)); - } - Err(e) => show_message(failed(&e, &name)), - } - return; - } - match plan { - PlayOnPhone::Open { url, service } => match node.open_link(phone, url).await { - Ok(()) => { - crate::media::command(crate::media::LOCAL_DEVICE, player, MediaAction::Pause, None); - show_message(tr!( - "PlayOnPhone", - "Opening in %1 on %2. Tap the notification there.", - service, - name - )); - } - Err(e) => show_message(failed(&e, &name)), - }, - PlayOnPhone::Stream { open_instead } => { + /// Plays what this PC's `player` plays on a phone (the Play on phone + /// button on the PC's media card). + pub fn play(player: String) { + let Some((phone, name)) = phone() else { + return show_message(tr!("PlayOnPhone", "Connect your phone to play on it.")); + }; + let spawned = std::thread::Builder::new().name("play-on-phone".into()).spawn(move || { + // SAFETY: COM for this short-lived thread (UI Automation). + let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + let (title, position) = now_playing(&player); + let window = browser::window_for(&player, &title); + // A video file in a desktop player: the phone plays the file itself. + let file = crate::media_file::file_for(&player, &title); + let url = window + .as_ref() + .filter(|w| browser::is_browser(&w.program)) + .and_then(|w| Automation::new().ok()?.page_url(w)); + let plan = nectarlink_core::play_on_phone::plan(url.as_deref(), position); + let Some(node) = core_host::node() else { return }; + core_host::spawn(async move { + if let Some(file) = file { let shown = (!title.is_empty()).then_some(title); - match node.offer_watch(phone, player.clone(), shown).await { + let at = u64::from(position.unwrap_or(0)) * 1000; + match node.offer_watch_file(phone, player, shown, file, at).await { Ok(()) => { asked(); - show_message(tr!("PlayOnPhone", "Tap the notification on %1 to watch.", name)) + show_message(tr!("PlayOnPhone", "Tap the notification on %1 to watch.", name)); } - // A phone that can't watch yet: the page opens there instead. - Err(Error::Unsupported) if let Some(url) = open_instead => { - match node.open_link(phone, url).await { - Ok(()) => { - crate::media::command( - crate::media::LOCAL_DEVICE, - player, - MediaAction::Pause, - None, - ); - show_message(tr!( - "PlayOnPhone", - "Sent to %1. Tap the notification there.", - name - )); + Err(e) => show_message(failed(&e, &name)), + } + return; + } + match plan { + PlayOnPhone::Open { url, service } => match node.open_link(phone, url).await { + Ok(()) => { + crate::media::command( + crate::media::LOCAL_DEVICE, + player, + MediaAction::Pause, + None, + ); + show_message(tr!( + "PlayOnPhone", + "Opening in %1 on %2. Tap the notification there.", + service, + name + )); + } + Err(e) => show_message(failed(&e, &name)), + }, + PlayOnPhone::Stream { open_instead } => { + let shown = (!title.is_empty()).then_some(title); + match node.offer_watch(phone, player.clone(), shown).await { + Ok(()) => { + asked(); + show_message(tr!("PlayOnPhone", "Tap the notification on %1 to watch.", name)) + } + // A phone that can't watch yet: the page opens there instead. + Err(Error::Unsupported) if let Some(url) = open_instead => { + match node.open_link(phone, url).await { + Ok(()) => { + crate::media::command( + crate::media::LOCAL_DEVICE, + player, + MediaAction::Pause, + None, + ); + show_message(tr!( + "PlayOnPhone", + "Sent to %1. Tap the notification there.", + name + )); + } + Err(e) => show_message(failed(&e, &name)), } - Err(e) => show_message(failed(&e, &name)), } + Err(e) => show_message(failed(&e, &name)), } - Err(e) => show_message(failed(&e, &name)), } } - } + }); }); - }); - if let Err(e) = spawned { - tracing::warn!(error = %e, "can't start Play on phone"); + if let Err(e) = spawned { + tracing::warn!(error = %e, "can't start Play on phone"); + } } -} -fn failed(error: &Error, phone: &str) -> String { - match error { - Error::Unsupported => tr!("PlayOnPhone", "Update NectarLink on %1 to play on it.", phone), - Error::Denied => tr!("PlayOnPhone", "Media is turned off for %1.", phone), - other => describe(other), + fn failed(error: &Error, phone: &str) -> String { + match error { + Error::Unsupported => tr!("PlayOnPhone", "Update NectarLink on %1 to play on it.", phone), + Error::Denied => tr!("PlayOnPhone", "Media is turned off for %1.", phone), + other => describe(other), + } } -} -/// Where a watched player's picture is, kept up to date while a phone -/// watches it: the video moves when the page scrolls, the window moves or -/// goes full screen. -pub struct Watched { - place: Arc>>, - stop: Arc, -} + /// Where a watched player's picture is, kept up to date while a phone + /// watches it: the video moves when the page scrolls, the window moves or + /// goes full screen. + pub struct Watched { + place: Arc>>, + stop: Arc, + } -/// A watched player's picture: its window, and where in it the picture is. -#[derive(Debug, Clone, Copy)] -pub struct Place { - /// The window's handle (as a number: handles go between threads). - window: isize, - /// The picture, in physical screen pixels. - pub on_screen: RECT, - /// The picture, in the window's own pixels (from its frame's top-left), - /// as a capture of the window has it. - pub in_window: RECT, -} + /// A watched player's picture: its window, and where in it the picture is. + #[derive(Debug, Clone, Copy)] + pub struct Place { + /// The window's handle (as a number: handles go between threads). + window: isize, + /// The picture, in physical screen pixels. + pub on_screen: RECT, + /// The picture, in the window's own pixels (from its frame's top-left), + /// as a capture of the window has it. + pub in_window: RECT, + } -/// How often the picture's place is looked up again. -const FOLLOW_EVERY: Duration = Duration::from_millis(500); -/// How long a browser's player may take to show up in UI Automation -/// before the whole page is streamed instead. -const FIND_PLAYER: Duration = Duration::from_secs(2); + /// How often the picture's place is looked up again. + const FOLLOW_EVERY: Duration = Duration::from_millis(500); + /// How long a browser's player may take to show up in UI Automation + /// before the whole page is streamed instead. + const FIND_PLAYER: Duration = Duration::from_secs(2); -impl Place { - /// The window the picture is in. - pub fn window(&self) -> HWND { - HWND(self.window as *mut core::ffi::c_void) + impl Place { + /// The window the picture is in. + pub fn window(&self) -> HWND { + HWND(self.window as *mut core::ffi::c_void) + } } -} -impl Watched { - /// Starts following `player`'s picture. - pub fn follow(player: &str) -> Watched { - let place = Arc::new(Mutex::new(None)); - let stop = Arc::new(AtomicBool::new(false)); - let (found, stopped, player) = (place.clone(), stop.clone(), player.to_owned()); - let spawned = std::thread::Builder::new().name("play-on-phone-follow".into()).spawn(move || { - // SAFETY: COM for this thread, which ends with the watching. - let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; - // Places in the pixels the capture uses. - browser::use_physical_pixels(); - let automation = Automation::new().ok(); - let mut window: Option = None; - let started = std::time::Instant::now(); - while !stopped.load(Ordering::Relaxed) { - // The window it played in, while it's still there; else find it again. - if window.as_ref().is_none_or(|w| browser::frame_of(w.hwnd).is_none()) { - let (title, _) = now_playing(&player); - window = browser::window_for(&player, &title); - } - // Minimized, it draws nothing and the phone's picture stops: - // back behind the other windows, it plays on out of the way. - if let Some(w) = window.as_ref().filter(|w| browser::is_minimized(w.hwnd)) { - tracing::info!( - "the watched player's window was minimized; restoring it behind the others" - ); - browser::restore_behind(w.hwnd); + impl Watched { + /// Starts following `player`'s picture. + pub fn follow(player: &str) -> Watched { + let place = Arc::new(Mutex::new(None)); + let stop = Arc::new(AtomicBool::new(false)); + let (found, stopped, player) = (place.clone(), stop.clone(), player.to_owned()); + let spawned = std::thread::Builder::new().name("play-on-phone-follow".into()).spawn(move || { + // SAFETY: COM for this thread, which ends with the watching. + let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + // Places in the pixels the capture uses. + browser::use_physical_pixels(); + let automation = Automation::new().ok(); + let mut window: Option = None; + let started = std::time::Instant::now(); + while !stopped.load(Ordering::Relaxed) { + // The window it played in, while it's still there; else find it again. + if window.as_ref().is_none_or(|w| browser::frame_of(w.hwnd).is_none()) { + let (title, _) = now_playing(&player); + window = browser::window_for(&player, &title); + } + // Minimized, it draws nothing and the phone's picture stops: + // back behind the other windows, it plays on out of the way. + if let Some(w) = window.as_ref().filter(|w| browser::is_minimized(w.hwnd)) { + tracing::info!( + "the watched player's window was minimized; restoring it behind the others" + ); + browser::restore_behind(w.hwnd); + std::thread::sleep(FOLLOW_EVERY); + continue; + } + let place = window.as_ref().and_then(|w| { + let frame = browser::frame_of(w.hwnd)?; + let on_screen = match &automation { + // A browser builds what UI Automation sees of a page + // only once asked: give the player a moment to show + // up before streaming the whole page. + Some(a) if browser::is_browser(&w.program) => match a.player_rect(w) { + Some(player) => player, + None if started.elapsed() < FIND_PLAYER => return None, + None => a.page_rect(w).unwrap_or(frame), + }, + _ => frame, + }; + let in_window = RECT { + left: on_screen.left - frame.left, + top: on_screen.top - frame.top, + right: on_screen.right - frame.left, + bottom: on_screen.bottom - frame.top, + }; + Some(Place { window: w.hwnd.0 as isize, on_screen, in_window }) + }); + if place.is_some() { + *found.lock().unwrap_or_else(|e| e.into_inner()) = place; + } std::thread::sleep(FOLLOW_EVERY); - continue; - } - let place = window.as_ref().and_then(|w| { - let frame = browser::frame_of(w.hwnd)?; - let on_screen = match &automation { - // A browser builds what UI Automation sees of a page - // only once asked: give the player a moment to show - // up before streaming the whole page. - Some(a) if browser::is_browser(&w.program) => match a.player_rect(w) { - Some(player) => player, - None if started.elapsed() < FIND_PLAYER => return None, - None => a.page_rect(w).unwrap_or(frame), - }, - _ => frame, - }; - let in_window = RECT { - left: on_screen.left - frame.left, - top: on_screen.top - frame.top, - right: on_screen.right - frame.left, - bottom: on_screen.bottom - frame.top, - }; - Some(Place { window: w.hwnd.0 as isize, on_screen, in_window }) - }); - if place.is_some() { - *found.lock().unwrap_or_else(|e| e.into_inner()) = place; } - std::thread::sleep(FOLLOW_EVERY); + }); + if let Err(e) = spawned { + tracing::warn!(error = %e, "can't follow the watched player"); } - }); - if let Err(e) = spawned { - tracing::warn!(error = %e, "can't follow the watched player"); + Watched { place, stop } + } + + /// Where the picture is now, once known. + pub fn place(&self) -> Option { + *self.place.lock().unwrap_or_else(|e| e.into_inner()) } - Watched { place, stop } } - /// Where the picture is now, once known. - pub fn place(&self) -> Option { - *self.place.lock().unwrap_or_else(|e| e.into_inner()) + impl Drop for Watched { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } } -} -impl Drop for Watched { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); + #[cfg(test)] + mod tests { + use super::plays_video; + + #[test] + fn music_apps_are_not_offered_unless_they_say_video() { + assert!(plays_video("chrome", "YouTube", false), "a browser's video says music"); + assert!(plays_video("F0DC299D809B9700", "Zen", false)); + assert!(plays_video(r"C:/Program Files/VideoLAN/VLC/vlc.exe", "vlc", false)); + assert!(!plays_video("Spotify.exe", "Spotify", false)); + assert!(!plays_video("chrome", "YouTube Music", false), "named by its site"); + assert!(!plays_video( + "Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic", + "Media Player", + false + )); + assert!(plays_video( + "Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic", + "Media Player", + true + )); + } } } -#[cfg(test)] -mod tests { - use super::plays_video; - - #[test] - fn music_apps_are_not_offered_unless_they_say_video() { - assert!(plays_video("chrome", "YouTube", false), "a browser's video says music"); - assert!(plays_video("F0DC299D809B9700", "Zen", false)); - assert!(plays_video(r"C:/Program Files/VideoLAN/VLC/vlc.exe", "vlc", false)); - assert!(!plays_video("Spotify.exe", "Spotify", false)); - assert!(!plays_video("chrome", "YouTube Music", false), "named by its site"); - assert!(!plays_video("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic", "Media Player", false)); - assert!(plays_video("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic", "Media Player", true)); +#[cfg(not(windows))] +mod imp { + #![allow(dead_code)] + + use nectarlink_core::DeviceId; + + pub fn phone() -> Option<(DeviceId, String)> { + None + } + + pub fn watchable() -> Option { + None + } + + pub fn state() -> &'static str { + "idle" + } + + pub fn set_on(_on: bool) {} + + pub fn play(_player: String) {} + + pub struct Watched; + + #[derive(Debug, Clone, Copy)] + pub struct Place { + pub window: isize, + } + + impl Watched { + pub fn follow(_player: &str) -> Watched { + Watched + } + + pub fn place(&self) -> Option { + None + } } } + +pub use imp::*; diff --git a/desktop/app/src/recordings.rs b/desktop/app/src/recordings.rs index a6d7f713..dc7f3cc3 100644 --- a/desktop/app/src/recordings.rs +++ b/desktop/app/src/recordings.rs @@ -15,6 +15,7 @@ use std::{ }; use nectarlink_core::{RecordingMarker, Transfer, TransferState}; +#[cfg(windows)] use windows::{ Win32::{ Media::MediaFoundation::{ @@ -31,14 +32,13 @@ use windows::{ core::{HSTRING, Interface}, }; +#[cfg(windows)] +use crate::win::h264::ensure_mf_started; use crate::{ core_host, settings::{RecordingFormat, Settings}, transfers::{ACTION_OPEN, ACTION_SHOW, TOAST_GROUP}, - win::{ - h264::ensure_mf_started, - toast::{self, Toast}, - }, + win::toast::{self, Toast}, }; #[derive(Debug, Default)] @@ -109,12 +109,48 @@ pub fn format_recording_stem(year: u16, month: u16, day: u16, hour: u16, minute: } /// Base name for a recording saved right now, in local time. +#[cfg(windows)] pub fn current_recording_stem() -> String { // SAFETY: GetLocalTime always succeeds and writes a SYSTEMTIME struct. let st = unsafe { GetLocalTime() }; format_recording_stem(st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute) } +#[cfg(not(windows))] +pub fn current_recording_stem() -> String { + #[repr(C)] + struct Tm { + tm_sec: i32, + tm_min: i32, + tm_hour: i32, + tm_mday: i32, + tm_mon: i32, + tm_year: i32, + tm_wday: i32, + tm_yday: i32, + tm_isdst: i32, + tm_gmtoff: i64, + tm_zone: *const std::ffi::c_char, + } + unsafe extern "C" { + fn time(tloc: *mut i64) -> i64; + fn localtime_r(timep: *const i64, result: *mut Tm) -> *mut Tm; + } + unsafe { + let mut t: i64 = 0; + time(&mut t); + let mut tm = std::mem::zeroed::(); + localtime_r(&t, &mut tm); + format_recording_stem( + (tm.tm_year + 1900) as u16, + (tm.tm_mon + 1) as u16, + tm.tm_mday as u16, + tm.tm_hour as u16, + tm.tm_min as u16, + ) + } +} + /// The name the phone gave a recording (`Recording 2026-10-07 14.32`, from /// when it started), so one that waited for the PC keeps its own time. /// `None` for anything else. @@ -246,6 +282,12 @@ fn move_file(src: &Path, dest: &Path) -> std::io::Result<()> { Ok(()) } +#[cfg(not(windows))] +pub fn convert_audio(_src: &Path, _dest: &Path, _format: RecordingFormat) -> Result<(), String> { + Err("audio conversion via Media Foundation is only supported on Windows".into()) +} + +#[cfg(windows)] /// Converts `src` (`.m4a`) to `dest` in `format` (`Mp3`, `Wav`, or `Flac`) /// using Windows Media Foundation. pub fn convert_audio(src: &Path, dest: &Path, format: RecordingFormat) -> windows::core::Result<()> { @@ -285,6 +327,7 @@ pub fn convert_audio(src: &Path, dest: &Path, format: RecordingFormat) -> window } } +#[cfg(windows)] unsafe fn write_decoded_wav( reader: &IMFSourceReader, stream_idx: u32, @@ -340,6 +383,7 @@ unsafe fn write_decoded_wav( fs::write(dest, wav).map_err(|_| windows::core::Error::from_thread()) } +#[cfg(windows)] unsafe fn transcode_with_sink_writer( reader: &IMFSourceReader, stream_idx: u32, @@ -390,6 +434,7 @@ unsafe fn transcode_with_sink_writer( Ok(()) } +#[cfg(windows)] unsafe fn choose_encoder_output_type( subtype: &windows::core::GUID, sample_rate: u32, @@ -555,6 +600,7 @@ mod tests { } /// Builds a valid 48 kHz mono AAC-LC `.m4a` file for Media Foundation tests. + #[allow(dead_code)] fn test_m4a(duration_secs: u32) -> Vec { const AAC_FRAME: [u8; 6] = [0x01, 0x48, 0x00, 0x84, 0x21, 0x7E]; const SAMPLE_RATE: u32 = 48_000; @@ -762,6 +808,7 @@ mod tests { assert_eq!(text, "00:01.250 Intro\n01:05.000 Marker 2\n01:01:01.005 Wrap-up\n"); } + #[cfg(windows)] #[test] fn media_foundation_converts_m4a_to_m4a_mp3_wav_and_flac_with_markers() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/app/src/setup.rs b/desktop/app/src/setup.rs index ec9b2b96..9928acd7 100644 --- a/desktop/app/src/setup.rs +++ b/desktop/app/src/setup.rs @@ -20,8 +20,9 @@ //! `nectarlink-desktop.exe` by name, which would otherwise include these. use crate::i18n::tr; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use std::{ - os::windows::process::CommandExt, path::{Path, PathBuf}, time::{Duration, Instant}, }; @@ -34,11 +35,14 @@ pub const INSTALLED_ARG: &str = "--installed"; /// Where the Android app is downloaded (the done screen's QR code and link). pub const ANDROID_URL: &str = "https://github.com/thtbee/nectarlink/releases/latest"; -const APP_EXE: &str = "nectarlink-desktop.exe"; +const APP_EXE: &str = if cfg!(windows) { "nectarlink-desktop.exe" } else { "nectarlink-desktop" }; +#[cfg(windows)] const UNINSTALL_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Nectarlink"; -const SETUP_COPY_NAME: &str = "Nectarlink Uninstall.exe"; +const SETUP_COPY_NAME: &str = if cfg!(windows) { "Nectarlink Uninstall.exe" } else { "Nectarlink Uninstall" }; +#[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(windows)] const DETACHED_PROCESS: u32 = 0x0000_0008; /// What the window is for. @@ -58,12 +62,19 @@ pub struct Installed { } pub fn installed() -> Option { - use windows::Win32::System::Registry::HKEY_LOCAL_MACHINE; - let dir = crate::win::reg_string(HKEY_LOCAL_MACHINE, UNINSTALL_KEY, "InstallLocation")?; - let version = - crate::win::reg_string(HKEY_LOCAL_MACHINE, UNINSTALL_KEY, "DisplayVersion").unwrap_or_default(); - let dir = PathBuf::from(dir); - dir.join(APP_EXE).is_file().then_some(Installed { dir, version }) + #[cfg(windows)] + { + use windows::Win32::System::Registry::HKEY_LOCAL_MACHINE; + let dir = crate::win::reg_string(HKEY_LOCAL_MACHINE, UNINSTALL_KEY, "InstallLocation")?; + let version = + crate::win::reg_string(HKEY_LOCAL_MACHINE, UNINSTALL_KEY, "DisplayVersion").unwrap_or_default(); + let dir = PathBuf::from(dir); + dir.join(APP_EXE).is_file().then_some(Installed { dir, version }) + } + #[cfg(not(windows))] + { + None + } } /// How this install relates to the copy already on the PC. @@ -288,12 +299,19 @@ pub fn open_app(dir: &Path) -> Result<(), String> { if !app.is_file() { return Err(format!("{} is missing", app.display())); } - std::process::Command::new("explorer.exe") - .arg(&app) - .creation_flags(DETACHED_PROCESS) - .spawn() - .map(|_| ()) - .map_err(|e| e.to_string()) + #[cfg(windows)] + { + std::process::Command::new("explorer.exe") + .arg(&app) + .creation_flags(DETACHED_PROCESS) + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(windows))] + { + std::process::Command::new("open").arg(&app).spawn().map(|_| ()).map_err(|e| e.to_string()) + } } /// For `--remove` started from the installed folder: carries on from a copy @@ -333,11 +351,18 @@ pub fn delete_own_copy_later() { if !ours { return; } - let script = format!("/c ping -n 4 127.0.0.1 >nul & rmdir /s /q \"{}\"", dir.display()); - let _ = std::process::Command::new("cmd.exe") - .raw_arg(script) - .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) - .spawn(); + #[cfg(windows)] + { + let script = format!("/c ping -n 4 127.0.0.1 >nul & rmdir /s /q \"{}\"", dir.display()); + let _ = std::process::Command::new("cmd.exe") + .raw_arg(script) + .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) + .spawn(); + } + #[cfg(not(windows))] + { + let _ = std::fs::remove_dir_all(dir); + } } fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> { diff --git a/desktop/app/src/storage.rs b/desktop/app/src/storage.rs index 229fb531..f767b85b 100644 --- a/desktop/app/src/storage.rs +++ b/desktop/app/src/storage.rs @@ -8,717 +8,712 @@ //! reads (`CF_CALLBACK_TYPE_FETCH_DATA`). Renames, deletes, and new or modified //! files dropped into the sync root are forwarded to the phone. -#![allow(unsafe_code)] - -use std::{ - collections::{HashMap, HashSet}, - ffi::{OsStr, c_void}, - os::windows::ffi::{OsStrExt, OsStringExt}, - path::{Path, PathBuf}, - sync::{ - Arc, Mutex, MutexGuard, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use nectarlink_core::{DeviceId, LinkState, NodeEvent, StorageEntry}; -use windows::{ - Security::Cryptography::{BinaryStringEncoding, CryptographicBuffer}, - Storage::{ - Provider::{ - StorageProviderHardlinkPolicy, StorageProviderHydrationPolicy, - StorageProviderHydrationPolicyModifier, StorageProviderInSyncPolicy, - StorageProviderPopulationPolicy, StorageProviderSyncRootInfo, StorageProviderSyncRootManager, - }, - StorageFolder, - }, - Win32::{ - Foundation::{ - CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, - STATUS_ACCESS_DENIED, STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE, STATUS_CLOUD_FILE_UNSUCCESSFUL, - STATUS_SUCCESS, +#[cfg(windows)] +mod imp { + + #![allow(unsafe_code)] + + use std::{ + collections::{HashMap, HashSet}, + ffi::{OsStr, c_void}, + os::windows::ffi::{OsStrExt, OsStringExt}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, MutexGuard, + atomic::{AtomicBool, Ordering}, }, + time::Duration, + }; + + use nectarlink_core::{DeviceId, LinkState, NodeEvent, StorageEntry}; + use windows::{ + Security::Cryptography::{BinaryStringEncoding, CryptographicBuffer}, Storage::{ - CloudFilters::{ - CF_CALLBACK_INFO, CF_CALLBACK_PARAMETERS, CF_CALLBACK_REGISTRATION, - CF_CALLBACK_TYPE_FETCH_DATA, CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, CF_CALLBACK_TYPE_NONE, - CF_CALLBACK_TYPE_NOTIFY_DELETE, CF_CALLBACK_TYPE_NOTIFY_RENAME, - CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO, - CF_CONNECTION_KEY, CF_CONVERT_FLAG_MARK_IN_SYNC, CF_FS_METADATA, CF_IN_SYNC_STATE_IN_SYNC, - CF_OPERATION_INFO, CF_OPERATION_PARAMETERS, CF_OPERATION_PARAMETERS_0, - CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION, CF_OPERATION_TYPE, - CF_OPERATION_TYPE_ACK_DELETE, CF_OPERATION_TYPE_ACK_RENAME, CF_OPERATION_TYPE_TRANSFER_DATA, - CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS, CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, - CF_PLACEHOLDER_CREATE_INFO, CF_PLACEHOLDER_STATE_IN_SYNC, CF_PLACEHOLDER_STATE_PLACEHOLDER, - CF_SET_IN_SYNC_FLAG_NONE, CfConnectSyncRoot, CfConvertToPlaceholder, CfCreatePlaceholders, - CfDisconnectSyncRoot, CfExecute, CfGetPlaceholderStateFromAttributeTag, - CfReportProviderProgress, CfSetInSyncState, + Provider::{ + StorageProviderHardlinkPolicy, StorageProviderHydrationPolicy, + StorageProviderHydrationPolicyModifier, StorageProviderInSyncPolicy, + StorageProviderPopulationPolicy, StorageProviderSyncRootInfo, StorageProviderSyncRootManager, + }, + StorageFolder, + }, + Win32::{ + Foundation::{ + CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, + STATUS_ACCESS_DENIED, STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE, STATUS_CLOUD_FILE_UNSUCCESSFUL, + STATUS_SUCCESS, }, - FileSystem::{ - CreateFileW, FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_RENAMED_NEW_NAME, - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, - FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_DIR_NAME, FILE_NOTIFY_CHANGE_FILE_NAME, - FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_CHANGE_SIZE, FILE_NOTIFY_INFORMATION, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FindClose, FindFirstFileW, - OPEN_EXISTING, ReadDirectoryChangesW, WIN32_FIND_DATAW, + Storage::{ + CloudFilters::{ + CF_CALLBACK_INFO, CF_CALLBACK_PARAMETERS, CF_CALLBACK_REGISTRATION, + CF_CALLBACK_TYPE_FETCH_DATA, CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, CF_CALLBACK_TYPE_NONE, + CF_CALLBACK_TYPE_NOTIFY_DELETE, CF_CALLBACK_TYPE_NOTIFY_RENAME, + CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO, + CF_CONNECTION_KEY, CF_CONVERT_FLAG_MARK_IN_SYNC, CF_FS_METADATA, + CF_IN_SYNC_STATE_IN_SYNC, CF_OPERATION_INFO, CF_OPERATION_PARAMETERS, + CF_OPERATION_PARAMETERS_0, + CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION, CF_OPERATION_TYPE, + CF_OPERATION_TYPE_ACK_DELETE, CF_OPERATION_TYPE_ACK_RENAME, + CF_OPERATION_TYPE_TRANSFER_DATA, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS, + CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, CF_PLACEHOLDER_CREATE_INFO, + CF_PLACEHOLDER_STATE_IN_SYNC, CF_PLACEHOLDER_STATE_PLACEHOLDER, CF_SET_IN_SYNC_FLAG_NONE, + CfConnectSyncRoot, CfConvertToPlaceholder, CfCreatePlaceholders, CfDisconnectSyncRoot, + CfExecute, CfGetPlaceholderStateFromAttributeTag, CfReportProviderProgress, + CfSetInSyncState, + }, + FileSystem::{ + CreateFileW, FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_RENAMED_NEW_NAME, + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_BASIC_INFO, + FILE_FLAG_BACKUP_SEMANTICS, FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_DIR_NAME, + FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_CHANGE_SIZE, + FILE_NOTIFY_INFORMATION, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FindClose, + FindFirstFileW, OPEN_EXISTING, ReadDirectoryChangesW, WIN32_FIND_DATAW, + }, }, }, - }, - core::{HSTRING, PCWSTR}, -}; - -use crate::{ - core_host, - state::{Changes, CoreStatus}, - win, -}; + core::{HSTRING, PCWSTR}, + }; -/// Provider prefix for sync root IDs (`NectarLink!`). -const PROVIDER_PREFIX: &str = "Nectarlink!"; + use crate::{ + core_host, + state::{Changes, CoreStatus}, + win, + }; -/// Difference between Windows FILETIME epoch (1601-01-01) and Unix epoch -/// (1970-01-01) in 100-nanosecond intervals. -const EPOCH_DIFF_100NS: i64 = 116_444_736_000_000_000; + /// Provider prefix for sync root IDs (`NectarLink!`). + const PROVIDER_PREFIX: &str = "Nectarlink!"; -/// Cloud file network unavailable NTSTATUS (`STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE`). -const STATUS_CLOUD_NETWORK_UNAVAILABLE: NTSTATUS = STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE; + /// Difference between Windows FILETIME epoch (1601-01-01) and Unix epoch + /// (1970-01-01) in 100-nanosecond intervals. + const EPOCH_DIFF_100NS: i64 = 116_444_736_000_000_000; -/// Cloud file unsuccessful / cancelled operation NTSTATUS (`STATUS_CLOUD_FILE_UNSUCCESSFUL`). -const STATUS_CLOUD_UNSUCCESSFUL: NTSTATUS = STATUS_CLOUD_FILE_UNSUCCESSFUL; + /// Cloud file network unavailable NTSTATUS (`STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE`). + const STATUS_CLOUD_NETWORK_UNAVAILABLE: NTSTATUS = STATUS_CLOUD_FILE_NETWORK_UNAVAILABLE; -/// Chunk size for hydrating files via `CF_OPERATION_TYPE_TRANSFER_DATA` (must be -/// a multiple of 4096 bytes for non-EOF chunks). -const HYDRATE_CHUNK_BYTES: usize = 256 * 1024; + /// Cloud file unsuccessful / cancelled operation NTSTATUS (`STATUS_CLOUD_FILE_UNSUCCESSFUL`). + const STATUS_CLOUD_UNSUCCESSFUL: NTSTATUS = STATUS_CLOUD_FILE_UNSUCCESSFUL; -fn lock(m: &Mutex) -> MutexGuard<'_, T> { - m.lock().unwrap_or_else(|e| e.into_inner()) -} + /// Chunk size for hydrating files via `CF_OPERATION_TYPE_TRANSFER_DATA` (must be + /// a multiple of 4096 bytes for non-EOF chunks). + const HYDRATE_CHUNK_BYTES: usize = 256 * 1024; -struct ActiveRoot { - device: DeviceId, - name: String, - root_path: PathBuf, - connection_key: CF_CONNECTION_KEY, - stop_watcher: Arc, - populated_dirs: Mutex>, - uploading: Mutex>, -} - -static ACTIVE_ROOTS: Mutex>>> = Mutex::new(None); -static RECONCILING: Mutex<()> = Mutex::new(()); + fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) + } -fn active_roots() -> MutexGuard<'static, Option>>> { - lock(&ACTIVE_ROOTS) -} + struct ActiveRoot { + device: DeviceId, + name: String, + root_path: PathBuf, + connection_key: CF_CONNECTION_KEY, + stop_watcher: Arc, + populated_dirs: Mutex>, + uploading: Mutex>, + } -fn get_active_root(device: &DeviceId) -> Option> { - active_roots().as_ref().and_then(|m| m.get(device).cloned()) -} + static ACTIVE_ROOTS: Mutex>>> = Mutex::new(None); + static RECONCILING: Mutex<()> = Mutex::new(()); -/// Starts watching paired devices and their `storage` toggles to register or -/// unregister File Explorer sync roots. -pub fn start() { - { - let mut map = active_roots(); - if map.is_none() { - *map = Some(HashMap::new()); - } + fn active_roots() -> MutexGuard<'static, Option>>> { + lock(&ACTIVE_ROOTS) } - core_host::host().hub.subscribe( - Changes::DEVICES | Changes::STATUS | Changes::CAPABILITIES | Changes::TOGGLES, - || { - sync(); - true - }, - ); - sync(); -} -/// Triggers a reconciliation of active sync roots (e.g. after a device toggle changes). -pub fn sync() { - let host = core_host::host(); - let Some(node) = core_host::node() else { return }; - let ready = host.hub.read(|s| matches!(s.status, Some(CoreStatus::Ready { .. }))); - if !ready { - return; + fn get_active_root(device: &DeviceId) -> Option> { + active_roots().as_ref().and_then(|m| m.get(device).cloned()) } - let devices = host.hub.read(|s| { - s.devices - .iter() - .map(|d| (d.id, d.info.name.clone(), matches!(d.link, LinkState::Online { .. }))) - .collect::>() - }); - let mut desired: Vec<(DeviceId, String, bool)> = Vec::new(); - let mut disabled: Vec<(DeviceId, String)> = Vec::new(); - for (id, name, online) in devices { - let enabled = node - .device_toggles(id) - .ok() - .and_then(|t| t.into_iter().find(|(k, _)| *k == "storage").map(|(_, on)| on)) - .unwrap_or(false); - if enabled { - desired.push((id, name, online)); - } else { - disabled.push((id, name)); + + /// Starts watching paired devices and their `storage` toggles to register or + /// unregister File Explorer sync roots. + pub fn start() { + { + let mut map = active_roots(); + if map.is_none() { + *map = Some(HashMap::new()); + } } + core_host::host().hub.subscribe( + Changes::DEVICES | Changes::STATUS | Changes::CAPABILITIES | Changes::TOGGLES, + || { + sync(); + true + }, + ); + sync(); } - let _ = std::thread::Builder::new().name("storage-sync".into()).spawn(move || { - let _guard = lock(&RECONCILING); - win::with_com(|| reconcile(&desired, &disabled)); - }); -} - -/// Handles core events (`StorageChanged`, `DeviceRemoved`, `LinkChanged`). -pub fn on_event(event: &NodeEvent) { - match event { - NodeEvent::DeviceRemoved(device) => { - let device = *device; - let _ = std::thread::Builder::new().name("storage-unpair".into()).spawn(move || { - let _guard = lock(&RECONCILING); - win::with_com(|| remove_device_root(device, None)); - }); + /// Triggers a reconciliation of active sync roots (e.g. after a device toggle changes). + pub fn sync() { + let host = core_host::host(); + let Some(node) = core_host::node() else { return }; + let ready = host.hub.read(|s| matches!(s.status, Some(CoreStatus::Ready { .. }))); + if !ready { + return; } - NodeEvent::DeviceAdded(_) - | NodeEvent::PeerInfoChanged { .. } - | NodeEvent::Capabilities(_) - | NodeEvent::LinkChanged { .. } => { - sync(); + let devices = host.hub.read(|s| { + s.devices + .iter() + .map(|d| (d.id, d.info.name.clone(), matches!(d.link, LinkState::Online { .. }))) + .collect::>() + }); + let mut desired: Vec<(DeviceId, String, bool)> = Vec::new(); + let mut disabled: Vec<(DeviceId, String)> = Vec::new(); + for (id, name, online) in devices { + let enabled = node + .device_toggles(id) + .ok() + .and_then(|t| t.into_iter().find(|(k, _)| *k == "storage").map(|(_, on)| on)) + .unwrap_or(false); + if enabled { + desired.push((id, name, online)); + } else { + disabled.push((id, name)); + } } - NodeEvent::StorageChanged { device, path } => { - let device = *device; - let path = path.clone(); - if let Some(root) = get_active_root(&device) { - core_host::spawn(async move { - refresh_folder(root, &path).await; + + let _ = std::thread::Builder::new().name("storage-sync".into()).spawn(move || { + let _guard = lock(&RECONCILING); + win::with_com(|| reconcile(&desired, &disabled)); + }); + } + + /// Handles core events (`StorageChanged`, `DeviceRemoved`, `LinkChanged`). + pub fn on_event(event: &NodeEvent) { + match event { + NodeEvent::DeviceRemoved(device) => { + let device = *device; + let _ = std::thread::Builder::new().name("storage-unpair".into()).spawn(move || { + let _guard = lock(&RECONCILING); + win::with_com(|| remove_device_root(device, None)); }); } + NodeEvent::DeviceAdded(_) + | NodeEvent::PeerInfoChanged { .. } + | NodeEvent::Capabilities(_) + | NodeEvent::LinkChanged { .. } => { + sync(); + } + NodeEvent::StorageChanged { device, path } => { + let device = *device; + let path = path.clone(); + if let Some(root) = get_active_root(&device) { + core_host::spawn(async move { + refresh_folder(root, &path).await; + }); + } + } + _ => {} } - _ => {} } -} -/// Disconnects active sync roots when the desktop app exits. -pub fn shutdown() { - let roots: Vec> = - active_roots().as_mut().map(|m| m.drain().map(|(_, v)| v).collect()).unwrap_or_default(); - for root in roots { - root.stop_watcher.store(true, Ordering::Relaxed); - // SAFETY: connection_key was obtained from CfConnectSyncRoot. - let _ = unsafe { CfDisconnectSyncRoot(root.connection_key) }; + /// Disconnects active sync roots when the desktop app exits. + pub fn shutdown() { + let roots: Vec> = + active_roots().as_mut().map(|m| m.drain().map(|(_, v)| v).collect()).unwrap_or_default(); + for root in roots { + root.stop_watcher.store(true, Ordering::Relaxed); + // SAFETY: connection_key was obtained from CfConnectSyncRoot. + let _ = unsafe { CfDisconnectSyncRoot(root.connection_key) }; + } } -} -/// Unregisters all `NectarLink!*` sync roots from Windows (`--uninstall`). -pub fn unregister_all() { - shutdown(); - win::with_com(|| { - if let Ok(roots) = StorageProviderSyncRootManager::GetCurrentSyncRoots() { - let count = roots.Size().unwrap_or(0); - for i in 0..count { - if let Ok(info) = roots.GetAt(i) - && let Ok(id) = info.Id() - { - let id_str = id.to_string_lossy(); - if id_str.starts_with(PROVIDER_PREFIX) { - let folder_path = info - .Path() - .ok() - .and_then(|f| f.Path().ok()) - .map(|p| PathBuf::from(p.to_string_lossy())); - let _ = StorageProviderSyncRootManager::Unregister(&id); - if let Some(path) = folder_path { - cleanup_sync_root_dir(&path); + /// Unregisters all `NectarLink!*` sync roots from Windows (`--uninstall`). + pub fn unregister_all() { + shutdown(); + win::with_com(|| { + if let Ok(roots) = StorageProviderSyncRootManager::GetCurrentSyncRoots() { + let count = roots.Size().unwrap_or(0); + for i in 0..count { + if let Ok(info) = roots.GetAt(i) + && let Ok(id) = info.Id() + { + let id_str = id.to_string_lossy(); + if id_str.starts_with(PROVIDER_PREFIX) { + let folder_path = info + .Path() + .ok() + .and_then(|f| f.Path().ok()) + .map(|p| PathBuf::from(p.to_string_lossy())); + let _ = StorageProviderSyncRootManager::Unregister(&id); + if let Some(path) = folder_path { + cleanup_sync_root_dir(&path); + } } } } } - } - if let Some(base) = base_sync_dir() { - let _ = std::fs::remove_dir(&base); - } - }); -} - -/// Returns the local sync root path for a paired phone (`%USERPROFILE%\Nectarlink\`). -pub fn sync_root_path_for(device: DeviceId) -> Option { - if let Some(active) = get_active_root(&device) { - return Some(active.root_path.clone()); + if let Some(base) = base_sync_dir() { + let _ = std::fs::remove_dir(&base); + } + }); } - let name = core_host::host().hub.read(|s| s.name_of(&device))?; - Some(base_sync_dir()?.join(sanitize_folder_name(&name))) -} -/// Opens a paired phone's sync root folder in File Explorer. -pub fn open_in_explorer(device: DeviceId) -> Result<(), String> { - let path = - sync_root_path_for(device).ok_or_else(|| "Phone storage folder is not available".to_owned())?; - if !path.exists() { - std::fs::create_dir_all(&path).map_err(|e| e.to_string())?; + /// Returns the local sync root path for a paired phone (`%USERPROFILE%\Nectarlink\`). + pub fn sync_root_path_for(device: DeviceId) -> Option { + if let Some(active) = get_active_root(&device) { + return Some(active.root_path.clone()); + } + let name = core_host::host().hub.read(|s| s.name_of(&device))?; + Some(base_sync_dir()?.join(sanitize_folder_name(&name))) } - win::shell::run(Path::new("explorer.exe"), &format!("\"{}\"", path.display())) -} -fn base_sync_dir() -> Option { - dirs::home_dir().map(|h| h.join("Nectarlink")) -} - -fn sync_root_id(device: DeviceId) -> HSTRING { - HSTRING::from(format!("{PROVIDER_PREFIX}{device}")) -} - -/// Sanitizes a phone's display name into a valid Windows directory name. -pub fn sanitize_folder_name(device_name: &str) -> String { - let cleaned: String = device_name - .chars() - .map(|c| if c.is_control() || r#"<>:"/\|?*"#.contains(c) { ' ' } else { c }) - .collect(); - let mut name = cleaned.split_whitespace().collect::>().join(" "); - name = name.trim_end_matches(['.', ' ']).chars().take(64).collect(); - let stem = name.split('.').next().unwrap_or_default().to_ascii_uppercase(); - let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") - || ((stem.starts_with("COM") || stem.starts_with("LPT")) - && stem.len() == 4 - && stem.as_bytes()[3].is_ascii_digit()); - if name.is_empty() { - "Phone".into() - } else if reserved { - format!("{name} phone") - } else { - name + /// Opens a paired phone's sync root folder in File Explorer. + pub fn open_in_explorer(device: DeviceId) -> Result<(), String> { + let path = + sync_root_path_for(device).ok_or_else(|| "Phone storage folder is not available".to_owned())?; + if !path.exists() { + std::fs::create_dir_all(&path).map_err(|e| e.to_string())?; + } + win::shell::run(Path::new("explorer.exe"), &format!("\"{}\"", path.display())) } -} -/// Converts a Unix timestamp in milliseconds to Windows FILETIME 100ns ticks -/// since 1601-01-01. -pub fn unix_ms_to_filetime_ticks(unix_ms: i64) -> i64 { - EPOCH_DIFF_100NS.saturating_add(unix_ms.max(0).saturating_mul(10_000)) -} + fn base_sync_dir() -> Option { + dirs::home_dir().map(|h| h.join("Nectarlink")) + } -/// Converts Windows FILETIME 100ns ticks since 1601-01-01 to Unix milliseconds. -#[cfg(test)] -pub fn filetime_ticks_to_unix_ms(ticks: i64) -> i64 { - ticks.saturating_sub(EPOCH_DIFF_100NS).max(0) / 10_000 -} + fn sync_root_id(device: DeviceId) -> HSTRING { + HSTRING::from(format!("{PROVIDER_PREFIX}{device}")) + } -/// Computes the slash-separated relative path inside `root_path` from a full -/// local path (`""` for the sync root itself). Returns `None` if `full_path` is -/// not inside `root_path`. -pub fn relative_path_under_root(root_path: &Path, full_path: &Path) -> Option { - let root_norm = normalize_win_path(root_path); - let full_norm = normalize_win_path(full_path); - let root_lower = root_norm.to_lowercase(); - let full_lower = full_norm.to_lowercase(); - if full_lower == root_lower { - return Some(String::new()); + /// Sanitizes a phone's display name into a valid Windows directory name. + pub fn sanitize_folder_name(device_name: &str) -> String { + let cleaned: String = device_name + .chars() + .map(|c| if c.is_control() || r#"<>:"/\|?*"#.contains(c) { ' ' } else { c }) + .collect(); + let mut name = cleaned.split_whitespace().collect::>().join(" "); + name = name.trim_end_matches(['.', ' ']).chars().take(64).collect(); + let stem = name.split('.').next().unwrap_or_default().to_ascii_uppercase(); + let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ((stem.starts_with("COM") || stem.starts_with("LPT")) + && stem.len() == 4 + && stem.as_bytes()[3].is_ascii_digit()); + if name.is_empty() { + "Phone".into() + } else if reserved { + format!("{name} phone") + } else { + name + } } - let prefix = format!("{root_lower}\\"); - if !full_lower.starts_with(&prefix) { - return None; + + /// Converts a Unix timestamp in milliseconds to Windows FILETIME 100ns ticks + /// since 1601-01-01. + pub fn unix_ms_to_filetime_ticks(unix_ms: i64) -> i64 { + EPOCH_DIFF_100NS.saturating_add(unix_ms.max(0).saturating_mul(10_000)) } - let rel = &full_norm[prefix.len()..]; - let parts: Vec<&str> = rel.split('\\').filter(|s| !s.is_empty()).collect(); - if parts.iter().any(|p| *p == "." || *p == "..") { - return None; + + /// Converts Windows FILETIME 100ns ticks since 1601-01-01 to Unix milliseconds. + #[cfg(test)] + pub fn filetime_ticks_to_unix_ms(ticks: i64) -> i64 { + ticks.saturating_sub(EPOCH_DIFF_100NS).max(0) / 10_000 } - Some(parts.join("/")) -} -fn normalize_win_path(path: &Path) -> String { - let s = path.to_string_lossy(); - let stripped = s.strip_prefix(r"\\?\").unwrap_or(&s); - stripped.trim_end_matches('\\').to_owned() -} + /// Computes the slash-separated relative path inside `root_path` from a full + /// local path (`""` for the sync root itself). Returns `None` if `full_path` is + /// not inside `root_path`. + pub fn relative_path_under_root(root_path: &Path, full_path: &Path) -> Option { + let root_norm = normalize_win_path(root_path); + let full_norm = normalize_win_path(full_path); + let root_lower = root_norm.to_lowercase(); + let full_lower = full_norm.to_lowercase(); + if full_lower == root_lower { + return Some(String::new()); + } + let prefix = format!("{root_lower}\\"); + if !full_lower.starts_with(&prefix) { + return None; + } + let rel = &full_norm[prefix.len()..]; + let parts: Vec<&str> = rel.split('\\').filter(|s| !s.is_empty()).collect(); + if parts.iter().any(|p| *p == "." || *p == "..") { + return None; + } + Some(parts.join("/")) + } -fn reconcile(desired: &[(DeviceId, String, bool)], disabled: &[(DeviceId, String)]) { - // Unregister any disabled devices that belong to this instance. - for (id, name) in disabled { - remove_device_root(*id, Some(name)); + fn normalize_win_path(path: &Path) -> String { + let s = path.to_string_lossy(); + let stripped = s.strip_prefix(r"\\?\").unwrap_or(&s); + stripped.trim_end_matches('\\').to_owned() } - // Remove any active roots no longer in `desired`. - let active_ids: Vec = - active_roots().as_ref().map(|m| m.keys().copied().collect()).unwrap_or_default(); - for id in active_ids { - if !desired.iter().any(|(d, _, _)| *d == id) { - remove_device_root(id, None); + fn reconcile(desired: &[(DeviceId, String, bool)], disabled: &[(DeviceId, String)]) { + // Unregister any disabled devices that belong to this instance. + for (id, name) in disabled { + remove_device_root(*id, Some(name)); } - } - // Register and connect desired roots. - for (id, name, online) in desired { - let already = get_active_root(id); - if let Some(existing) = already { - if existing.name == *name { - if *online { - let root = existing.clone(); - core_host::spawn(async move { - let is_root_populated = lock(&root.populated_dirs).contains(""); - if is_root_populated { - refresh_folder(root, "").await; - } - }); - } - continue; + // Remove any active roots no longer in `desired`. + let active_ids: Vec = + active_roots().as_ref().map(|m| m.keys().copied().collect()).unwrap_or_default(); + for id in active_ids { + if !desired.iter().any(|(d, _, _)| *d == id) { + remove_device_root(id, None); } - // Name changed: replace root. - remove_device_root(*id, Some(&existing.name)); } - if let Err(e) = register_and_connect(*id, name, *online) { - tracing::warn!(device = %id.short(), error = %e, "can't register phone storage sync root"); + // Register and connect desired roots. + for (id, name, online) in desired { + let already = get_active_root(id); + if let Some(existing) = already { + if existing.name == *name { + if *online { + let root = existing.clone(); + core_host::spawn(async move { + let is_root_populated = lock(&root.populated_dirs).contains(""); + if is_root_populated { + refresh_folder(root, "").await; + } + }); + } + continue; + } + // Name changed: replace root. + remove_device_root(*id, Some(&existing.name)); + } + + if let Err(e) = register_and_connect(*id, name, *online) { + tracing::warn!(device = %id.short(), error = %e, "can't register phone storage sync root"); + } } } -} -fn remove_device_root(device: DeviceId, known_name: Option<&str>) { - let removed = active_roots().as_mut().and_then(|m| m.remove(&device)); - let mut folder_to_clean: Option = None; - if let Some(root) = removed { - root.stop_watcher.store(true, Ordering::Relaxed); - // SAFETY: connection_key was obtained from CfConnectSyncRoot. - let _ = unsafe { CfDisconnectSyncRoot(root.connection_key) }; - folder_to_clean = Some(root.root_path.clone()); - } else if let Some(name) = known_name - && let Some(base) = base_sync_dir() - { - folder_to_clean = Some(base.join(sanitize_folder_name(name))); - } + fn remove_device_root(device: DeviceId, known_name: Option<&str>) { + let removed = active_roots().as_mut().and_then(|m| m.remove(&device)); + let mut folder_to_clean: Option = None; + if let Some(root) = removed { + root.stop_watcher.store(true, Ordering::Relaxed); + // SAFETY: connection_key was obtained from CfConnectSyncRoot. + let _ = unsafe { CfDisconnectSyncRoot(root.connection_key) }; + folder_to_clean = Some(root.root_path.clone()); + } else if let Some(name) = known_name + && let Some(base) = base_sync_dir() + { + folder_to_clean = Some(base.join(sanitize_folder_name(name))); + } - let id = sync_root_id(device); - let _ = StorageProviderSyncRootManager::Unregister(&id); + let id = sync_root_id(device); + let _ = StorageProviderSyncRootManager::Unregister(&id); - if let Some(path) = folder_to_clean { - cleanup_sync_root_dir(&path); - } - if let Some(base) = base_sync_dir() { - let _ = std::fs::remove_dir(&base); + if let Some(path) = folder_to_clean { + cleanup_sync_root_dir(&path); + } + if let Some(base) = base_sync_dir() { + let _ = std::fs::remove_dir(&base); + } } -} -fn cleanup_sync_root_dir(path: &Path) { - if !path.exists() { - return; + fn cleanup_sync_root_dir(path: &Path) { + if !path.exists() { + return; + } + let _ = std::fs::remove_dir_all(path); } - let _ = std::fs::remove_dir_all(path); -} -fn register_and_connect(device: DeviceId, name: &str, online: bool) -> Result<(), String> { - let base = base_sync_dir().ok_or_else(|| "no user home directory".to_owned())?; - let folder_name = sanitize_folder_name(name); - let root_path = base.join(&folder_name); - std::fs::create_dir_all(&root_path).map_err(|e| format!("create sync root dir: {e}"))?; - - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let icon_resource = format!("{},0", exe.display()); - - let info = StorageProviderSyncRootInfo::new().map_err(|e| format!("SyncRootInfo::new: {e}"))?; - info.SetId(&sync_root_id(device)).map_err(|e| format!("SetId: {e}"))?; - - let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(root_path.as_os_str())) - .map_err(|e| format!("GetFolderFromPathAsync: {e}"))? - .join() - .map_err(|e| format!("GetFolderFromPathAsync.join: {e}"))?; - info.SetPath(&folder).map_err(|e| format!("SetPath: {e}"))?; - info.SetDisplayNameResource(&HSTRING::from(name)).map_err(|e| format!("SetDisplayNameResource: {e}"))?; - info.SetIconResource(&HSTRING::from(&icon_resource)).map_err(|e| format!("SetIconResource: {e}"))?; - info.SetVersion(&HSTRING::from(env!("CARGO_PKG_VERSION"))).map_err(|e| format!("SetVersion: {e}"))?; - info.SetHydrationPolicy(StorageProviderHydrationPolicy::Full) - .map_err(|e| format!("SetHydrationPolicy: {e}"))?; - info.SetHydrationPolicyModifier(StorageProviderHydrationPolicyModifier::None) - .map_err(|e| format!("SetHydrationPolicyModifier: {e}"))?; - info.SetPopulationPolicy(StorageProviderPopulationPolicy::Full) - .map_err(|e| format!("SetPopulationPolicy: {e}"))?; - info.SetInSyncPolicy( - StorageProviderInSyncPolicy::FileCreationTime | StorageProviderInSyncPolicy::DirectoryCreationTime, - ) - .map_err(|e| format!("SetInSyncPolicy: {e}"))?; - info.SetHardlinkPolicy(StorageProviderHardlinkPolicy::None) - .map_err(|e| format!("SetHardlinkPolicy: {e}"))?; - info.SetShowSiblingsAsGroup(false).map_err(|e| format!("SetShowSiblingsAsGroup: {e}"))?; - - let context_buf = CryptographicBuffer::ConvertStringToBinary( - &HSTRING::from(device.to_string()), - BinaryStringEncoding::Utf8, - ) - .map_err(|e| format!("ConvertStringToBinary: {e}"))?; - info.SetContext(&context_buf).map_err(|e| format!("SetContext: {e}"))?; - - StorageProviderSyncRootManager::Register(&info).map_err(|e| format!("Register: {e}"))?; - - let callbacks = [ - CF_CALLBACK_REGISTRATION { - Type: CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, - Callback: Some(on_fetch_placeholders), - }, - CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_FETCH_DATA, Callback: Some(on_fetch_data) }, - CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_NOTIFY_RENAME, Callback: Some(on_notify_rename) }, - CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_NOTIFY_DELETE, Callback: Some(on_notify_delete) }, - CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_NONE, Callback: None }, - ]; - - // Keep the DeviceId in a heap allocation for the process lifetime so async - // cfapi callbacks always read a valid DeviceId and look up `ACTIVE_ROOTS`. - let ctx_ptr: *const c_void = Box::into_raw(Box::new(device)).cast(); - let wide_root = to_wide_null(&root_path); - // SAFETY: wide_root is null-terminated, callbacks ends with CF_CALLBACK_TYPE_NONE. - let connection_key = unsafe { - CfConnectSyncRoot( - PCWSTR(wide_root.as_ptr()), - callbacks.as_ptr(), - Some(ctx_ptr), - CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO | CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, + fn register_and_connect(device: DeviceId, name: &str, online: bool) -> Result<(), String> { + let base = base_sync_dir().ok_or_else(|| "no user home directory".to_owned())?; + let folder_name = sanitize_folder_name(name); + let root_path = base.join(&folder_name); + std::fs::create_dir_all(&root_path).map_err(|e| format!("create sync root dir: {e}"))?; + + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let icon_resource = format!("{},0", exe.display()); + + let info = StorageProviderSyncRootInfo::new().map_err(|e| format!("SyncRootInfo::new: {e}"))?; + info.SetId(&sync_root_id(device)).map_err(|e| format!("SetId: {e}"))?; + + let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(root_path.as_os_str())) + .map_err(|e| format!("GetFolderFromPathAsync: {e}"))? + .join() + .map_err(|e| format!("GetFolderFromPathAsync.join: {e}"))?; + info.SetPath(&folder).map_err(|e| format!("SetPath: {e}"))?; + info.SetDisplayNameResource(&HSTRING::from(name)) + .map_err(|e| format!("SetDisplayNameResource: {e}"))?; + info.SetIconResource(&HSTRING::from(&icon_resource)).map_err(|e| format!("SetIconResource: {e}"))?; + info.SetVersion(&HSTRING::from(env!("CARGO_PKG_VERSION"))).map_err(|e| format!("SetVersion: {e}"))?; + info.SetHydrationPolicy(StorageProviderHydrationPolicy::Full) + .map_err(|e| format!("SetHydrationPolicy: {e}"))?; + info.SetHydrationPolicyModifier(StorageProviderHydrationPolicyModifier::None) + .map_err(|e| format!("SetHydrationPolicyModifier: {e}"))?; + info.SetPopulationPolicy(StorageProviderPopulationPolicy::Full) + .map_err(|e| format!("SetPopulationPolicy: {e}"))?; + info.SetInSyncPolicy( + StorageProviderInSyncPolicy::FileCreationTime + | StorageProviderInSyncPolicy::DirectoryCreationTime, ) - } - .map_err(|e| format!("CfConnectSyncRoot: {e}"))?; - - let stop_watcher = Arc::new(AtomicBool::new(false)); - let active = Arc::new(ActiveRoot { - device, - name: name.to_owned(), - root_path: root_path.clone(), - connection_key, - stop_watcher: stop_watcher.clone(), - populated_dirs: Mutex::new(HashSet::new()), - uploading: Mutex::new(HashSet::new()), - }); - - if let Some(map) = active_roots().as_mut() { - map.insert(device, active.clone()); - } + .map_err(|e| format!("SetInSyncPolicy: {e}"))?; + info.SetHardlinkPolicy(StorageProviderHardlinkPolicy::None) + .map_err(|e| format!("SetHardlinkPolicy: {e}"))?; + info.SetShowSiblingsAsGroup(false).map_err(|e| format!("SetShowSiblingsAsGroup: {e}"))?; + + let context_buf = CryptographicBuffer::ConvertStringToBinary( + &HSTRING::from(device.to_string()), + BinaryStringEncoding::Utf8, + ) + .map_err(|e| format!("ConvertStringToBinary: {e}"))?; + info.SetContext(&context_buf).map_err(|e| format!("SetContext: {e}"))?; - start_directory_watcher(active.clone(), stop_watcher); + StorageProviderSyncRootManager::Register(&info).map_err(|e| format!("Register: {e}"))?; - if online { - core_host::spawn(async move { - // Pre-populate top-level placeholders when connected so opening the - // sync root in Explorer or inspecting it immediately shows folders. - refresh_folder(active, "").await; + let callbacks = [ + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, + Callback: Some(on_fetch_placeholders), + }, + CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_FETCH_DATA, Callback: Some(on_fetch_data) }, + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_NOTIFY_RENAME, + Callback: Some(on_notify_rename), + }, + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_NOTIFY_DELETE, + Callback: Some(on_notify_delete), + }, + CF_CALLBACK_REGISTRATION { Type: CF_CALLBACK_TYPE_NONE, Callback: None }, + ]; + + // Keep the DeviceId in a heap allocation for the process lifetime so async + // cfapi callbacks always read a valid DeviceId and look up `ACTIVE_ROOTS`. + let ctx_ptr: *const c_void = Box::into_raw(Box::new(device)).cast(); + let wide_root = to_wide_null(&root_path); + // SAFETY: wide_root is null-terminated, callbacks ends with CF_CALLBACK_TYPE_NONE. + let connection_key = unsafe { + CfConnectSyncRoot( + PCWSTR(wide_root.as_ptr()), + callbacks.as_ptr(), + Some(ctx_ptr), + CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO | CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, + ) + } + .map_err(|e| format!("CfConnectSyncRoot: {e}"))?; + + let stop_watcher = Arc::new(AtomicBool::new(false)); + let active = Arc::new(ActiveRoot { + device, + name: name.to_owned(), + root_path: root_path.clone(), + connection_key, + stop_watcher: stop_watcher.clone(), + populated_dirs: Mutex::new(HashSet::new()), + uploading: Mutex::new(HashSet::new()), }); - } - tracing::info!(device = %device.short(), "registered phone storage sync root"); - Ok(()) -} + if let Some(map) = active_roots().as_mut() { + map.insert(device, active.clone()); + } -fn to_wide_null(s: impl AsRef) -> Vec { - s.as_ref().encode_wide().chain(std::iter::once(0)).collect() -} + start_directory_watcher(active.clone(), stop_watcher); + + if online { + core_host::spawn(async move { + // Pre-populate top-level placeholders when connected so opening the + // sync root in Explorer or inspecting it immediately shows folders. + refresh_folder(active, "").await; + }); + } -unsafe fn pcwstr_to_string(ptr: PCWSTR) -> String { - if ptr.is_null() { - return String::new(); + tracing::info!(device = %device.short(), "registered phone storage sync root"); + Ok(()) } - // SAFETY: caller guarantees `ptr` is a valid null-terminated wide string. - let slice = unsafe { ptr.as_wide() }; - std::ffi::OsString::from_wide(slice).to_string_lossy().into_owned() -} -unsafe fn callback_root_and_path( - info: *const CF_CALLBACK_INFO, -) -> Option<(Arc, String, CF_CONNECTION_KEY, i64, i64)> { - // SAFETY: Windows passes a valid CF_CALLBACK_INFO pointer to registered callbacks. - let cb = unsafe { info.as_ref()? }; - if cb.CallbackContext.is_null() { - return None; + fn to_wide_null(s: impl AsRef) -> Vec { + s.as_ref().encode_wide().chain(std::iter::once(0)).collect() } - // SAFETY: CallbackContext points to a leaked `Box`. - let device = unsafe { *(cb.CallbackContext as *const DeviceId) }; - let root = get_active_root(&device)?; - // SAFETY: VolumeDosName and NormalizedPath are valid PCWSTRs in CF_CALLBACK_INFO. - let vol = unsafe { pcwstr_to_string(cb.VolumeDosName) }; - // SAFETY: NormalizedPath is valid when CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH is set. - let norm = unsafe { pcwstr_to_string(cb.NormalizedPath) }; - let full = PathBuf::from(format!("{vol}{norm}")); - let rel = relative_path_under_root(&root.root_path, &full)?; - Some((root, rel, cb.ConnectionKey, cb.TransferKey, cb.RequestKey)) -} -fn make_op_info( - connection_key: CF_CONNECTION_KEY, - transfer_key: i64, - request_key: i64, - op_type: CF_OPERATION_TYPE, -) -> CF_OPERATION_INFO { - CF_OPERATION_INFO { - StructSize: std::mem::size_of::() as u32, - Type: op_type, - ConnectionKey: connection_key, - TransferKey: transfer_key, - CorrelationVector: std::ptr::null(), - SyncStatus: std::ptr::null(), - RequestKey: request_key, + unsafe fn pcwstr_to_string(ptr: PCWSTR) -> String { + if ptr.is_null() { + return String::new(); + } + // SAFETY: caller guarantees `ptr` is a valid null-terminated wide string. + let slice = unsafe { ptr.as_wide() }; + std::ffi::OsString::from_wide(slice).to_string_lossy().into_owned() } -} -unsafe extern "system" fn on_fetch_placeholders( - callback_info: *const CF_CALLBACK_INFO, - _callback_parameters: *const CF_CALLBACK_PARAMETERS, -) { - // SAFETY: callback_info is provided by Windows cfapi. - let Some((root, rel_path, connection_key, transfer_key, request_key)) = - (unsafe { callback_root_and_path(callback_info) }) - else { - return; - }; + unsafe fn callback_root_and_path( + info: *const CF_CALLBACK_INFO, + ) -> Option<(Arc, String, CF_CONNECTION_KEY, i64, i64)> { + // SAFETY: Windows passes a valid CF_CALLBACK_INFO pointer to registered callbacks. + let cb = unsafe { info.as_ref()? }; + if cb.CallbackContext.is_null() { + return None; + } + // SAFETY: CallbackContext points to a leaked `Box`. + let device = unsafe { *(cb.CallbackContext as *const DeviceId) }; + let root = get_active_root(&device)?; + // SAFETY: VolumeDosName and NormalizedPath are valid PCWSTRs in CF_CALLBACK_INFO. + let vol = unsafe { pcwstr_to_string(cb.VolumeDosName) }; + // SAFETY: NormalizedPath is valid when CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH is set. + let norm = unsafe { pcwstr_to_string(cb.NormalizedPath) }; + let full = PathBuf::from(format!("{vol}{norm}")); + let rel = relative_path_under_root(&root.root_path, &full)?; + Some((root, rel, cb.ConnectionKey, cb.TransferKey, cb.RequestKey)) + } + + fn make_op_info( + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + request_key: i64, + op_type: CF_OPERATION_TYPE, + ) -> CF_OPERATION_INFO { + CF_OPERATION_INFO { + StructSize: std::mem::size_of::() as u32, + Type: op_type, + ConnectionKey: connection_key, + TransferKey: transfer_key, + CorrelationVector: std::ptr::null(), + SyncStatus: std::ptr::null(), + RequestKey: request_key, + } + } - core_host::spawn(async move { - let Some(node) = core_host::node() else { - ack_placeholders_failed(connection_key, transfer_key, request_key); + unsafe extern "system" fn on_fetch_placeholders( + callback_info: *const CF_CALLBACK_INFO, + _callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + // SAFETY: callback_info is provided by Windows cfapi. + let Some((root, rel_path, connection_key, transfer_key, request_key)) = + (unsafe { callback_root_and_path(callback_info) }) + else { return; }; - match node.storage_list(root.device, &rel_path).await { - Ok(entries) => { - lock(&root.populated_dirs).insert(rel_path.clone()); - transfer_placeholders(connection_key, transfer_key, request_key, &rel_path, &entries); - } - Err(e) => { - tracing::debug!( - device = %root.device.short(), - error = %e, - "fetch placeholders failed" - ); + + core_host::spawn(async move { + let Some(node) = core_host::node() else { ack_placeholders_failed(connection_key, transfer_key, request_key); + return; + }; + match node.storage_list(root.device, &rel_path).await { + Ok(entries) => { + lock(&root.populated_dirs).insert(rel_path.clone()); + transfer_placeholders(connection_key, transfer_key, request_key, &rel_path, &entries); + } + Err(e) => { + tracing::debug!( + device = %root.device.short(), + error = %e, + "fetch placeholders failed" + ); + ack_placeholders_failed(connection_key, transfer_key, request_key); + } } - } - }); -} - -fn transfer_placeholders( - connection_key: CF_CONNECTION_KEY, - transfer_key: i64, - request_key: i64, - parent_rel: &str, - entries: &[StorageEntry], -) { - let mut wide_names: Vec> = Vec::with_capacity(entries.len()); - let mut identities: Vec> = Vec::with_capacity(entries.len()); - for entry in entries { - wide_names.push(to_wide_null(&entry.name)); - let rel = - if parent_rel.is_empty() { entry.name.clone() } else { format!("{parent_rel}/{}", entry.name) }; - identities.push(rel.into_bytes()); + }); } - let mut create_infos: Vec = entries - .iter() - .enumerate() - .map(|(i, entry)| build_placeholder_create_info(entry, &wide_names[i], &identities[i])) - .collect(); - - let op_info = - make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - TransferPlaceholders: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_4 { - Flags: CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION, - CompletionStatus: STATUS_SUCCESS, - PlaceholderTotalCount: create_infos.len() as i64, - PlaceholderArray: if create_infos.is_empty() { - std::ptr::null_mut() - } else { - create_infos.as_mut_ptr() + fn transfer_placeholders( + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + request_key: i64, + parent_rel: &str, + entries: &[StorageEntry], + ) { + let mut wide_names: Vec> = Vec::with_capacity(entries.len()); + let mut identities: Vec> = Vec::with_capacity(entries.len()); + for entry in entries { + wide_names.push(to_wide_null(&entry.name)); + let rel = if parent_rel.is_empty() { + entry.name.clone() + } else { + format!("{parent_rel}/{}", entry.name) + }; + identities.push(rel.into_bytes()); + } + + let mut create_infos: Vec = entries + .iter() + .enumerate() + .map(|(i, entry)| build_placeholder_create_info(entry, &wide_names[i], &identities[i])) + .collect(); + + let op_info = + make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + TransferPlaceholders: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_4 { + Flags: CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION, + CompletionStatus: STATUS_SUCCESS, + PlaceholderTotalCount: create_infos.len() as i64, + PlaceholderArray: if create_infos.is_empty() { + std::ptr::null_mut() + } else { + create_infos.as_mut_ptr() + }, + PlaceholderCount: create_infos.len() as u32, + EntriesProcessed: 0, }, - PlaceholderCount: create_infos.len() as u32, - EntriesProcessed: 0, }, - }, - }; - // SAFETY: op_info and op_params point to valid stack structures and buffers. - let _ = unsafe { CfExecute(&op_info, &mut op_params) }; -} + }; + // SAFETY: op_info and op_params point to valid stack structures and buffers. + let _ = unsafe { CfExecute(&op_info, &mut op_params) }; + } -fn ack_placeholders_failed(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64) { - let op_info = - make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - TransferPlaceholders: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_4 { - Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAGS(0), - CompletionStatus: STATUS_CLOUD_NETWORK_UNAVAILABLE, - PlaceholderTotalCount: 0, - PlaceholderArray: std::ptr::null_mut(), - PlaceholderCount: 0, - EntriesProcessed: 0, + fn ack_placeholders_failed(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64) { + let op_info = + make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + TransferPlaceholders: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_4 { + Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAGS(0), + CompletionStatus: STATUS_CLOUD_NETWORK_UNAVAILABLE, + PlaceholderTotalCount: 0, + PlaceholderArray: std::ptr::null_mut(), + PlaceholderCount: 0, + EntriesProcessed: 0, + }, }, - }, - }; - // SAFETY: op_info and op_params are valid. - let _ = unsafe { CfExecute(&op_info, &mut op_params) }; -} + }; + // SAFETY: op_info and op_params are valid. + let _ = unsafe { CfExecute(&op_info, &mut op_params) }; + } -fn build_placeholder_create_info( - entry: &StorageEntry, - wide_name: &[u16], - identity: &[u8], -) -> CF_PLACEHOLDER_CREATE_INFO { - let ticks = unix_ms_to_filetime_ticks(entry.modified); - let attrs = if entry.is_dir { FILE_ATTRIBUTE_DIRECTORY.0 } else { FILE_ATTRIBUTE_NORMAL.0 }; - let file_size = if entry.is_dir { 0 } else { entry.size as i64 }; - CF_PLACEHOLDER_CREATE_INFO { - RelativeFileName: PCWSTR(wide_name.as_ptr()), - FsMetadata: CF_FS_METADATA { - BasicInfo: FILE_BASIC_INFO { - CreationTime: ticks, - LastAccessTime: ticks, - LastWriteTime: ticks, - ChangeTime: ticks, - FileAttributes: attrs, + fn build_placeholder_create_info( + entry: &StorageEntry, + wide_name: &[u16], + identity: &[u8], + ) -> CF_PLACEHOLDER_CREATE_INFO { + let ticks = unix_ms_to_filetime_ticks(entry.modified); + let attrs = if entry.is_dir { FILE_ATTRIBUTE_DIRECTORY.0 } else { FILE_ATTRIBUTE_NORMAL.0 }; + let file_size = if entry.is_dir { 0 } else { entry.size as i64 }; + CF_PLACEHOLDER_CREATE_INFO { + RelativeFileName: PCWSTR(wide_name.as_ptr()), + FsMetadata: CF_FS_METADATA { + BasicInfo: FILE_BASIC_INFO { + CreationTime: ticks, + LastAccessTime: ticks, + LastWriteTime: ticks, + ChangeTime: ticks, + FileAttributes: attrs, + }, + FileSize: file_size, }, - FileSize: file_size, - }, - FileIdentity: identity.as_ptr().cast(), - FileIdentityLength: identity.len() as u32, - Flags: CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, - Result: windows::core::HRESULT(0), - CreateUsn: 0, + FileIdentity: identity.as_ptr().cast(), + FileIdentityLength: identity.len() as u32, + Flags: CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, + Result: windows::core::HRESULT(0), + CreateUsn: 0, + } } -} -unsafe extern "system" fn on_fetch_data( - callback_info: *const CF_CALLBACK_INFO, - callback_parameters: *const CF_CALLBACK_PARAMETERS, -) { - // SAFETY: callback_info and callback_parameters are provided by Windows cfapi. - let Some((root, rel_path, connection_key, transfer_key, request_key)) = - (unsafe { callback_root_and_path(callback_info) }) - else { - return; - }; - let Some(params) = (unsafe { callback_parameters.as_ref() }) else { - return; - }; - // SAFETY: Union variant matches CF_CALLBACK_TYPE_FETCH_DATA. - let fetch = unsafe { params.Anonymous.FetchData }; - let required_offset = fetch.RequiredFileOffset.max(0) as u64; - let required_length = fetch.RequiredLength.max(0) as u64; - - core_host::spawn(async move { - let Some(node) = core_host::node() else { - ack_data_failed( - connection_key, - transfer_key, - request_key, - required_offset as i64, - STATUS_CLOUD_NETWORK_UNAVAILABLE, - ); + unsafe extern "system" fn on_fetch_data( + callback_info: *const CF_CALLBACK_INFO, + callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + // SAFETY: callback_info and callback_parameters are provided by Windows cfapi. + let Some((root, rel_path, connection_key, transfer_key, request_key)) = + (unsafe { callback_root_and_path(callback_info) }) + else { return; }; + let Some(params) = (unsafe { callback_parameters.as_ref() }) else { + return; + }; + // SAFETY: Union variant matches CF_CALLBACK_TYPE_FETCH_DATA. + let fetch = unsafe { params.Anonymous.FetchData }; + let required_offset = fetch.RequiredFileOffset.max(0) as u64; + let required_length = fetch.RequiredLength.max(0) as u64; - let (meta, mut recv) = match node - .storage_read_stream(root.device, &rel_path, required_offset, Some(required_length)) - .await - { - Ok(res) => res, - Err(e) => { - tracing::debug!( - device = %root.device.short(), - error = %e, - "fetch data stream failed" - ); + core_host::spawn(async move { + let Some(node) = core_host::node() else { ack_data_failed( connection_key, transfer_key, @@ -727,505 +722,588 @@ unsafe extern "system" fn on_fetch_data( STATUS_CLOUD_NETWORK_UNAVAILABLE, ); return; - } - }; - let total_size = meta.size; - - if required_length == 0 { - let _ = transfer_data_chunk( - connection_key, - transfer_key, - request_key, - &[], - required_offset as i64, - STATUS_SUCCESS, - ); - return; - } + }; - let mut current_offset = required_offset; - let mut remaining = required_length; - let mut buf = Vec::with_capacity(HYDRATE_CHUNK_BYTES); - - while remaining > 0 { - let want = (remaining as usize).min(HYDRATE_CHUNK_BYTES); - buf.clear(); - while buf.len() < want { - match recv.read_chunk(want - buf.len()).await { - Ok(Some(chunk)) => buf.extend_from_slice(&chunk), - Ok(None) => break, - Err(e) => { - tracing::debug!(error = %e, "hydration stream read error"); - ack_data_failed( - connection_key, - transfer_key, - request_key, - current_offset as i64, - STATUS_CLOUD_NETWORK_UNAVAILABLE, - ); - return; - } + let (meta, mut recv) = match node + .storage_read_stream(root.device, &rel_path, required_offset, Some(required_length)) + .await + { + Ok(res) => res, + Err(e) => { + tracing::debug!( + device = %root.device.short(), + error = %e, + "fetch data stream failed" + ); + ack_data_failed( + connection_key, + transfer_key, + request_key, + required_offset as i64, + STATUS_CLOUD_NETWORK_UNAVAILABLE, + ); + return; } - } - if buf.is_empty() { - break; - } - let len = buf.len() as u64; - let completed = (current_offset + len).min(total_size); - // SAFETY: valid connection_key and transfer_key during active fetch. - let _ = unsafe { - CfReportProviderProgress(connection_key, transfer_key, total_size as i64, completed as i64) }; - if !transfer_data_chunk( - connection_key, - transfer_key, - request_key, - &buf, - current_offset as i64, - STATUS_SUCCESS, - ) { + let total_size = meta.size; + + if required_length == 0 { + let _ = transfer_data_chunk( + connection_key, + transfer_key, + request_key, + &[], + required_offset as i64, + STATUS_SUCCESS, + ); return; } - current_offset += len; - remaining = remaining.saturating_sub(len); - } - }); -} -fn transfer_data_chunk( - connection_key: CF_CONNECTION_KEY, - transfer_key: i64, - request_key: i64, - data: &[u8], - offset: i64, - status: NTSTATUS, -) -> bool { - let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_DATA); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - TransferData: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_0 { - Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_TRANSFER_DATA_FLAGS(0), - CompletionStatus: status, - Buffer: if data.is_empty() { std::ptr::null() } else { data.as_ptr().cast() }, - Offset: offset, - Length: data.len() as i64, + let mut current_offset = required_offset; + let mut remaining = required_length; + let mut buf = Vec::with_capacity(HYDRATE_CHUNK_BYTES); + + while remaining > 0 { + let want = (remaining as usize).min(HYDRATE_CHUNK_BYTES); + buf.clear(); + while buf.len() < want { + match recv.read_chunk(want - buf.len()).await { + Ok(Some(chunk)) => buf.extend_from_slice(&chunk), + Ok(None) => break, + Err(e) => { + tracing::debug!(error = %e, "hydration stream read error"); + ack_data_failed( + connection_key, + transfer_key, + request_key, + current_offset as i64, + STATUS_CLOUD_NETWORK_UNAVAILABLE, + ); + return; + } + } + } + if buf.is_empty() { + break; + } + let len = buf.len() as u64; + let completed = (current_offset + len).min(total_size); + // SAFETY: valid connection_key and transfer_key during active fetch. + let _ = unsafe { + CfReportProviderProgress( + connection_key, + transfer_key, + total_size as i64, + completed as i64, + ) + }; + if !transfer_data_chunk( + connection_key, + transfer_key, + request_key, + &buf, + current_offset as i64, + STATUS_SUCCESS, + ) { + return; + } + current_offset += len; + remaining = remaining.saturating_sub(len); + } + }); + } + + fn transfer_data_chunk( + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + request_key: i64, + data: &[u8], + offset: i64, + status: NTSTATUS, + ) -> bool { + let op_info = + make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_TRANSFER_DATA); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + TransferData: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_0 { + Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_TRANSFER_DATA_FLAGS(0), + CompletionStatus: status, + Buffer: if data.is_empty() { std::ptr::null() } else { data.as_ptr().cast() }, + Offset: offset, + Length: data.len() as i64, + }, }, - }, - }; - // SAFETY: op_info and op_params reference valid memory for the duration of CfExecute. - unsafe { CfExecute(&op_info, &mut op_params) }.is_ok() -} + }; + // SAFETY: op_info and op_params reference valid memory for the duration of CfExecute. + unsafe { CfExecute(&op_info, &mut op_params) }.is_ok() + } -fn ack_data_failed( - connection_key: CF_CONNECTION_KEY, - transfer_key: i64, - request_key: i64, - offset: i64, - status: NTSTATUS, -) { - let _ = transfer_data_chunk(connection_key, transfer_key, request_key, &[], offset, status); -} + fn ack_data_failed( + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + request_key: i64, + offset: i64, + status: NTSTATUS, + ) { + let _ = transfer_data_chunk(connection_key, transfer_key, request_key, &[], offset, status); + } -unsafe extern "system" fn on_notify_rename( - callback_info: *const CF_CALLBACK_INFO, - callback_parameters: *const CF_CALLBACK_PARAMETERS, -) { - // SAFETY: callback_info and callback_parameters are provided by Windows cfapi. - let Some((root, old_rel, connection_key, transfer_key, request_key)) = - (unsafe { callback_root_and_path(callback_info) }) - else { - return; - }; - let Some(cb) = (unsafe { callback_info.as_ref() }) else { return }; - let Some(params) = (unsafe { callback_parameters.as_ref() }) else { return }; - // SAFETY: Union variant matches CF_CALLBACK_TYPE_NOTIFY_RENAME. - let rename = unsafe { params.Anonymous.Rename }; - // SAFETY: VolumeDosName and TargetPath are valid PCWSTRs. - let vol = unsafe { pcwstr_to_string(cb.VolumeDosName) }; - let target_norm = unsafe { pcwstr_to_string(rename.TargetPath) }; - let target_full = PathBuf::from(format!("{vol}{target_norm}")); - let new_rel = relative_path_under_root(&root.root_path, &target_full); - - core_host::spawn(async move { - let Some(node) = core_host::node() else { - ack_rename(connection_key, transfer_key, request_key, STATUS_CLOUD_NETWORK_UNAVAILABLE); + unsafe extern "system" fn on_notify_rename( + callback_info: *const CF_CALLBACK_INFO, + callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + // SAFETY: callback_info and callback_parameters are provided by Windows cfapi. + let Some((root, old_rel, connection_key, transfer_key, request_key)) = + (unsafe { callback_root_and_path(callback_info) }) + else { return; }; - let status = match new_rel { - Some(new_rel) if !old_rel.is_empty() && !new_rel.is_empty() => { - match node.storage_rename(root.device, &old_rel, &new_rel).await { - Ok(()) => STATUS_SUCCESS, - Err(nectarlink_core::Error::Offline) => STATUS_CLOUD_NETWORK_UNAVAILABLE, - Err(nectarlink_core::Error::Denied) => STATUS_ACCESS_DENIED, - Err(_) => STATUS_CLOUD_UNSUCCESSFUL, + let Some(cb) = (unsafe { callback_info.as_ref() }) else { return }; + let Some(params) = (unsafe { callback_parameters.as_ref() }) else { return }; + // SAFETY: Union variant matches CF_CALLBACK_TYPE_NOTIFY_RENAME. + let rename = unsafe { params.Anonymous.Rename }; + // SAFETY: VolumeDosName and TargetPath are valid PCWSTRs. + let vol = unsafe { pcwstr_to_string(cb.VolumeDosName) }; + let target_norm = unsafe { pcwstr_to_string(rename.TargetPath) }; + let target_full = PathBuf::from(format!("{vol}{target_norm}")); + let new_rel = relative_path_under_root(&root.root_path, &target_full); + + core_host::spawn(async move { + let Some(node) = core_host::node() else { + ack_rename(connection_key, transfer_key, request_key, STATUS_CLOUD_NETWORK_UNAVAILABLE); + return; + }; + let status = match new_rel { + Some(new_rel) if !old_rel.is_empty() && !new_rel.is_empty() => { + match node.storage_rename(root.device, &old_rel, &new_rel).await { + Ok(()) => STATUS_SUCCESS, + Err(nectarlink_core::Error::Offline) => STATUS_CLOUD_NETWORK_UNAVAILABLE, + Err(nectarlink_core::Error::Denied) => STATUS_ACCESS_DENIED, + Err(_) => STATUS_CLOUD_UNSUCCESSFUL, + } } - } - _ => STATUS_ACCESS_DENIED, - }; - ack_rename(connection_key, transfer_key, request_key, status); - }); -} + _ => STATUS_ACCESS_DENIED, + }; + ack_rename(connection_key, transfer_key, request_key, status); + }); + } -fn ack_rename(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64, status: NTSTATUS) { - let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_ACK_RENAME); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - AckRename: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_6 { - Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_ACK_RENAME_FLAGS(0), - CompletionStatus: status, + fn ack_rename(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64, status: NTSTATUS) { + let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_ACK_RENAME); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + AckRename: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_6 { + Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_ACK_RENAME_FLAGS(0), + CompletionStatus: status, + }, }, - }, - }; - // SAFETY: op_info and op_params are valid. - let _ = unsafe { CfExecute(&op_info, &mut op_params) }; -} - -unsafe extern "system" fn on_notify_delete( - callback_info: *const CF_CALLBACK_INFO, - _callback_parameters: *const CF_CALLBACK_PARAMETERS, -) { - // SAFETY: callback_info is provided by Windows cfapi. - let Some((root, rel_path, connection_key, transfer_key, request_key)) = - (unsafe { callback_root_and_path(callback_info) }) - else { - return; - }; + }; + // SAFETY: op_info and op_params are valid. + let _ = unsafe { CfExecute(&op_info, &mut op_params) }; + } - core_host::spawn(async move { - if rel_path.is_empty() { - ack_delete(connection_key, transfer_key, request_key, STATUS_ACCESS_DENIED); + unsafe extern "system" fn on_notify_delete( + callback_info: *const CF_CALLBACK_INFO, + _callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + // SAFETY: callback_info is provided by Windows cfapi. + let Some((root, rel_path, connection_key, transfer_key, request_key)) = + (unsafe { callback_root_and_path(callback_info) }) + else { return; - } - let Some(node) = core_host::node() else { - ack_delete(connection_key, transfer_key, request_key, STATUS_CLOUD_NETWORK_UNAVAILABLE); - return; - }; - let status = match node.storage_delete(root.device, &rel_path, true).await { - Ok(()) => STATUS_SUCCESS, - Err(nectarlink_core::Error::Offline) => STATUS_CLOUD_NETWORK_UNAVAILABLE, - Err(nectarlink_core::Error::Denied) => STATUS_ACCESS_DENIED, - Err(nectarlink_core::Error::Storage(msg)) if msg.contains("not_found") => STATUS_SUCCESS, - Err(_) => STATUS_CLOUD_UNSUCCESSFUL, }; - ack_delete(connection_key, transfer_key, request_key, status); - }); -} - -fn ack_delete(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64, status: NTSTATUS) { - let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_ACK_DELETE); - let mut op_params = CF_OPERATION_PARAMETERS { - ParamSize: std::mem::size_of::() as u32, - Anonymous: CF_OPERATION_PARAMETERS_0 { - AckDelete: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_7 { - Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_ACK_DELETE_FLAGS(0), - CompletionStatus: status, - }, - }, - }; - // SAFETY: op_info and op_params are valid. - let _ = unsafe { CfExecute(&op_info, &mut op_params) }; -} -/// Refreshes a folder's placeholders from the phone (`storage.changed` or initial connect). -async fn refresh_folder(root: Arc, rel_folder: &str) { - let Some(node) = core_host::node() else { return }; - let Ok(entries) = node.storage_list(root.device, rel_folder).await else { return }; + core_host::spawn(async move { + if rel_path.is_empty() { + ack_delete(connection_key, transfer_key, request_key, STATUS_ACCESS_DENIED); + return; + } + let Some(node) = core_host::node() else { + ack_delete(connection_key, transfer_key, request_key, STATUS_CLOUD_NETWORK_UNAVAILABLE); + return; + }; + let status = match node.storage_delete(root.device, &rel_path, true).await { + Ok(()) => STATUS_SUCCESS, + Err(nectarlink_core::Error::Offline) => STATUS_CLOUD_NETWORK_UNAVAILABLE, + Err(nectarlink_core::Error::Denied) => STATUS_ACCESS_DENIED, + Err(nectarlink_core::Error::Storage(msg)) if msg.contains("not_found") => STATUS_SUCCESS, + Err(_) => STATUS_CLOUD_UNSUCCESSFUL, + }; + ack_delete(connection_key, transfer_key, request_key, status); + }); + } - let local_dir = if rel_folder.is_empty() { - root.root_path.clone() - } else { - root.root_path.join(rel_folder.replace('/', "\\")) - }; - if !local_dir.exists() { - return; + fn ack_delete(connection_key: CF_CONNECTION_KEY, transfer_key: i64, request_key: i64, status: NTSTATUS) { + let op_info = make_op_info(connection_key, transfer_key, request_key, CF_OPERATION_TYPE_ACK_DELETE); + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + AckDelete: windows::Win32::Storage::CloudFilters::CF_OPERATION_PARAMETERS_0_7 { + Flags: windows::Win32::Storage::CloudFilters::CF_OPERATION_ACK_DELETE_FLAGS(0), + CompletionStatus: status, + }, + }, + }; + // SAFETY: op_info and op_params are valid. + let _ = unsafe { CfExecute(&op_info, &mut op_params) }; } - lock(&root.populated_dirs).insert(rel_folder.to_owned()); + /// Refreshes a folder's placeholders from the phone (`storage.changed` or initial connect). + async fn refresh_folder(root: Arc, rel_folder: &str) { + let Some(node) = core_host::node() else { return }; + let Ok(entries) = node.storage_list(root.device, rel_folder).await else { return }; - let mut missing: Vec = Vec::new(); - for entry in entries { - let child_path = local_dir.join(&entry.name); - if !child_path.exists() { - missing.push(entry); + let local_dir = if rel_folder.is_empty() { + root.root_path.clone() + } else { + root.root_path.join(rel_folder.replace('/', "\\")) + }; + if !local_dir.exists() { + return; } - } - if missing.is_empty() { - return; - } - let mut wide_names: Vec> = Vec::with_capacity(missing.len()); - let mut identities: Vec> = Vec::with_capacity(missing.len()); - for entry in &missing { - wide_names.push(to_wide_null(&entry.name)); - let rel = - if rel_folder.is_empty() { entry.name.clone() } else { format!("{rel_folder}/{}", entry.name) }; - identities.push(rel.into_bytes()); - } - let mut create_infos: Vec = missing - .iter() - .enumerate() - .map(|(i, entry)| build_placeholder_create_info(entry, &wide_names[i], &identities[i])) - .collect(); - - let wide_dir = to_wide_null(&local_dir); - let mut processed = 0u32; - // SAFETY: wide_dir is null-terminated and create_infos stays alive for the call. - let _ = unsafe { - CfCreatePlaceholders( - PCWSTR(wide_dir.as_ptr()), - &mut create_infos, - windows::Win32::Storage::CloudFilters::CF_CREATE_FLAG_NONE, - Some(&mut processed), - ) - }; -} + lock(&root.populated_dirs).insert(rel_folder.to_owned()); -/// Watches the sync root directory for files or folders dropped in by the user -/// and uploads them to the phone via `storage.write` / `storage.mkdir`. -fn start_directory_watcher(root: Arc, stop: Arc) { - let _ = std::thread::Builder::new().name("storage-watch".into()).spawn(move || { - let wide_dir = to_wide_null(&root.root_path); - // SAFETY: wide_dir is a valid null-terminated path. - let dir_handle = unsafe { - CreateFileW( + let mut missing: Vec = Vec::new(); + for entry in entries { + let child_path = local_dir.join(&entry.name); + if !child_path.exists() { + missing.push(entry); + } + } + if missing.is_empty() { + return; + } + + let mut wide_names: Vec> = Vec::with_capacity(missing.len()); + let mut identities: Vec> = Vec::with_capacity(missing.len()); + for entry in &missing { + wide_names.push(to_wide_null(&entry.name)); + let rel = if rel_folder.is_empty() { + entry.name.clone() + } else { + format!("{rel_folder}/{}", entry.name) + }; + identities.push(rel.into_bytes()); + } + let mut create_infos: Vec = missing + .iter() + .enumerate() + .map(|(i, entry)| build_placeholder_create_info(entry, &wide_names[i], &identities[i])) + .collect(); + + let wide_dir = to_wide_null(&local_dir); + let mut processed = 0u32; + // SAFETY: wide_dir is null-terminated and create_infos stays alive for the call. + let _ = unsafe { + CfCreatePlaceholders( PCWSTR(wide_dir.as_ptr()), - FILE_LIST_DIRECTORY.0, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - None, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, - None, + &mut create_infos, + windows::Win32::Storage::CloudFilters::CF_CREATE_FLAG_NONE, + Some(&mut processed), ) }; - let Ok(dir_handle) = dir_handle else { return }; - if dir_handle == INVALID_HANDLE_VALUE { - return; - } + } - let mut buf = vec![0u64; 4096]; - while !stop.load(Ordering::Relaxed) { - let mut bytes_returned = 0u32; - // SAFETY: buf is 8-byte aligned and valid for ReadDirectoryChangesW. - let ok = unsafe { - ReadDirectoryChangesW( - dir_handle, - buf.as_mut_ptr().cast(), - (buf.len() * std::mem::size_of::()) as u32, - true, - FILE_NOTIFY_CHANGE_FILE_NAME - | FILE_NOTIFY_CHANGE_DIR_NAME - | FILE_NOTIFY_CHANGE_LAST_WRITE - | FILE_NOTIFY_CHANGE_SIZE, - Some(&mut bytes_returned), + /// Watches the sync root directory for files or folders dropped in by the user + /// and uploads them to the phone via `storage.write` / `storage.mkdir`. + fn start_directory_watcher(root: Arc, stop: Arc) { + let _ = std::thread::Builder::new().name("storage-watch".into()).spawn(move || { + let wide_dir = to_wide_null(&root.root_path); + // SAFETY: wide_dir is a valid null-terminated path. + let dir_handle = unsafe { + CreateFileW( + PCWSTR(wide_dir.as_ptr()), + FILE_LIST_DIRECTORY.0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, None, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, None, ) + }; + let Ok(dir_handle) = dir_handle else { return }; + if dir_handle == INVALID_HANDLE_VALUE { + return; } - .is_ok(); - - if !ok || stop.load(Ordering::Relaxed) || bytes_returned == 0 { - break; - } - - let changed_paths = parse_notify_buffer( - // SAFETY: bytes_returned bytes were initialized by ReadDirectoryChangesW. - unsafe { std::slice::from_raw_parts(buf.as_ptr().cast::(), bytes_returned as usize) }, - ); - for rel_win in changed_paths { - let rel_slash = rel_win.replace('\\', "/"); - if rel_slash.is_empty() - || rel_slash.split('/').any(|seg| seg.starts_with('.') || seg.starts_with("~$")) - { - continue; + let mut buf = vec![0u64; 4096]; + while !stop.load(Ordering::Relaxed) { + let mut bytes_returned = 0u32; + // SAFETY: buf is 8-byte aligned and valid for ReadDirectoryChangesW. + let ok = unsafe { + ReadDirectoryChangesW( + dir_handle, + buf.as_mut_ptr().cast(), + (buf.len() * std::mem::size_of::()) as u32, + true, + FILE_NOTIFY_CHANGE_FILE_NAME + | FILE_NOTIFY_CHANGE_DIR_NAME + | FILE_NOTIFY_CHANGE_LAST_WRITE + | FILE_NOTIFY_CHANGE_SIZE, + Some(&mut bytes_returned), + None, + None, + ) } - let full_path = root.root_path.join(&rel_win); - if !needs_upload(&full_path) { - continue; + .is_ok(); + + if !ok || stop.load(Ordering::Relaxed) || bytes_returned == 0 { + break; } - { - let mut uploading = lock(&root.uploading); - if !uploading.insert(rel_slash.clone()) { + + let changed_paths = parse_notify_buffer( + // SAFETY: bytes_returned bytes were initialized by ReadDirectoryChangesW. + unsafe { std::slice::from_raw_parts(buf.as_ptr().cast::(), bytes_returned as usize) }, + ); + + for rel_win in changed_paths { + let rel_slash = rel_win.replace('\\', "/"); + if rel_slash.is_empty() + || rel_slash.split('/').any(|seg| seg.starts_with('.') || seg.starts_with("~$")) + { continue; } + let full_path = root.root_path.join(&rel_win); + if !needs_upload(&full_path) { + continue; + } + { + let mut uploading = lock(&root.uploading); + if !uploading.insert(rel_slash.clone()) { + continue; + } + } + let root_clone = root.clone(); + core_host::spawn(async move { + // Brief debounce so Explorer finishes writing the dropped file. + tokio::time::sleep(Duration::from_millis(350)).await; + upload_local_item(&root_clone, &rel_slash, &full_path).await; + lock(&root_clone.uploading).remove(&rel_slash); + }); } - let root_clone = root.clone(); - core_host::spawn(async move { - // Brief debounce so Explorer finishes writing the dropped file. - tokio::time::sleep(Duration::from_millis(350)).await; - upload_local_item(&root_clone, &rel_slash, &full_path).await; - lock(&root_clone.uploading).remove(&rel_slash); - }); } - } - // SAFETY: dir_handle was opened with CreateFileW above. - let _ = unsafe { CloseHandle(dir_handle) }; - }); -} + // SAFETY: dir_handle was opened with CreateFileW above. + let _ = unsafe { CloseHandle(dir_handle) }; + }); + } -fn parse_notify_buffer(bytes: &[u8]) -> Vec { - let mut out = Vec::new(); - let mut offset = 0usize; - while offset + std::mem::size_of::() <= bytes.len() { - // SAFETY: offset is within bounds and 4-byte aligned in ReadDirectoryChangesW buffer. - let info = unsafe { &*(bytes.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION) }; - let name_bytes = info.FileNameLength as usize; - let name_offset = offset + std::mem::offset_of!(FILE_NOTIFY_INFORMATION, FileName); - if name_offset + name_bytes <= bytes.len() - && matches!(info.Action, FILE_ACTION_ADDED | FILE_ACTION_MODIFIED | FILE_ACTION_RENAMED_NEW_NAME) - { - // SAFETY: FileName is a UTF-16 slice of `name_bytes / 2` u16 elements. - let wide = unsafe { - std::slice::from_raw_parts(bytes.as_ptr().add(name_offset) as *const u16, name_bytes / 2) - }; - let rel = std::ffi::OsString::from_wide(wide).to_string_lossy().into_owned(); - if !out.contains(&rel) { - out.push(rel); + fn parse_notify_buffer(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut offset = 0usize; + while offset + std::mem::size_of::() <= bytes.len() { + // SAFETY: offset is within bounds and 4-byte aligned in ReadDirectoryChangesW buffer. + let info = unsafe { &*(bytes.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION) }; + let name_bytes = info.FileNameLength as usize; + let name_offset = offset + std::mem::offset_of!(FILE_NOTIFY_INFORMATION, FileName); + if name_offset + name_bytes <= bytes.len() + && matches!( + info.Action, + FILE_ACTION_ADDED | FILE_ACTION_MODIFIED | FILE_ACTION_RENAMED_NEW_NAME + ) + { + // SAFETY: FileName is a UTF-16 slice of `name_bytes / 2` u16 elements. + let wide = unsafe { + std::slice::from_raw_parts(bytes.as_ptr().add(name_offset) as *const u16, name_bytes / 2) + }; + let rel = std::ffi::OsString::from_wide(wide).to_string_lossy().into_owned(); + if !out.contains(&rel) { + out.push(rel); + } } + if info.NextEntryOffset == 0 { + break; + } + offset = offset.saturating_add(info.NextEntryOffset as usize); } - if info.NextEntryOffset == 0 { - break; - } - offset = offset.saturating_add(info.NextEntryOffset as usize); + out } - out -} -/// Checks whether `full_path` is a newly dropped non-placeholder file/folder or -/// a modified placeholder that is no longer in sync. -fn needs_upload(full_path: &Path) -> bool { - let wide = to_wide_null(full_path); - let mut find_data = WIN32_FIND_DATAW::default(); - // SAFETY: wide is null-terminated and find_data is a valid struct. - let handle = unsafe { FindFirstFileW(PCWSTR(wide.as_ptr()), &mut find_data) }; - let Ok(handle) = handle else { return false }; - if handle == INVALID_HANDLE_VALUE { - return false; + /// Checks whether `full_path` is a newly dropped non-placeholder file/folder or + /// a modified placeholder that is no longer in sync. + fn needs_upload(full_path: &Path) -> bool { + let wide = to_wide_null(full_path); + let mut find_data = WIN32_FIND_DATAW::default(); + // SAFETY: wide is null-terminated and find_data is a valid struct. + let handle = unsafe { FindFirstFileW(PCWSTR(wide.as_ptr()), &mut find_data) }; + let Ok(handle) = handle else { return false }; + if handle == INVALID_HANDLE_VALUE { + return false; + } + // SAFETY: handle came from FindFirstFileW. + let _ = unsafe { FindClose(handle) }; + // SAFETY: dwFileAttributes and dwReserved0 (reparse tag) were populated by FindFirstFileW. + let state = unsafe { + CfGetPlaceholderStateFromAttributeTag(find_data.dwFileAttributes, find_data.dwReserved0) + }; + if (state.0 & CF_PLACEHOLDER_STATE_PLACEHOLDER.0) == 0 { + return true; + } + (state.0 & CF_PLACEHOLDER_STATE_IN_SYNC.0) == 0 } - // SAFETY: handle came from FindFirstFileW. - let _ = unsafe { FindClose(handle) }; - // SAFETY: dwFileAttributes and dwReserved0 (reparse tag) were populated by FindFirstFileW. - let state = - unsafe { CfGetPlaceholderStateFromAttributeTag(find_data.dwFileAttributes, find_data.dwReserved0) }; - if (state.0 & CF_PLACEHOLDER_STATE_PLACEHOLDER.0) == 0 { - return true; + + async fn upload_local_item(root: &Arc, rel_slash: &str, full_path: &Path) { + if !full_path.exists() || !needs_upload(full_path) { + return; + } + let Some(node) = core_host::node() else { return }; + if full_path.is_dir() { + if node.storage_mkdir(root.device, rel_slash).await.is_ok() { + mark_path_in_sync(full_path, rel_slash, true); + } + } else if full_path.is_file() && node.storage_write(root.device, rel_slash, full_path).await.is_ok() { + mark_path_in_sync(full_path, rel_slash, false); + } } - (state.0 & CF_PLACEHOLDER_STATE_IN_SYNC.0) == 0 -} -async fn upload_local_item(root: &Arc, rel_slash: &str, full_path: &Path) { - if !full_path.exists() || !needs_upload(full_path) { - return; + fn mark_path_in_sync(full_path: &Path, rel_slash: &str, is_dir: bool) { + let wide = to_wide_null(full_path); + let flags = if is_dir { FILE_FLAG_BACKUP_SEMANTICS } else { Default::default() }; + // SAFETY: wide is a valid null-terminated path. + let handle = unsafe { + CreateFileW( + PCWSTR(wide.as_ptr()), + GENERIC_READ.0 | GENERIC_WRITE.0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + None, + OPEN_EXISTING, + flags, + None, + ) + }; + let Ok(handle): Result = handle else { return }; + if handle == INVALID_HANDLE_VALUE { + return; + } + let identity = rel_slash.as_bytes(); + // SAFETY: handle is open with read/write access; identity is valid for the call. + let converted = unsafe { + CfConvertToPlaceholder( + handle, + Some(identity.as_ptr().cast()), + identity.len() as u32, + CF_CONVERT_FLAG_MARK_IN_SYNC, + None, + None, + ) + }; + if converted.is_err() { + // Already a placeholder: mark it in sync. + // SAFETY: handle is valid. + let _ = + unsafe { CfSetInSyncState(handle, CF_IN_SYNC_STATE_IN_SYNC, CF_SET_IN_SYNC_FLAG_NONE, None) }; + } + // SAFETY: handle was opened with CreateFileW above. + let _ = unsafe { CloseHandle(handle) }; } - let Some(node) = core_host::node() else { return }; - if full_path.is_dir() { - if node.storage_mkdir(root.device, rel_slash).await.is_ok() { - mark_path_in_sync(full_path, rel_slash, true); + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn converts_unix_ms_to_filetime_and_back() { + let unix_ms = 1_710_000_000_123i64; + let ticks = unix_ms_to_filetime_ticks(unix_ms); + assert_eq!(ticks, EPOCH_DIFF_100NS + unix_ms * 10_000); + assert_eq!(filetime_ticks_to_unix_ms(ticks), unix_ms); + assert_eq!(unix_ms_to_filetime_ticks(-500), EPOCH_DIFF_100NS); + } + + #[test] + fn builds_placeholder_metadata_for_files_and_folders() { + let file_entry = StorageEntry { + name: "photo.jpg".into(), + size: 42_000, + modified: 1_700_000_000_000, + is_dir: false, + }; + let wide_file = to_wide_null(&file_entry.name); + let id_file = b"DCIM/photo.jpg"; + let info_file = build_placeholder_create_info(&file_entry, &wide_file, id_file); + assert_eq!(info_file.FsMetadata.FileSize, 42_000); + assert_eq!(info_file.FsMetadata.BasicInfo.FileAttributes, FILE_ATTRIBUTE_NORMAL.0); + assert_eq!( + info_file.FsMetadata.BasicInfo.LastWriteTime, + unix_ms_to_filetime_ticks(1_700_000_000_000) + ); + assert_eq!(info_file.FileIdentityLength, id_file.len() as u32); + + let dir_entry = + StorageEntry { name: "Camera".into(), size: 999, modified: 1_700_000_000_000, is_dir: true }; + let wide_dir = to_wide_null(&dir_entry.name); + let id_dir = b"DCIM/Camera"; + let info_dir = build_placeholder_create_info(&dir_entry, &wide_dir, id_dir); + assert_eq!(info_dir.FsMetadata.FileSize, 0); + assert_eq!(info_dir.FsMetadata.BasicInfo.FileAttributes, FILE_ATTRIBUTE_DIRECTORY.0); + } + + #[test] + fn normalizes_relative_paths_and_sanitizes_folder_names() { + let root = Path::new(r"C:\Users\alice\Nectarlink\Pixel 9"); + assert_eq!(relative_path_under_root(root, root), Some(String::new())); + assert_eq!( + relative_path_under_root( + root, + Path::new(r"C:\Users\alice\Nectarlink\Pixel 9\DCIM\Camera\a.jpg") + ), + Some("DCIM/Camera/a.jpg".into()) + ); + assert_eq!( + relative_path_under_root(root, Path::new(r"C:\Users\alice\Nectarlink\Pixel 9 Pro\DCIM")), + None + ); + + assert_eq!(sanitize_folder_name("Pixel 9 (test)"), "Pixel 9 (test)"); + assert_eq!(sanitize_folder_name("My:Phone*1?"), "My Phone 1"); + assert_eq!(sanitize_folder_name("CON"), "CON phone"); } - } else if full_path.is_file() && node.storage_write(root.device, rel_slash, full_path).await.is_ok() { - mark_path_in_sync(full_path, rel_slash, false); } } -fn mark_path_in_sync(full_path: &Path, rel_slash: &str, is_dir: bool) { - let wide = to_wide_null(full_path); - let flags = if is_dir { FILE_FLAG_BACKUP_SEMANTICS } else { Default::default() }; - // SAFETY: wide is a valid null-terminated path. - let handle = unsafe { - CreateFileW( - PCWSTR(wide.as_ptr()), - GENERIC_READ.0 | GENERIC_WRITE.0, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - None, - OPEN_EXISTING, - flags, - None, - ) - }; - let Ok(handle): Result = handle else { return }; - if handle == INVALID_HANDLE_VALUE { - return; +#[cfg(not(windows))] +mod imp { + #![allow(dead_code)] + + use nectarlink_core::{DeviceId, NodeEvent}; + use std::path::{Path, PathBuf}; + + pub fn start() {} + + pub fn sync() {} + + pub fn on_event(_event: &NodeEvent) {} + + pub fn shutdown() {} + + pub fn unregister_all() {} + + pub fn sync_root_path_for(_device: DeviceId) -> Option { + None } - let identity = rel_slash.as_bytes(); - // SAFETY: handle is open with read/write access; identity is valid for the call. - let converted = unsafe { - CfConvertToPlaceholder( - handle, - Some(identity.as_ptr().cast()), - identity.len() as u32, - CF_CONVERT_FLAG_MARK_IN_SYNC, - None, - None, - ) - }; - if converted.is_err() { - // Already a placeholder: mark it in sync. - // SAFETY: handle is valid. - let _ = unsafe { CfSetInSyncState(handle, CF_IN_SYNC_STATE_IN_SYNC, CF_SET_IN_SYNC_FLAG_NONE, None) }; + + pub fn open_in_explorer(_device: DeviceId) -> Result<(), String> { + Err("phone storage exploration is not supported on this platform".into()) } - // SAFETY: handle was opened with CreateFileW above. - let _ = unsafe { CloseHandle(handle) }; -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn converts_unix_ms_to_filetime_and_back() { - let unix_ms = 1_710_000_000_123i64; - let ticks = unix_ms_to_filetime_ticks(unix_ms); - assert_eq!(ticks, EPOCH_DIFF_100NS + unix_ms * 10_000); - assert_eq!(filetime_ticks_to_unix_ms(ticks), unix_ms); - assert_eq!(unix_ms_to_filetime_ticks(-500), EPOCH_DIFF_100NS); + pub fn sanitize_folder_name(device_name: &str) -> String { + device_name.chars().filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')).collect() } - #[test] - fn builds_placeholder_metadata_for_files_and_folders() { - let file_entry = StorageEntry { - name: "photo.jpg".into(), - size: 42_000, - modified: 1_700_000_000_000, - is_dir: false, - }; - let wide_file = to_wide_null(&file_entry.name); - let id_file = b"DCIM/photo.jpg"; - let info_file = build_placeholder_create_info(&file_entry, &wide_file, id_file); - assert_eq!(info_file.FsMetadata.FileSize, 42_000); - assert_eq!(info_file.FsMetadata.BasicInfo.FileAttributes, FILE_ATTRIBUTE_NORMAL.0); - assert_eq!( - info_file.FsMetadata.BasicInfo.LastWriteTime, - unix_ms_to_filetime_ticks(1_700_000_000_000) - ); - assert_eq!(info_file.FileIdentityLength, id_file.len() as u32); - - let dir_entry = - StorageEntry { name: "Camera".into(), size: 999, modified: 1_700_000_000_000, is_dir: true }; - let wide_dir = to_wide_null(&dir_entry.name); - let id_dir = b"DCIM/Camera"; - let info_dir = build_placeholder_create_info(&dir_entry, &wide_dir, id_dir); - assert_eq!(info_dir.FsMetadata.FileSize, 0); - assert_eq!(info_dir.FsMetadata.BasicInfo.FileAttributes, FILE_ATTRIBUTE_DIRECTORY.0); + pub fn unix_ms_to_filetime_ticks(unix_ms: i64) -> i64 { + unix_ms * 10_000 + 116_444_736_000_000_000 } - #[test] - fn normalizes_relative_paths_and_sanitizes_folder_names() { - let root = Path::new(r"C:\Users\alice\Nectarlink\Pixel 9"); - assert_eq!(relative_path_under_root(root, root), Some(String::new())); - assert_eq!( - relative_path_under_root(root, Path::new(r"C:\Users\alice\Nectarlink\Pixel 9\DCIM\Camera\a.jpg")), - Some("DCIM/Camera/a.jpg".into()) - ); - assert_eq!( - relative_path_under_root(root, Path::new(r"C:\Users\alice\Nectarlink\Pixel 9 Pro\DCIM")), - None - ); + pub fn filetime_ticks_to_unix_ms(ticks: i64) -> i64 { + (ticks - 116_444_736_000_000_000) / 10_000 + } - assert_eq!(sanitize_folder_name("Pixel 9 (test)"), "Pixel 9 (test)"); - assert_eq!(sanitize_folder_name("My:Phone*1?"), "My Phone 1"); - assert_eq!(sanitize_folder_name("CON"), "CON phone"); + pub fn relative_path_under_root(root_path: &Path, full_path: &Path) -> Option { + full_path.strip_prefix(root_path).ok().and_then(|p| p.to_str()).map(|s| s.replace('\\', "/")) } } + +pub use imp::*; diff --git a/desktop/app/src/updater.rs b/desktop/app/src/updater.rs index f85ca043..c8941685 100644 --- a/desktop/app/src/updater.rs +++ b/desktop/app/src/updater.rs @@ -25,8 +25,9 @@ //! update didn't go through. use crate::i18n::tr; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use std::{ - os::windows::process::CommandExt, path::{Path, PathBuf}, process::ExitCode, sync::{ @@ -39,10 +40,12 @@ use std::{ use serde::Deserialize; use sha2::{Digest, Sha256}; +#[cfg(windows)] +use crate::win::autostart; use crate::{ core_host, state::Changes, - win::{autostart, http, toast}, + win::{http, toast}, }; const LATEST: &str = "https://api.github.com/repos/thtbee/nectarlink/releases/latest"; @@ -64,6 +67,7 @@ pub struct Update { } /// The scheduled task the installer registers (see the module docs). +#[cfg(windows)] pub const TASK: &str = "Nectarlink Update"; /// What the task runs: `nectarlink-desktop.exe --update-service`. pub const SERVICE_ARG: &str = "--update-service"; @@ -71,7 +75,9 @@ pub const SERVICE_ARG: &str = "--update-service"; const QUIET_FOR: Duration = Duration::from_secs(10 * 60); /// While an update waits for a quiet moment, how often to look for one. const LOOK_FOR_QUIET: Duration = Duration::from_secs(5 * 60); +#[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(windows)] const DETACHED_PROCESS: u32 = 0x0000_0008; static AUTO: AtomicBool = AtomicBool::new(true); @@ -100,6 +106,10 @@ pub fn set_auto_install(on: bool) { /// Whether this copy updates itself (an installed one, not a build run /// from its folder). pub fn can_update() -> bool { + #[cfg(not(windows))] + return false; + + #[cfg(windows)] std::env::current_exe().is_ok_and(|exe| autostart::is_installed(&exe)) } @@ -217,6 +227,9 @@ const INSTALLER_SUFFIX: &str = /// The release as an update, if it's a newer published one with an /// installer for this architecture and its sums. fn newer_release(release: Release, current: &str) -> Option { + if !cfg!(windows) { + return None; + } newer_release_for(release, current, INSTALLER_SUFFIX) } @@ -305,13 +318,20 @@ fn quiet() -> bool { /// Whether the installer registered [`TASK`] on this PC. fn task_registered() -> bool { - std::process::Command::new("schtasks.exe") - .args(["/Query", "/TN", TASK]) - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) + #[cfg(windows)] + { + std::process::Command::new("schtasks.exe") + .args(["/Query", "/TN", TASK]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + } + #[cfg(not(windows))] + { + false + } } /// Installs the update found: downloads it (reporting `progress(done, @@ -320,20 +340,28 @@ fn task_registered() -> bool { /// Blocking. Returns what went wrong, in words for the user. On success the /// app is about to quit for the installer. pub fn install(progress: impl FnMut(u64, u64)) -> Result<(), String> { - let update = available().ok_or("There's no update to install.")?; - let staged = stage(&update, progress).map_err(|e| { - tracing::warn!(error = %e, "update download failed"); - e.for_user() - })?; - if task_registered() { - match install_with_task(true) { - Ok(()) => return Ok(()), - Err(e) => { - tracing::warn!(error = e, "the update task didn't start; asking for permission instead") + #[cfg(not(windows))] + { + let _ = progress; + Err("Updates are not supported on this platform.".into()) + } + #[cfg(windows)] + { + let update = available().ok_or("There's no update to install.")?; + let staged = stage(&update, progress).map_err(|e| { + tracing::warn!(error = %e, "update download failed"); + e.for_user() + })?; + if task_registered() { + match install_with_task(true) { + Ok(()) => return Ok(()), + Err(e) => { + tracing::warn!(error = e, "the update task didn't start; asking for permission instead") + } } } + install_with_prompt(&update, &staged) } - install_with_prompt(&update, &staged) } /// Why a download didn't produce a checked installer. @@ -445,50 +473,68 @@ pub fn stage(update: &Update, progress: impl FnMut(u64, u64)) -> Result