From 8a9f9d82730ffa3ed18a311d28b1f84a2bfaacd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Kirst=C3=A4tter?= <7329802+jochenkirstaetter@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:31:41 +0400 Subject: [PATCH 1/5] fix(qml): guard Qt 6.5/6.7 QML properties for Qt 6.4 compatibility on Ubuntu 24.04 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jochen Kirstätter <7329802+jochenkirstaetter@users.noreply.github.com> --- desktop/app/qml/App.qml | 5 +++-- desktop/app/qml/IslandWindow.qml | 4 ++-- desktop/app/qml/MainWindow.qml | 6 +++--- desktop/app/qml/components/Txt.qml | 4 +++- desktop/app/qml/pages/PairingPanel.qml | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/desktop/app/qml/App.qml b/desktop/app/qml/App.qml index 4dbfb815..785c8026 100644 --- a/desktop/app/qml/App.qml +++ b/desktop/app/qml/App.qml @@ -155,9 +155,10 @@ QtObject { // Qt follows the system color scheme unless told otherwise; the window // frame and Mica tint must follow the app's theme instead. readonly property Binding colorScheme: Binding { - target: Qt.styleHints + when: "colorScheme" in Qt.styleHints && typeof Qt.ColorScheme !== "undefined" + target: "colorScheme" in Qt.styleHints ? Qt.styleHints : null property: "colorScheme" - value: Theme.dark ? Qt.ColorScheme.Dark : Qt.ColorScheme.Light + value: typeof Qt.ColorScheme !== "undefined" ? (Theme.dark ? Qt.ColorScheme.Dark : Qt.ColorScheme.Light) : 0 } readonly property Connections controller: Connections { diff --git a/desktop/app/qml/IslandWindow.qml b/desktop/app/qml/IslandWindow.qml index 404fe14d..ee03975c 100644 --- a/desktop/app/qml/IslandWindow.qml +++ b/desktop/app/qml/IslandWindow.qml @@ -442,7 +442,7 @@ Window { Shape { anchors.fill: parent - preferredRendererType: Shape.CurveRenderer + Component.onCompleted: if ("preferredRendererType" in this) preferredRendererType = 2 ShapePath { strokeColor: Qt.rgba(1, 1, 1, 0.16) @@ -699,7 +699,7 @@ Window { Shape { anchors.fill: parent - preferredRendererType: Shape.CurveRenderer + Component.onCompleted: if ("preferredRendererType" in this) preferredRendererType = 2 ShapePath { strokeColor: Qt.rgba(1, 1, 1, 0.16) fillColor: "transparent" diff --git a/desktop/app/qml/MainWindow.qml b/desktop/app/qml/MainWindow.qml index 5b3d4c97..c45ef3f4 100644 --- a/desktop/app/qml/MainWindow.qml +++ b/desktop/app/qml/MainWindow.qml @@ -520,7 +520,7 @@ NativeWindow { visible: (shown || opacity > 0) && room > 120 opacity: shown ? 1 : 0 scale: shown ? 1 : 0.94 - color: Theme.errorContainer + color: Theme.errorContainer || Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.2) border.width: 1 border.color: Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, Theme.dark ? 0.45 : 0.30) Behavior on opacity { enabled: !Theme.reduceMotion; NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } @@ -568,7 +568,7 @@ NativeWindow { anchors.verticalCenter: parent.verticalCenter text: sharingPill.fullText role: "label" - color: Theme.errorContainerContent + color: Theme.errorContainerContent || Theme.error elide: Text.ElideRight } Button { @@ -646,7 +646,7 @@ NativeWindow { anchors.bottom: parent.bottom width: parent.width clip: true - topLeftRadius: 18 + Component.onCompleted: if ("topLeftRadius" in this) topLeftRadius = 18 // Hive: lets the comb show faintly through, under opaque cards. color: Theme.hive ? Qt.rgba(Theme.surfaceContainerLow.r, Theme.surfaceContainerLow.g, Theme.surfaceContainerLow.b, Theme.dark ? 0.50 : 0.58) diff --git a/desktop/app/qml/components/Txt.qml b/desktop/app/qml/components/Txt.qml index b531f490..3036f11b 100644 --- a/desktop/app/qml/components/Txt.qml +++ b/desktop/app/qml/components/Txt.qml @@ -30,7 +30,9 @@ Text { font.family: family font.pixelSize: Math.max(1, Math.round(size)) font.weight: weight - font.variableAxes: family === Theme.fontUi ? { "wght": weight } : {} + onFamilyChanged: if ("variableAxes" in font) font.variableAxes = family === Theme.fontUi ? { "wght": weight } : {} + onWeightChanged: if ("variableAxes" in font) font.variableAxes = family === Theme.fontUi ? { "wght": weight } : {} + Component.onCompleted: if ("variableAxes" in font) font.variableAxes = family === Theme.fontUi ? { "wght": weight } : {} font.letterSpacing: (graphiteLabel ? Theme.labelStyle.tracking : spec.tracking) * size font.capitalization: graphiteLabel ? Font.AllUppercase : Font.MixedCase // Token line heights are CSS-style (a multiple of the font size), not diff --git a/desktop/app/qml/pages/PairingPanel.qml b/desktop/app/qml/pages/PairingPanel.qml index 1f2fa1b9..b8be7acd 100644 --- a/desktop/app/qml/pages/PairingPanel.qml +++ b/desktop/app/qml/pages/PairingPanel.qml @@ -239,7 +239,7 @@ Item { anchors.left: deviceIcon.right; anchors.leftMargin: 12 anchors.right: pairButton.left; anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: modelData.name + text: (modelData && modelData.name) ? modelData.name : "" role: "title" } Button { From 3b66d75c78da40b8fa012574d78ed9efa51cfb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Kirst=C3=A4tter?= <7329802+jochenkirstaetter@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:40:19 +0400 Subject: [PATCH 2/5] feat(linux): full Linux desktop and CLI implementation with CI workflow 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 | 28 + .gitignore | 1 + 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 | 124 +- desktop/app/cpp/app_helpers.cpp | 18 + desktop/app/cpp/native_window.cpp | 189 +++ desktop/app/cpp/native_window.h | 1 + desktop/app/i18n/rust_strings.h | 1 + desktop/app/src/automations.rs | 1 + desktop/app/src/bridge/app.rs | 302 +++-- desktop/app/src/bridge/media.rs | 12 +- desktop/app/src/core_host.rs | 3 +- desktop/app/src/deck.rs | 23 +- desktop/app/src/linux/audio.rs | 393 ++++++ desktop/app/src/linux/audio_out.rs | 311 +++++ desktop/app/src/linux/autostart.rs | 135 ++ desktop/app/src/linux/ble_beacon.rs | 387 ++++++ desktop/app/src/linux/browser.rs | 405 ++++++ desktop/app/src/linux/bt_hfp.rs | 239 ++++ desktop/app/src/linux/clipboard.rs | 980 ++++++++++++++ desktop/app/src/linux/doctor.rs | 338 +++++ desktop/app/src/linux/elevated.rs | 109 ++ desktop/app/src/linux/h264.rs | 967 +++++++++++++ desktop/app/src/linux/http.rs | 166 +++ desktop/app/src/linux/icon.rs | 25 + desktop/app/src/linux/image.rs | 1194 +++++++++++++++++ desktop/app/src/linux/input.rs | 573 ++++++++ desktop/app/src/linux/jumplist.rs | 185 +++ desktop/app/src/linux/media_sessions.rs | 804 +++++++++++ desktop/app/src/linux/mod.rs | 276 ++++ desktop/app/src/linux/mp4.rs | 896 +++++++++++++ desktop/app/src/linux/net.rs | 151 +++ desktop/app/src/linux/share_target.rs | 215 +++ desktop/app/src/linux/shell.rs | 439 ++++++ desktop/app/src/linux/shortcut.rs | 388 ++++++ desktop/app/src/linux/single_instance.rs | 152 +++ desktop/app/src/linux/smtc.rs | 337 +++++ desktop/app/src/linux/sound.rs | 198 +++ desktop/app/src/linux/toast.rs | 812 +++++++++++ desktop/app/src/linux/tray.rs | 984 ++++++++++++++ desktop/app/src/linux/vcam.rs | 425 ++++++ desktop/app/src/linux/vmic.rs | 314 +++++ desktop/app/src/linux/wallpaper.rs | 389 ++++++ desktop/app/src/linux/widget.rs | 274 ++++ desktop/app/src/linux/window_capture.rs | 186 +++ desktop/app/src/linux/wol.rs | 268 ++++ desktop/app/src/main.rs | 4 + desktop/app/src/mirror.rs | 189 +++ desktop/app/src/pc_audio_stream.rs | 164 +++ desktop/app/src/pc_mirror.rs | 727 +++++++++- desktop/app/src/pc_notifications.rs | 197 ++- desktop/app/src/play_on_phone.rs | 5 + desktop/app/src/recordings.rs | 58 +- desktop/app/src/send_to.rs | 4 + desktop/app/src/settings.rs | 4 + desktop/app/src/setup.rs | 110 +- desktop/app/src/storage.rs | 685 +++++++++- desktop/app/src/transfers.rs | 18 +- desktop/app/src/updater.rs | 213 ++- desktop/packaging/linux/build-appimage.sh | 70 + desktop/packaging/linux/build-deb.sh | 113 ++ desktop/packaging/linux/build-tarball.sh | 112 ++ desktop/packaging/linux/nectarlink.desktop | 27 + .../linux/org.nectarlink.desktop.metainfo.xml | 38 + desktop/vcam/Cargo.toml | 7 +- desktop/vcam/src/lib.rs | 10 + desktop/vcam/src/shm.rs | 114 ++ docs/LINUX_PLAN.md | 396 ++++++ scripts/check.ps1 | 13 +- spikes/s1-qt-rust/build.rs | 14 + spikes/s1-qt-rust/cpp/frame_notifier.h | 4 + spikes/s1-qt-rust/cpp/video_surface.cpp | 20 +- spikes/s1-qt-rust/cpp/window_helpers.cpp | 3 + spikes/s1-qt-rust/src/video.rs | 41 +- 77 files changed, 18153 insertions(+), 183 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/window_capture.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 create mode 100644 docs/LINUX_PLAN.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77745268..1bbacb53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,34 @@ 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 + - 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/.gitignore b/.gitignore index 537ae384..bb4b5fee 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ signing.properties **/*.rs.bk # ---- Qt / C++ build output ---- +/.qt6-sysroot/ build/ build-*/ *.qmlc 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 edd4e7c5..40591787 100644 --- a/desktop/app/Cargo.toml +++ b/desktop/app/Cargo.toml @@ -112,6 +112,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 adedcbe0..6e946cfb 100644 --- a/desktop/app/build.rs +++ b/desktop/app/build.rs @@ -237,6 +237,101 @@ 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); + } + } + if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { + let sysroot_bin = Path::new(&manifest_dir).join("../../.qt6-sysroot/usr/lib/qt6/bin/lrelease"); + if sysroot_bin.is_file() { + return Some(sysroot_bin); + } + } + None +} + +fn compile_translations() { + let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else { + return; + }; + let i18n_dir = Path::new(&manifest_dir).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); + let sysroot_lib = Path::new(&manifest_dir).join("../../.qt6-sysroot/usr/lib/x86_64-linux-gnu"); + + for ts in ts_files { + let Some(stem) = ts.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let qm_name = format!("{stem}.qm"); + let out_qm = out_path.join(&qm_name); + let mut cmd = std::process::Command::new(&lrelease); + if sysroot_lib.is_dir() { + let mut ld_path = sysroot_lib.display().to_string(); + if let Ok(existing) = std::env::var("LD_LIBRARY_PATH") + && !existing.is_empty() + { + ld_path.push(':'); + ld_path.push_str(&existing); + } + cmd.env("LD_LIBRARY_PATH", ld_path); + } + let compiled = + cmd.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 +340,7 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { windows_resources(); } + 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))); @@ -282,8 +378,28 @@ 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") + && let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") + { + println!("cargo:rustc-link-arg=-fuse-ld=bfd"); + println!("cargo:rustc-link-lib=Qt6Quick"); + let sysroot_lib = + std::path::Path::new(&manifest_dir).join("../../.qt6-sysroot/usr/lib/x86_64-linux-gnu"); + if let Ok(canon) = sysroot_lib.canonicalize() { + println!("cargo:rustc-link-search=native={}", canon.display()); + println!("cargo:rustc-link-arg=-Wl,--disable-new-dtags"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", canon.display()); + } + } + 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/app_helpers.cpp b/desktop/app/cpp/app_helpers.cpp index 687e7598..b10cc02f 100644 --- a/desktop/app/cpp/app_helpers.cpp +++ b/desktop/app/cpp/app_helpers.cpp @@ -12,7 +12,10 @@ #include #include #include +#include +#if __has_include() #include +#endif #include #include #include @@ -72,6 +75,13 @@ void register_qml_app_engine(QQmlApplicationEngine &engine) { qmlEngineRef() = &engine; remember(&engine); + engine.addImportPath(QStringLiteral("qrc:/qt/qml")); + engine.addImportPath(QStringLiteral(":/qt/qml")); +#ifdef __linux__ + const QString localQml = QStringLiteral("/datadisk/Repos/nectarlink/.qt6-sysroot/usr/lib/x86_64-linux-gnu/qt6/qml"); + if (QFileInfo::exists(localQml)) + engine.addImportPath(localQml); +#endif } #include @@ -104,6 +114,14 @@ void prepare_qt() void prepare_qt_without_log() { +#ifdef __linux__ + const QString localPlugins = QStringLiteral("/datadisk/Repos/nectarlink/.qt6-sysroot/usr/lib/x86_64-linux-gnu/qt6/plugins"); + if (qEnvironmentVariableIsEmpty("QT_PLUGIN_PATH") && QFileInfo::exists(localPlugins)) + qputenv("QT_PLUGIN_PATH", localPlugins.toUtf8()); + const QString localQml = QStringLiteral("/datadisk/Repos/nectarlink/.qt6-sysroot/usr/lib/x86_64-linux-gnu/qt6/qml"); + if (qEnvironmentVariableIsEmpty("QML_IMPORT_PATH") && QFileInfo::exists(localQml)) + qputenv("QML_IMPORT_PATH", localQml.toUtf8()); +#endif // Windows get an alpha channel so the Mica backdrop can show through, and // D3D swap chains are presented with DirectComposition, without which // transparent pixels render white (see spikes/s1-qt-rust, lesson 7). diff --git a/desktop/app/cpp/native_window.cpp b/desktop/app/cpp/native_window.cpp index cd675489..67bbd892 100644 --- a/desktop/app/cpp/native_window.cpp +++ b/desktop/app/cpp/native_window.cpp @@ -8,15 +8,21 @@ #include +#ifdef _WIN32 #define WIN32_LEAN_AND_MEAN #include #include #include #include #include +#else +#include +#include +#endif namespace { +#ifdef _WIN32 // Values from the Windows 11 SDK, defined here so older SDK headers work. constexpr DWORD kUseImmersiveDarkMode = 20; // DWMWA_USE_IMMERSIVE_DARK_MODE constexpr DWORD kCaptionColor = 35; // DWMWA_CAPTION_COLOR @@ -48,6 +54,151 @@ void trimD3DResources(QQuickWindow *window) dxgi3->Release(); } } +#else +void trimD3DResources(QQuickWindow *) +{ +} + +class LinuxWindowEventFilter : public QObject +{ +public: + explicit LinuxWindowEventFilter(NativeWindow *window) + : QObject(window) + , window_(window) + { + } + +protected: + bool eventFilter(QObject *watched, QEvent *event) override + { + if (watched != window_) + return QObject::eventFilter(watched, event); + + switch (event->type()) { + case QEvent::MouseMove: { + auto *me = static_cast(event); + if (me->buttons() == Qt::NoButton) { + const Qt::Edges edges = hitResizeEdges(me->position()); + updateCursorForEdges(edges); + } + break; + } + case QEvent::MouseButtonPress: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + const QPointF pos = me->position(); + const Qt::Edges edges = hitResizeEdges(pos); + if (edges != Qt::Edges()) { + window_->startSystemResize(edges); + return true; + } + const qreal titleBarHeight = window_->captionHeight(); + if (pos.y() < titleBarHeight && !hitInteractiveControl(window_->contentItem(), pos)) { + window_->startSystemMove(); + return true; + } + } + break; + } + case QEvent::MouseButtonDblClick: { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + const QPointF pos = me->position(); + const Qt::Edges edges = hitResizeEdges(pos); + const qreal titleBarHeight = window_->captionHeight(); + if (edges == Qt::Edges() && pos.y() < titleBarHeight + && !hitInteractiveControl(window_->contentItem(), pos)) { + window_->toggleMaximized(); + return true; + } + } + break; + } + case QEvent::Leave: + if (borderCursorActive_) { + window_->unsetCursor(); + borderCursorActive_ = false; + } + break; + default: + break; + } + return QObject::eventFilter(watched, event); + } + +private: + Qt::Edges hitResizeEdges(const QPointF &pos) const + { + if (!window_ || window_->visibility() == QWindow::Maximized + || window_->minimumSize() == window_->maximumSize()) { + return {}; + } + if (pos.y() < window_->captionHeight() && hitInteractiveControl(window_->contentItem(), pos)) { + return {}; + } + const qreal w = window_->width(); + const qreal h = window_->height(); + const qreal b = resizeBorderWidth_; + Qt::Edges edges; + if (pos.x() < b) + edges |= Qt::LeftEdge; + else if (pos.x() >= w - b) + edges |= Qt::RightEdge; + if (pos.y() < b) + edges |= Qt::TopEdge; + else if (pos.y() >= h - b) + edges |= Qt::BottomEdge; + return edges; + } + + void updateCursorForEdges(Qt::Edges edges) + { + if (edges == Qt::Edges()) { + if (borderCursorActive_) { + window_->unsetCursor(); + borderCursorActive_ = false; + } + return; + } + Qt::CursorShape shape = Qt::ArrowCursor; + if ((edges == (Qt::TopEdge | Qt::LeftEdge)) || (edges == (Qt::BottomEdge | Qt::RightEdge))) + shape = Qt::SizeFDiagCursor; + else if ((edges == (Qt::TopEdge | Qt::RightEdge)) || (edges == (Qt::BottomEdge | Qt::LeftEdge))) + shape = Qt::SizeBDiagCursor; + else if (edges & (Qt::LeftEdge | Qt::RightEdge)) + shape = Qt::SizeHorCursor; + else if (edges & (Qt::TopEdge | Qt::BottomEdge)) + shape = Qt::SizeVerCursor; + window_->setCursor(QCursor(shape)); + borderCursorActive_ = true; + } + + bool hitInteractiveControl(QQuickItem *item, const QPointF &scenePos) const + { + if (!item || !item->isVisible() || !item->isEnabled() || item->opacity() <= 0.0) + return false; + const QPointF localPos = item->mapFromScene(scenePos); + if (!item->contains(localPos)) + return false; + if (item == window_->maximizeButton() || item->property("_nl_caption_hole").toBool()) + return true; + const auto children = item->childItems(); + for (int i = children.size() - 1; i >= 0; --i) { + if (hitInteractiveControl(children[i], scenePos)) + return true; + } + if (item != window_->contentItem() + && item->acceptedMouseButtons() != Qt::NoButton) { + return true; + } + return false; + } + + NativeWindow *window_ = nullptr; + int resizeBorderWidth_ = 6; + bool borderCursorActive_ = false; +}; +#endif } // namespace @@ -55,12 +206,24 @@ NativeWindow::NativeWindow(QWindow *parent) : QQuickWindow(parent) { // Qt removes the system title bar and lets content fill the window. +#ifdef _WIN32 setFlags(flags() | Qt::ExpandedClientAreaHint); setColor(Qt::transparent); +#else + setFlags(flags() | Qt::FramelessWindowHint); + setColor(QColor(0x14, 0x13, 0x11)); +#endif // Drop the scene graph and D3D/RHI resources when the window is hidden or // closed to the tray instead of keeping GPU buffers in private commit. setPersistentSceneGraph(false); setPersistentGraphics(false); + connect(this, &QWindow::visibilityChanged, this, [this](QWindow::Visibility) { + emit windowVisibilityChanged(); + }); +#ifndef _WIN32 + setProperty("_nl_linux_filter", true); + installEventFilter(new LinuxWindowEventFilter(this)); +#endif } void NativeWindow::setBackdrop(bool on) @@ -117,12 +280,16 @@ void NativeWindow::setMaximizeState(bool hovered, bool pressed) void NativeWindow::addCaptionHole(QQuickItem *item) { + if (item) + item->setProperty("_nl_caption_hole", true); if (item && std::none_of(m_holes.begin(), m_holes.end(), [item](const auto &h) { return h == item; })) m_holes.emplace_back(item); } void NativeWindow::removeCaptionHole(QQuickItem *item) { + if (item) + item->setProperty("_nl_caption_hole", false); m_holes.erase(std::remove_if(m_holes.begin(), m_holes.end(), [item](const auto &h) { return h.isNull() || h == item; }), m_holes.end()); @@ -142,6 +309,7 @@ void NativeWindow::bringToFront() showNormal(); raise(); requestActivate(); +#ifdef _WIN32 if (!handle()) return; const HWND hwnd = hwndOf(this); @@ -160,14 +328,19 @@ void NativeWindow::bringToFront() SetForegroundWindow(hwnd); if (attached) AttachThreadInput(thisThread, foregroundThread, FALSE); +#endif } void NativeWindow::flash() { if (!handle()) return; +#ifdef _WIN32 FLASHWINFO info { sizeof(FLASHWINFO), hwndOf(this), FLASHW_TRAY | FLASHW_TIMERNOFG, 0, 0 }; FlashWindowEx(&info); +#else + alert(0); +#endif } void NativeWindow::releaseAndTeardown() @@ -208,6 +381,7 @@ bool NativeWindow::event(QEvent *event) void NativeWindow::applyFrame() { +#ifdef _WIN32 if (!handle()) return; const HWND hwnd = hwndOf(this); @@ -222,6 +396,13 @@ void NativeWindow::applyFrame() DwmSetWindowAttribute(hwnd, kSystemBackdropType, &backdrop, sizeof(backdrop)); const COLORREF none = kColorNone; DwmSetWindowAttribute(hwnd, kCaptionColor, &none, sizeof(none)); +#else + setColor(m_darkFrame ? QColor(0x14, 0x13, 0x11) : QColor(0xF8, 0xF6, 0xF2)); + if (!property("_nl_linux_filter").toBool()) { + setProperty("_nl_linux_filter", true); + installEventFilter(new LinuxWindowEventFilter(this)); + } +#endif } bool NativeWindow::itemContains(const QQuickItem *item, const QPointF &scenePoint) const @@ -233,6 +414,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 +446,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 +513,6 @@ bool NativeWindow::nativeEvent(const QByteArray &eventType, void *message, qintp default: break; } +#endif return QQuickWindow::nativeEvent(eventType, message, result); } diff --git a/desktop/app/cpp/native_window.h b/desktop/app/cpp/native_window.h index ad06b6bb..72f6a8a4 100644 --- a/desktop/app/cpp/native_window.h +++ b/desktop/app/cpp/native_window.h @@ -72,6 +72,7 @@ class NativeWindow : public QQuickWindow void captionHeightChanged(); void maximizeButtonChanged(); void maximizeStateChanged(); + void windowVisibilityChanged(); void closeRequested(); protected: diff --git a/desktop/app/i18n/rust_strings.h b/desktop/app/i18n/rust_strings.h index 5bb1c926..c375d77c 100644 --- a/desktop/app/i18n/rust_strings.h +++ b/desktop/app/i18n/rust_strings.h @@ -170,6 +170,7 @@ static const char *const nectarlink_rust_strings[] = { QT_TRANSLATE_NOOP("Deck", "Choose a key for the shortcut."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Choose an application (.exe or .lnk) on this PC."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter a command to run on this PC."), // desktop/app/src/deck.rs + QT_TRANSLATE_NOOP("Deck", "Enter an application path (/usr/bin/..., .desktop, or .AppImage)."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter an http:// or https:// web address."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Enter the text snippet to type."), // desktop/app/src/deck.rs QT_TRANSLATE_NOOP("Deck", "Invalid action configuration."), // desktop/app/src/deck.rs diff --git a/desktop/app/src/automations.rs b/desktop/app/src/automations.rs index 188f6e72..78e75910 100644 --- a/desktop/app/src/automations.rs +++ b/desktop/app/src/automations.rs @@ -16,6 +16,7 @@ use crate::{deck, settings, win}; /// Supported automation trigger identifiers. pub const TRIGGER_PHONE_CONNECTED: &str = "phone_connected"; pub const TRIGGER_PHONE_DISCONNECTED: &str = "phone_disconnected"; +#[allow(dead_code)] pub const TRIGGER_PHONE_WALKED_AWAY: &str = "phone_walked_away"; pub const TRIGGER_CALL_RINGING: &str = "call_ringing"; pub const TRIGGER_CALL_ENDED: &str = "call_ended"; diff --git a/desktop/app/src/bridge/app.rs b/desktop/app/src/bridge/app.rs index f7b91929..386f9d88 100644 --- a/desktop/app/src/bridge/app.rs +++ b/desktop/app/src/bridge/app.rs @@ -1650,6 +1650,9 @@ impl qobject::AppController { core_host::refresh_capabilities(); } if name_str == "storage" { + if on && !crate::settings::share_pc_storage() { + crate::settings::set_share_pc_storage(true); + } crate::storage::sync(); } let revision = self.toggles_revision.wrapping_add(1); @@ -1735,7 +1738,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) { @@ -1932,9 +1935,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); @@ -2635,26 +2645,49 @@ fn compute_shelf_items_json(current_device_idx: i32) -> String { let summary = home_store(|s| s.devices.get(&dev).cloned()); match summary { Some(d) if d.photo_loaded => { - if !d.photo_id.is_empty() { - let thumb_p = crate::photos::thumb_path(dev, &d.photo_id); - let thumb_url = if !d.photo_thumb.is_empty() { - d.photo_thumb.clone() + let make_obj = |id: &str, name: &str, date: i64, cached_thumb: &str| -> serde_json::Value { + let thumb_p = crate::photos::thumb_path(dev, id); + let thumb_url = if !cached_thumb.is_empty() { + cached_thumb.to_owned() } else if thumb_p.exists() { crate::icons::file_url(&thumb_p) } else { String::new() }; - let obj = serde_json::json!({ - "id": d.photo_id, - "name": d.photo_name, - "date": d.photo_date, + serde_json::json!({ + "id": id, + "name": name, + "date": date, "thumbUrl": thumb_url, "localPath": "", - }); + }) + }; + if !d.latest_photo_id.is_empty() { + latest_photo = Some(make_obj( + &d.latest_photo_id, + &d.latest_photo_name, + d.latest_photo_date, + &d.latest_photo_thumb, + )); + latest_photo_ts = d.latest_photo_date; + } + if !d.screenshot_id.is_empty() { + latest_screenshot = Some(make_obj( + &d.screenshot_id, + &d.screenshot_name, + d.screenshot_date, + &d.screenshot_thumb, + )); + latest_screenshot_ts = d.screenshot_date; + } + if !d.photo_id.is_empty() { + let obj = make_obj(&d.photo_id, &d.photo_name, d.photo_date, &d.photo_thumb); if d.photo_is_screenshot { - latest_screenshot = Some(obj); - latest_screenshot_ts = d.photo_date; - } else { + if latest_screenshot.is_none() || d.photo_date > latest_screenshot_ts { + latest_screenshot = Some(obj); + latest_screenshot_ts = d.photo_date; + } + } else if latest_photo.is_none() || d.photo_date > latest_photo_ts { latest_photo = Some(obj); latest_photo_ts = d.photo_date; } @@ -2667,45 +2700,55 @@ fn compute_shelf_items_json(current_device_idx: i32) -> String { } } - // 2. Check Timeline photo entries (saved photos / screenshots on disk). - if let Some(ref n) = node - && let Ok(page) = n.timeline_page(&TimelineQuery { - kind: Some(TimelineKind::Photo), - device: device_opt, - limit: 25, - ..Default::default() - }) - { - for entry in page.entries { - let first_target = entry.target.lines().next().unwrap_or("").trim(); - if first_target.is_empty() { - continue; - } - let path = std::path::PathBuf::from(first_target); - if !path.exists() { - continue; - } - let name = path.file_name().and_then(|f| f.to_str()).unwrap_or(&entry.title).to_owned(); - let lower_title = entry.title.to_ascii_lowercase(); - let lower_name = name.to_ascii_lowercase(); - let is_shot = lower_title.contains("screenshot") - || lower_name.contains("screenshot") - || entry.detail.to_ascii_lowercase().contains("screenshot"); - let obj = serde_json::json!({ - "id": path.to_string_lossy(), - "name": name, - "date": entry.timestamp, - "thumbUrl": crate::icons::file_url(&path), - "localPath": path.to_string_lossy(), - }); - if is_shot { - if entry.timestamp >= latest_screenshot_ts { - latest_screenshot_ts = entry.timestamp; - latest_screenshot = Some(obj); + // 2. Check Timeline photo & file entries (saved photos / screenshots on disk). + if let Some(ref n) = node { + for kind in [TimelineKind::Photo, TimelineKind::File] { + if let Ok(page) = n.timeline_page(&TimelineQuery { + kind: Some(kind), + device: device_opt, + limit: 25, + ..Default::default() + }) { + for entry in page.entries { + let first_target = entry.target.lines().next().unwrap_or("").trim(); + if first_target.is_empty() { + continue; + } + let path = std::path::PathBuf::from(first_target); + if !path.exists() || !path.is_file() { + continue; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "webp") { + continue; + } + let name = path.file_name().and_then(|f| f.to_str()).unwrap_or(&entry.title).to_owned(); + let lower_title = entry.title.to_ascii_lowercase(); + let lower_name = name.to_ascii_lowercase(); + let is_shot = lower_title.contains("screenshot") + || lower_name.contains("screenshot") + || entry.detail.to_ascii_lowercase().contains("screenshot"); + let obj = serde_json::json!({ + "id": path.to_string_lossy(), + "name": name, + "date": entry.timestamp, + "thumbUrl": crate::icons::file_url(&path), + "localPath": path.to_string_lossy(), + }); + if is_shot { + if entry.timestamp >= latest_screenshot_ts { + latest_screenshot_ts = entry.timestamp; + latest_screenshot = Some(obj); + } + } else if entry.timestamp >= latest_photo_ts { + latest_photo_ts = entry.timestamp; + latest_photo = Some(obj); + } } - } else if entry.timestamp >= latest_photo_ts { - latest_photo_ts = entry.timestamp; - latest_photo = Some(obj); } } } @@ -2759,37 +2802,38 @@ fn compute_shelf_items_json(current_device_idx: i32) -> String { } } - // 4. Current clip (from encrypted local clipboard history, or live Windows clipboard fallback). - let current_clip = if let Some(entry) = - node.as_ref().and_then(|n| n.clipboard_history(None).into_iter().next()) - { - let (kind, image_data_url) = match entry.kind { - ClipboardItemKind::Text => ("text", String::new()), - ClipboardItemKind::Image => { - ("image", crate::clipboard::thumb_for_clip(&entry.id).unwrap_or_default()) + // 4. Current clip (from encrypted local clipboard history, or live OS clipboard fallback). + let current_clip = + if let Some(entry) = node.as_ref().and_then(|n| n.clipboard_history(None).into_iter().next()) { + let (kind, image_data_url) = match entry.kind { + ClipboardItemKind::Text => ("text", String::new()), + ClipboardItemKind::Image => { + ("image", crate::clipboard::thumb_for_clip(&entry.id).unwrap_or_default()) + } + }; + serde_json::json!({ + "id": entry.id, + "kind": kind, + "text": entry.text, + "imageDataUrl": image_data_url, + "deviceName": entry.device_name, + "localPath": "", + }) + } else { + match crate::win::clipboard::read() { + crate::win::clipboard::Clip::Text { text: t, .. } if !t.trim().is_empty() => { + serde_json::json!({ + "id": "", + "kind": "text", + "text": t, + "imageDataUrl": "", + "deviceName": "This PC", + "localPath": "", + }) + } + _ => serde_json::Value::Null, } }; - serde_json::json!({ - "id": entry.id, - "kind": kind, - "text": entry.text, - "imageDataUrl": image_data_url, - "deviceName": entry.device_name, - "localPath": "", - }) - } else { - match crate::win::clipboard::read() { - crate::win::clipboard::Clip::Text { text: t, .. } if !t.trim().is_empty() => serde_json::json!({ - "id": "", - "kind": "text", - "text": t, - "imageDataUrl": "", - "deviceName": "This PC", - "localPath": "", - }), - _ => serde_json::Value::Null, - } - }; // 5. Recent received files (up to 5 existing files on disk). let mut recent_files: Vec = Vec::new(); @@ -2928,6 +2972,14 @@ struct HomeDeviceSummary { photo_thumb: String, photo_is_video: bool, photo_is_screenshot: bool, + latest_photo_id: String, + latest_photo_name: String, + latest_photo_date: i64, + latest_photo_thumb: String, + screenshot_id: String, + screenshot_name: String, + screenshot_date: i64, + screenshot_thumb: String, } #[derive(Debug, Default)] @@ -3079,9 +3131,20 @@ pub(crate) fn on_home_event(event: &nectarlink_core::NodeEvent) { entry.photo_id.clone_from(&photo.id); entry.photo_name.clone_from(&photo.name); entry.photo_date = photo.taken; - entry.photo_thumb = thumb_url; + entry.photo_thumb.clone_from(&thumb_url); entry.photo_is_video = photo.id.starts_with("video:"); entry.photo_is_screenshot = photo.screenshot; + if photo.screenshot { + entry.screenshot_id.clone_from(&photo.id); + entry.screenshot_name.clone_from(&photo.name); + entry.screenshot_date = photo.taken; + entry.screenshot_thumb = thumb_url; + } else { + entry.latest_photo_id.clone_from(&photo.id); + entry.latest_photo_name.clone_from(&photo.name); + entry.latest_photo_date = photo.taken; + entry.latest_photo_thumb = thumb_url; + } }); bump_home_summary(); } @@ -3137,7 +3200,7 @@ fn spawn_home_photo_fetch(device: DeviceId) { return; }; core_host::spawn(async move { - let Ok(items) = node.photo_list(device, None, None, 1).await else { + let Ok(items) = node.photo_list(device, None, None, 25).await else { home_store(|s| { if let Some(d) = s.devices.get_mut(&device) { d.photo_busy = false; @@ -3145,7 +3208,15 @@ fn spawn_home_photo_fetch(device: DeviceId) { }); return; }; - let Some(item) = items.into_iter().next() else { + let is_item_screenshot = |it: &nectarlink_core::PhotoItem| -> bool { + it.name.to_ascii_lowercase().contains("screenshot") + || it.album.as_deref().is_some_and(|a| a.to_ascii_lowercase().contains("screenshot")) + }; + let first_item = items.first().cloned(); + let latest_non_shot = items.iter().find(|it| !is_item_screenshot(it)).cloned(); + let latest_shot = items.iter().find(|it| is_item_screenshot(it)).cloned(); + + let Some(item) = first_item else { home_store(|s| { let d = s.devices.entry(device).or_default(); d.photo_loaded = true; @@ -3154,23 +3225,50 @@ fn spawn_home_photo_fetch(device: DeviceId) { d.photo_name.clear(); d.photo_date = 0; d.photo_thumb.clear(); + d.latest_photo_id.clear(); + d.latest_photo_name.clear(); + d.latest_photo_date = 0; + d.latest_photo_thumb.clear(); + d.screenshot_id.clear(); + d.screenshot_name.clear(); + d.screenshot_date = 0; + d.screenshot_thumb.clear(); }); bump_home_summary(); return; }; - let mut disk_path = crate::photos::thumb_path(device, &item.id); - if !disk_path.exists() - && let Ok(thumbs) = node.photo_thumbs(device, vec![item.id.clone()]).await - && let Some(first) = thumbs.into_iter().next() - && let Some(saved) = crate::photos::write_thumb(device, &first.id, &first.data) + + let mut needed_ids = Vec::new(); + for cand in [Some(&item), latest_non_shot.as_ref(), latest_shot.as_ref()].into_iter().flatten() { + if !crate::photos::thumb_path(device, &cand.id).exists() && !needed_ids.contains(&cand.id) { + needed_ids.push(cand.id.clone()); + } + } + if !needed_ids.is_empty() + && let Ok(thumbs) = node.photo_thumbs(device, needed_ids).await { - disk_path = saved; + for th in thumbs { + let _ = crate::photos::write_thumb(device, &th.id, &th.data); + } } - let thumb_url = if disk_path.exists() { crate::icons::file_url(&disk_path) } else { String::new() }; - let lower_name = item.name.to_ascii_lowercase(); - let is_screenshot = lower_name.contains("screenshot") - || item.album.as_deref().is_some_and(|a| a.to_ascii_lowercase().contains("screenshot")); + + let thumb_url_for = |id: &str| -> String { + let p = crate::photos::thumb_path(device, id); + if p.exists() { crate::icons::file_url(&p) } else { String::new() } + }; + + let thumb_url = thumb_url_for(&item.id); + let is_screenshot = is_item_screenshot(&item); let is_video = item.duration.is_some() || item.id.starts_with("video:"); + let non_shot_tuple = latest_non_shot.map(|it| { + let u = thumb_url_for(&it.id); + (it.id, it.name, it.date, u) + }); + let shot_tuple = latest_shot.map(|it| { + let u = thumb_url_for(&it.id); + (it.id, it.name, it.date, u) + }); + home_store(|s| { let d = s.devices.entry(device).or_default(); d.photo_loaded = true; @@ -3181,6 +3279,18 @@ fn spawn_home_photo_fetch(device: DeviceId) { d.photo_thumb = thumb_url; d.photo_is_video = is_video; d.photo_is_screenshot = is_screenshot; + if let Some((id, name, date, thumb)) = non_shot_tuple { + d.latest_photo_id = id; + d.latest_photo_name = name; + d.latest_photo_date = date; + d.latest_photo_thumb = thumb; + } + if let Some((id, name, date, thumb)) = shot_tuple { + d.screenshot_id = id; + d.screenshot_name = name; + d.screenshot_date = date; + d.screenshot_thumb = thumb; + } }); bump_home_summary(); }); diff --git a/desktop/app/src/bridge/media.rs b/desktop/app/src/bridge/media.rs index bae92dba..9ee3c3b0 100644 --- a/desktop/app/src/bridge/media.rs +++ b/desktop/app/src/bridge/media.rs @@ -399,7 +399,10 @@ mod tests { art_key: None, art: None, }, + #[cfg(windows)] art: Some(PathBuf::from(r"C:\data\cache\art\a.jpg")), + #[cfg(not(windows))] + art: Some(PathBuf::from("/data/cache/art/a.jpg")), at: UNIX_EPOCH + std::time::Duration::from_millis(1_500), }; let get = |role| role_value(&row, role); @@ -410,9 +413,10 @@ 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"; + #[cfg(not(windows))] + let expected_art = "file:///data/cache/art/a.jpg"; + assert_eq!(get("art").value::().map(|s| String::from(&s)), Some(expected_art.into())); } } diff --git a/desktop/app/src/core_host.rs b/desktop/app/src/core_host.rs index 9b85c138..691b32d5 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()) } @@ -360,7 +361,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, diff --git a/desktop/app/src/deck.rs b/desktop/app/src/deck.rs index a969cafb..42ebaa9a 100644 --- a/desktop/app/src/deck.rs +++ b/desktop/app/src/deck.rs @@ -321,7 +321,14 @@ fn execute_action(action: &DeckAction) -> Result<(), String> { Ok(()) } DeckAction::Screenshot => { - win::input::press_key("s", &[KeyMod::Win, KeyMod::Shift]); + #[cfg(target_os = "linux")] + { + let _ = win::shell::take_screenshot(); + } + #[cfg(not(target_os = "linux"))] + { + win::input::press_key("s", &[KeyMod::Win, KeyMod::Shift]); + } Ok(()) } DeckAction::Shortcut { key, mods } => { @@ -512,7 +519,13 @@ pub fn build_action( }; if !action.is_valid() { return Err(match kind { - deck_kinds::LAUNCH_APP => tr!("Deck", "Application path must end with .exe or .lnk."), + deck_kinds::LAUNCH_APP => { + if cfg!(target_os = "linux") { + tr!("Deck", "Enter an application path (/usr/bin/..., .desktop, or .AppImage).") + } else { + tr!("Deck", "Application path must end with .exe or .lnk.") + } + } deck_kinds::OPEN_URL => tr!("Deck", "Web address must start with http:// or https://."), deck_kinds::SHORTCUT => { tr!("Deck", "Use a single key (a-z, 0-9, f1-f12, enter, tab, space, escape, arrows, etc.).") @@ -617,6 +630,12 @@ mod tests { assert_eq!(app, DeckAction::LaunchApp { path: "C:\\Windows\\System32\\calc.exe".into() }); assert!(build_action("launch_app", "C:\\script.bat", false, false, false, false).is_err()); + let linux_bin = build_action("launch_app", "/usr/bin/firefox", false, false, false, false).unwrap(); + assert_eq!(linux_bin, DeckAction::LaunchApp { path: "/usr/bin/firefox".into() }); + let linux_desktop = + build_action("launch_app", "org.mozilla.firefox.desktop", false, false, false, false).unwrap(); + assert_eq!(linux_desktop, DeckAction::LaunchApp { path: "org.mozilla.firefox.desktop".into() }); + let cmd = build_action("run_command", " echo hello ", false, false, false, false).unwrap(); assert_eq!(cmd, DeckAction::RunCommand { command: "echo hello".into() }); } diff --git a/desktop/app/src/linux/audio.rs b/desktop/app/src/linux/audio.rs new file mode 100644 index 00000000..b87dd97d --- /dev/null +++ b/desktop/app/src/linux/audio.rs @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Linux speaker volume, mute, output device enumeration, microphone mute +//! state, and event-driven change notifications via PipeWire (`wpctl`) and +//! PulseAudio (`pactl`). + +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock, mpsc}; +use std::time::Duration; + +use nectarlink_core::AudioOutputDevice; + +static FALLBACK_SPEAKER: Mutex<(u8, bool)> = Mutex::new((50, false)); +static FALLBACK_MIC_MUTED: Mutex = Mutex::new(false); +static WATCH_STARTED: OnceLock<()> = OnceLock::new(); + +/// Parses `wpctl get-volume` output such as `"Volume: 0.45"` or +/// `"Volume: 0.45 [MUTED]"` into `(volume_percent, muted)`. +pub(crate) fn parse_wpctl_volume(output: &str) -> Option<(u8, bool)> { + let muted = output.contains("[MUTED]"); + let after_colon = output.split_once("Volume:")?.1; + let vol_token = after_colon.split_whitespace().next()?; + let val = vol_token.parse::().ok()?; + if !val.is_finite() { + return None; + } + let pct = (val * 100.0).round().clamp(0.0, 100.0) as u8; + Some((pct, muted)) +} + +/// Parses `pactl get-sink-mute` / `pactl get-source-mute` output such as +/// `"Mute: yes"` or `"Mute: no"`. +pub(crate) fn parse_pactl_mute(mute_out: &str) -> Option { + let rhs = mute_out.split_once(':').map(|(_, r)| r).unwrap_or(mute_out).trim(); + if rhs.eq_ignore_ascii_case("yes") || rhs.eq_ignore_ascii_case("true") || rhs == "1" { + Some(true) + } else if rhs.eq_ignore_ascii_case("no") || rhs.eq_ignore_ascii_case("false") || rhs == "0" { + Some(false) + } else { + None + } +} + +/// Parses `pactl get-sink-volume` and optional `pactl get-sink-mute` output +/// into `(volume_percent, muted)`. +pub(crate) fn parse_pactl_volume_and_mute(vol_out: &str, mute_out: Option<&str>) -> Option<(u8, bool)> { + let pct_token = vol_out.split_whitespace().find_map(|tok| tok.strip_suffix('%'))?; + let pct_val = pct_token.trim().parse::().ok()?; + if !pct_val.is_finite() { + return None; + } + let pct = pct_val.round().clamp(0.0, 100.0) as u8; + let muted = mute_out.and_then(parse_pactl_mute).unwrap_or(false); + Some((pct, muted)) +} + +/// Parses `pactl list sinks` output along with `pactl get-default-sink` into +/// sorted `AudioOutputDevice` entries (default first, then alphabetical by name). +pub(crate) fn parse_pactl_sinks(list_out: &str, default_sink: Option<&str>) -> Vec { + let default_trimmed = default_sink.map(str::trim).filter(|s| !s.is_empty()); + let mut out = Vec::new(); + let mut current_id: Option = None; + let mut current_desc: Option = None; + + let flush = |id: &mut Option, desc: &mut Option, out: &mut Vec| { + if let Some(sink_id) = id.take() { + let name = desc.take().filter(|d| !d.is_empty()).unwrap_or_else(|| sink_id.clone()); + let is_default = default_trimmed == Some(sink_id.as_str()); + out.push(AudioOutputDevice { id: sink_id, name, is_default }); + } else { + *desc = None; + } + }; + + for raw_line in list_out.lines() { + let line = raw_line.trim(); + if line.starts_with("Sink #") { + flush(&mut current_id, &mut current_desc, &mut out); + } else if let Some(rest) = line.strip_prefix("Name:") { + let id = rest.trim(); + if !id.is_empty() && current_id.is_none() { + current_id = Some(id.to_owned()); + } + } else if let Some(rest) = line.strip_prefix("Description:") { + let desc = rest.trim(); + if !desc.is_empty() && current_desc.is_none() { + current_desc = Some(desc.to_owned()); + } + } + } + flush(&mut current_id, &mut current_desc, &mut out); + + out.sort_by(|a, b| b.is_default.cmp(&a.is_default).then_with(|| a.name.cmp(&b.name))); + out +} + +/// Returns `true` when a `pactl subscribe` event line indicates a sink, source, +/// or server state/device change (ignoring per-stream `sink-input` / `source-output` noise). +pub(crate) fn parse_pactl_subscribe_line(line: &str) -> bool { + let trimmed = line.trim(); + if !trimmed.starts_with("Event '") { + return false; + } + (trimmed.contains(" on sink #") + || trimmed.ends_with(" on sink") + || trimmed.contains(" on source #") + || trimmed.ends_with(" on source") + || trimmed.contains(" on server #") + || trimmed.ends_with(" on server")) + && !trimmed.contains("sink-input") + && !trimmed.contains("source-output") +} + +/// Reads the default speaker endpoint's `(volume_percent, muted)`. +pub fn speaker_state() -> Option<(u8, bool)> { + if let Ok(out) = Command::new("wpctl").args(["get-volume", "@DEFAULT_AUDIO_SINK@"]).output() + && out.status.success() + && let Some(state) = parse_wpctl_volume(&String::from_utf8_lossy(&out.stdout)) + { + if let Ok(mut guard) = FALLBACK_SPEAKER.lock() { + *guard = state; + } + return Some(state); + } + + if let Ok(vol_out) = Command::new("pactl").args(["get-sink-volume", "@DEFAULT_SINK@"]).output() + && vol_out.status.success() + { + let mute_out = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + if let Some(state) = + parse_pactl_volume_and_mute(&String::from_utf8_lossy(&vol_out.stdout), mute_out.as_deref()) + { + if let Ok(mut guard) = FALLBACK_SPEAKER.lock() { + *guard = state; + } + return Some(state); + } + } + + Some(*FALLBACK_SPEAKER.lock().ok()?) +} + +/// Sets the default speaker endpoint's master volume (`0..=100`). +pub fn set_speaker_volume(level: u8) -> Result<(), String> { + let clamped = level.min(100); + if let Ok(mut guard) = FALLBACK_SPEAKER.lock() { + guard.0 = clamped; + } + let arg = format!("{clamped}%"); + let _ = Command::new("wpctl") + .args(["set-volume", "@DEFAULT_AUDIO_SINK@", &arg]) + .status() + .and_then(|s| if s.success() { Ok(s) } else { Err(std::io::Error::other("wpctl failed")) }) + .or_else(|_| Command::new("pactl").args(["set-sink-volume", "@DEFAULT_SINK@", &arg]).status()); + Ok(()) +} + +/// Sets the default speaker endpoint's mute state. +pub fn set_speaker_mute(muted: bool) -> Result<(), String> { + if let Ok(mut guard) = FALLBACK_SPEAKER.lock() { + guard.1 = muted; + } + let flag = if muted { "1" } else { "0" }; + let _ = Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", flag]) + .status() + .and_then(|s| if s.success() { Ok(s) } else { Err(std::io::Error::other("wpctl failed")) }) + .or_else(|_| Command::new("pactl").args(["set-sink-mute", "@DEFAULT_SINK@", flag]).status()); + Ok(()) +} + +/// Steps the default speaker volume up by one increment (2%) and unmutes if muted. +pub fn speaker_volume_up() -> Result<(u8, bool), String> { + let (current, muted) = speaker_state().unwrap_or((50, false)); + let next = current.saturating_add(2).min(100); + set_speaker_volume(next)?; + if muted { + set_speaker_mute(false)?; + } + Ok((next, false)) +} + +/// Steps the default speaker volume down by one increment (2%). +pub fn speaker_volume_down() -> Result<(u8, bool), String> { + let (current, muted) = speaker_state().unwrap_or((50, false)); + let next = current.saturating_sub(2); + set_speaker_volume(next)?; + Ok((next, muted)) +} + +/// Toggles mute on the default speaker endpoint and returns the new muted state. +pub fn toggle_speaker_mute() -> Result { + let (_, muted) = speaker_state().unwrap_or((50, false)); + let next = !muted; + set_speaker_mute(next)?; + Ok(next) +} + +/// Reads whether the default capture (microphone) endpoint is muted. +pub fn mic_muted() -> Option { + if let Ok(out) = Command::new("wpctl").args(["get-volume", "@DEFAULT_AUDIO_SOURCE@"]).output() + && out.status.success() + && let Some((_, muted)) = parse_wpctl_volume(&String::from_utf8_lossy(&out.stdout)) + { + if let Ok(mut guard) = FALLBACK_MIC_MUTED.lock() { + *guard = muted; + } + return Some(muted); + } + + if let Ok(out) = Command::new("pactl").args(["get-source-mute", "@DEFAULT_SOURCE@"]).output() + && out.status.success() + && let Some(muted) = parse_pactl_mute(&String::from_utf8_lossy(&out.stdout)) + { + if let Ok(mut guard) = FALLBACK_MIC_MUTED.lock() { + *guard = muted; + } + return Some(muted); + } + + Some(*FALLBACK_MIC_MUTED.lock().ok()?) +} + +/// Toggles mute on the default capture (microphone) endpoint and returns the new muted state. +pub fn toggle_mic_mute() -> Result { + let current = mic_muted().unwrap_or(false); + let next = !current; + if let Ok(mut guard) = FALLBACK_MIC_MUTED.lock() { + *guard = next; + } + let flag = if next { "1" } else { "0" }; + let _ = Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SOURCE@", flag]) + .status() + .and_then(|s| if s.success() { Ok(s) } else { Err(std::io::Error::other("wpctl failed")) }) + .or_else(|_| Command::new("pactl").args(["set-source-mute", "@DEFAULT_SOURCE@", flag]).status()); + Ok(next) +} + +/// Lists active audio output sinks on this PC, marking the current default +/// output (`is_default: true`) and sorting the default first. +pub fn output_devices() -> Vec { + let default_sink = Command::new("pactl") + .arg("get-default-sink") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) + .filter(|s| !s.is_empty()); + + if let Ok(list_out) = Command::new("pactl").args(["list", "sinks"]).output() + && list_out.status.success() + { + let parsed = parse_pactl_sinks(&String::from_utf8_lossy(&list_out.stdout), default_sink.as_deref()); + if !parsed.is_empty() { + return parsed; + } + } + + vec![AudioOutputDevice { + id: "default".to_string(), + name: "Default Output".to_string(), + is_default: true, + }] +} + +/// Switches the system default output sink to `id`. +pub fn set_default_output_device(id: &str) -> Result<(), String> { + let trimmed = id.trim(); + if trimmed.is_empty() { + return Err("output device id cannot be empty".to_string()); + } + let status = Command::new("pactl") + .args(["set-default-sink", trimmed]) + .status() + .and_then(|s| if s.success() { Ok(s) } else { Err(std::io::Error::other("pactl failed")) }) + .or_else(|_| Command::new("wpctl").args(["set-default", trimmed]).status()) + .map_err(|e| format!("failed to set default output device: {e}"))?; + + if status.success() { Ok(()) } else { Err(format!("audio backend rejected default sink '{trimmed}'")) } +} + +/// Subscribes to PipeWire/PulseAudio sink/source/server events and invokes +/// `changed` after coalescing rapid event bursts over 80 ms. +pub fn watch(changed: fn()) { + if WATCH_STARTED.set(()).is_err() { + return; + } + let _ = std::thread::Builder::new().name("audio-watch".into()).spawn(move || watch_thread(changed)); +} + +fn watch_thread(changed: fn()) { + let Ok(mut child) = + Command::new("pactl").arg("subscribe").stdout(Stdio::piped()).stderr(Stdio::null()).spawn() + else { + return; + }; + let Some(stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return; + }; + + let (tx, rx) = mpsc::channel::<()>(); + let _ = std::thread::Builder::new().name("audio-watch-reader".into()).spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if parse_pactl_subscribe_line(&line) && tx.send(()).is_err() { + break; + } + } + }); + + while rx.recv().is_ok() { + while rx.recv_timeout(Duration::from_millis(80)).is_ok() {} + changed(); + } + + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_wpctl_volume_handles_normal_and_muted_lines() { + assert_eq!(parse_wpctl_volume("Volume: 0.45\n"), Some((45, false))); + assert_eq!(parse_wpctl_volume("Volume: 0.72 [MUTED]\n"), Some((72, true))); + assert_eq!(parse_wpctl_volume("Volume: 1.35"), Some((100, false))); + assert_eq!(parse_wpctl_volume("garbage"), None); + } + + #[test] + fn parse_pactl_volume_and_mute_extracts_percentage_and_mute() { + let vol = "Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 32768 / 50% / -18.06 dB\n"; + assert_eq!(parse_pactl_volume_and_mute(vol, Some("Mute: no\n")), Some((50, false))); + assert_eq!(parse_pactl_volume_and_mute(vol, Some("Mute: yes\n")), Some((50, true))); + assert_eq!(parse_pactl_volume_and_mute("invalid output", None), None); + } + + #[test] + fn parse_pactl_sinks_parses_and_sorts_default_first() { + let sample = "\ +Sink #41 +\tState: RUNNING +\tName: alsa_output.pci-0000_00_1f.3.analog-stereo +\tDescription: Built-in Audio Analog Stereo +\tVolume: front-left: 40000 / 61% + +Sink #52 +\tState: IDLE +\tName: bluez_output.AA_BB_CC_DD_EE_FF.1 +\tDescription: Pixel Buds Pro +"; + let devices = parse_pactl_sinks(sample, Some("bluez_output.AA_BB_CC_DD_EE_FF.1")); + assert_eq!(devices.len(), 2); + assert_eq!(devices[0].id, "bluez_output.AA_BB_CC_DD_EE_FF.1"); + assert_eq!(devices[0].name, "Pixel Buds Pro"); + assert!(devices[0].is_default); + assert_eq!(devices[1].id, "alsa_output.pci-0000_00_1f.3.analog-stereo"); + assert_eq!(devices[1].name, "Built-in Audio Analog Stereo"); + assert!(!devices[1].is_default); + } + + #[test] + fn parse_pactl_subscribe_line_filters_relevant_events() { + assert!(parse_pactl_subscribe_line("Event 'change' on sink #0")); + assert!(parse_pactl_subscribe_line("Event 'change' on source #2")); + assert!(parse_pactl_subscribe_line("Event 'change' on server #0")); + assert!(!parse_pactl_subscribe_line("Event 'change' on sink-input #44")); + assert!(!parse_pactl_subscribe_line("Event 'change' on source-output #12")); + assert!(!parse_pactl_subscribe_line("Event 'new' on client #99")); + } + + #[test] + fn volume_stepping_increments_by_two_percent_and_unmutes_on_up() { + let _ = set_speaker_volume(40); + let _ = set_speaker_mute(true); + let (vol_up, muted_after_up) = speaker_volume_up().unwrap(); + assert!(!muted_after_up); + assert!(vol_up <= 100); + + let (vol_down, _) = speaker_volume_down().unwrap(); + assert!(vol_down <= 100); + } +} diff --git a/desktop/app/src/linux/audio_out.rs b/desktop/app/src/linux/audio_out.rs new file mode 100644 index 00000000..27878ca3 --- /dev/null +++ b/desktop/app/src/linux/audio_out.rs @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Low-latency 16-bit signed little-endian PCM playback on Linux via +//! PulseAudio (`pacat`), PipeWire (`pw-play`), or ALSA (`aplay`), backed by an +//! adaptive jitter buffer with smooth sample interpolation. + +use std::collections::VecDeque; +use std::io::{Result, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, SyncSender, TrySendError}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +const BYTES_PER_SAMPLE: usize = 2; +const JITTER_START_MS: usize = 40; +const JITTER_LOW_MS: usize = 20; +const JITTER_TARGET_MS: usize = 80; +const JITTER_HIGH_MS: usize = 160; +const JITTER_MAX_MS: usize = 220; + +/// Linearly interpolates two interleaved `s16le` frames (`a` and `b`) into `out`. +pub(crate) fn interpolate_frame_s16le(a: &[u8], b: &[u8], out: &mut [u8]) { + let len = a.len().min(b.len()).min(out.len()) & !1; + for i in (0..len).step_by(2) { + let sa = i16::from_le_bytes([a[i], a[i + 1]]); + let sb = i16::from_le_bytes([b[i], b[i + 1]]); + let mid = ((i32::from(sa) + i32::from(sb)) / 2) as i16; + let bytes = mid.to_le_bytes(); + out[i] = bytes[0]; + out[i + 1] = bytes[1]; + } +} + +#[derive(Debug)] +struct Jitter { + pcm: VecDeque, + frame_bytes: usize, + start_bytes: usize, + low_bytes: usize, + target_bytes: usize, + high_bytes: usize, + max_bytes: usize, + primed: bool, + last_frame: Vec, +} + +impl Jitter { + fn new(rate: u32, channels: u16) -> Self { + let frame_bytes = (channels as usize).max(1) * BYTES_PER_SAMPLE; + let bytes_per_ms = ((rate as usize) * frame_bytes / 1000).max(frame_bytes); + let align = |ms: usize| { + let raw = (ms * bytes_per_ms).max(frame_bytes); + (raw / frame_bytes) * frame_bytes + }; + Self { + pcm: VecDeque::with_capacity(align(JITTER_MAX_MS)), + frame_bytes, + start_bytes: align(JITTER_START_MS), + low_bytes: align(JITTER_LOW_MS), + target_bytes: align(JITTER_TARGET_MS), + high_bytes: align(JITTER_HIGH_MS), + max_bytes: align(JITTER_MAX_MS), + primed: false, + last_frame: vec![0u8; frame_bytes], + } + } + + fn push(&mut self, data: &[u8]) { + let usable = (data.len() / self.frame_bytes) * self.frame_bytes; + if usable == 0 { + return; + } + self.pcm.extend(&data[..usable]); + if self.pcm.len() > self.max_bytes { + let drop = ((self.pcm.len() - self.target_bytes) / self.frame_bytes) * self.frame_bytes; + self.pcm.drain(..drop.min(self.pcm.len())); + } + if self.pcm.len() >= self.start_bytes { + self.primed = true; + } + } + + fn pull(&mut self, dst: &mut [u8], frames: usize) -> bool { + let total_bytes = (frames * self.frame_bytes).min(dst.len()); + if !self.primed { + dst[..total_bytes].fill(0); + return false; + } + + if self.pcm.len() > self.high_bytes && frames >= 8 { + for _ in 0..self.frame_bytes { + let _ = self.pcm.pop_front(); + } + } + + let available_frames = self.pcm.len() / self.frame_bytes; + if available_frames == 0 { + self.primed = false; + self.last_frame.fill(0); + dst[..total_bytes].fill(0); + return false; + } + + let stretch = self.pcm.len() < self.low_bytes && frames >= 16; + let mut written = 0usize; + let mut produced_frames = 0usize; + let mut next_peek = vec![0u8; self.frame_bytes]; + + while produced_frames < frames && written + self.frame_bytes <= total_bytes { + if self.pcm.len() >= self.frame_bytes { + for b in &mut self.last_frame { + *b = self.pcm.pop_front().unwrap_or(0); + } + dst[written..written + self.frame_bytes].copy_from_slice(&self.last_frame); + written += self.frame_bytes; + produced_frames += 1; + + if stretch && produced_frames == frames / 2 && written + self.frame_bytes <= total_bytes { + if self.pcm.len() >= self.frame_bytes { + for (i, b) in next_peek.iter_mut().enumerate() { + *b = self.pcm.get(i).copied().unwrap_or(0); + } + interpolate_frame_s16le( + &self.last_frame, + &next_peek, + &mut dst[written..written + self.frame_bytes], + ); + } else { + dst[written..written + self.frame_bytes].copy_from_slice(&self.last_frame); + } + written += self.frame_bytes; + produced_frames += 1; + } + } else { + self.primed = false; + dst[written..total_bytes].fill(0); + break; + } + } + true + } +} + +fn spawn_pcm_sink(rate: u32, channels: u16, device_id: Option<&str>) -> Option { + let rate_s = rate.to_string(); + let ch_s = channels.to_string(); + let dev = device_id.map(str::trim).filter(|s| !s.is_empty() && *s != "default"); + + let mut pacat = Command::new("pacat"); + pacat.args([ + "--playback", + "--raw", + "--format=s16le", + &format!("--rate={rate_s}"), + &format!("--channels={ch_s}"), + "--latency-msec=20", + ]); + if let Some(d) = dev { + pacat.arg(format!("--device={d}")); + } + if let Ok(child) = pacat.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + return Some(child); + } + + let mut pw = Command::new("pw-play"); + pw.args(["--raw", "--format=s16", &format!("--rate={rate_s}"), &format!("--channels={ch_s}")]); + if let Some(d) = dev { + pw.arg(format!("--target={d}")); + } + pw.arg("-"); + if let Ok(child) = pw.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + return Some(child); + } + + Command::new("aplay") + .args(["-q", "-t", "raw", "-f", "S16_LE", "-r", &rate_s, "-c", &ch_s, "--buffer-size=1920", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() +} + +#[derive(Debug)] +pub struct Player { + tx: SyncSender>, + stop: Arc, + handle: Option>, +} + +impl Player { + pub fn start(rate: u32, channels: u16) -> Result { + Self::start_for_device(rate, channels, None) + } + + pub fn start_on(device: Option, rate: u32, channels: u16) -> Result { + Self::start_for_device(rate, channels, device.as_deref()) + } + + pub fn start_for_device(rate: u32, channels: u16, device_id: Option<&str>) -> Result { + let (tx, rx) = mpsc::sync_channel::>(64); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let clean_rate = rate.max(8_000); + let clean_channels = channels.max(1); + let dev_owned = device_id.map(str::to_owned); + + let handle = thread::Builder::new().name("nectarlink-audio-out".into()).spawn(move || { + let mut jitter = Jitter::new(clean_rate, clean_channels); + let frame_bytes = jitter.frame_bytes; + let frames_per_tick = ((clean_rate as usize) / 100).max(1); + let mut scratch = vec![0u8; frames_per_tick * frame_bytes]; + let mut child = spawn_pcm_sink(clean_rate, clean_channels, dev_owned.as_deref()); + + while !thread_stop.load(Ordering::Acquire) { + while let Ok(chunk) = rx.try_recv() { + jitter.push(&chunk); + } + let has_audio = jitter.pull(&mut scratch, frames_per_tick); + if has_audio + && let Some(proc) = child.as_mut() + && let Some(stdin) = proc.stdin.as_mut() + && stdin.write_all(&scratch).is_err() + { + let _ = proc.kill(); + let _ = proc.wait(); + child = spawn_pcm_sink(clean_rate, clean_channels, dev_owned.as_deref()); + } + thread::sleep(Duration::from_millis(10)); + } + + if let Some(mut proc) = child { + drop(proc.stdin.take()); + let _ = proc.kill(); + let _ = proc.wait(); + } + })?; + + Ok(Player { tx, stop, handle: Some(handle) }) + } + + pub fn push(&self, data: Vec) { + if data.is_empty() { + return; + } + match self.tx.try_send(data) { + Ok(()) | Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {} + } + } +} + +impl Drop for Player { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jitter_waits_for_prime_threshold_before_emitting_audio() { + let mut jitter = Jitter::new(48_000, 2); + let mut out = vec![0xFFu8; 480 * 4]; + jitter.push(&vec![0x11u8; 480 * 4]); + assert!(!jitter.pull(&mut out, 480)); + assert!(out.iter().all(|&b| b == 0)); + + jitter.push(&vec![0x22u8; 480 * 4 * 4]); + assert!(jitter.pull(&mut out, 480)); + assert!(out.iter().any(|&b| b != 0)); + } + + #[test] + fn jitter_caps_overflow_to_target_depth() { + let mut jitter = Jitter::new(48_000, 2); + jitter.push(&vec![0x33u8; jitter.max_bytes + 4096]); + assert!(jitter.pcm.len() <= jitter.max_bytes); + assert!(jitter.pcm.len() >= jitter.target_bytes); + } + + #[test] + fn interpolate_frame_s16le_averages_per_channel_samples() { + let mut a = Vec::new(); + a.extend_from_slice(&1000i16.to_le_bytes()); + a.extend_from_slice(&(-2000i16).to_le_bytes()); + + let mut b = Vec::new(); + b.extend_from_slice(&3000i16.to_le_bytes()); + b.extend_from_slice(&(-1000i16).to_le_bytes()); + + let mut out = [0u8; 4]; + interpolate_frame_s16le(&a, &b, &mut out); + assert_eq!(i16::from_le_bytes([out[0], out[1]]), 2000); + assert_eq!(i16::from_le_bytes([out[2], out[3]]), -1500); + } + + #[test] + fn player_starts_accepts_pushes_and_drops_cleanly() { + let player = Player::start(48_000, 2).expect("player should start"); + player.push(vec![0u8; 1920]); + drop(player); + } +} diff --git a/desktop/app/src/linux/autostart.rs b/desktop/app/src/linux/autostart.rs new file mode 100644 index 00000000..28a3ab04 --- /dev/null +++ b/desktop/app/src/linux/autostart.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! XDG autostart management (`~/.config/autostart/nectarlink.desktop`) on Linux. + +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +const DESKTOP_FILE_NAME: &str = "nectarlink.desktop"; + +/// The command line written into the autostart entry's `Exec=` line. +pub fn command(exe: &Path) -> String { + format!("\"{}\" --minimized", exe.display()) +} + +fn autostart_file_path_in(config_dir: &Path) -> PathBuf { + config_dir.join("autostart").join(DESKTOP_FILE_NAME) +} + +fn autostart_file_path() -> io::Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no XDG config directory"))?; + Ok(autostart_file_path_in(&config_dir)) +} + +fn render_desktop_entry(exe: &Path) -> String { + let cmd = command(exe); + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=NectarLink\n\ + Comment=Link your Android phone and PC\n\ + Exec={cmd}\n\ + Icon=nectarlink\n\ + Terminal=false\n\ + Categories=Utility;Network;\n\ + StartupNotify=false\n\ + X-GNOME-Autostart-enabled=true\n" + ) +} + +fn set_in(config_dir: &Path, enabled: bool, exe: &Path) -> io::Result<()> { + let path = autostart_file_path_in(config_dir); + if enabled { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, render_desktop_entry(exe)) + } else { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } +} + +fn is_enabled_in(config_dir: &Path) -> bool { + let path = autostart_file_path_in(config_dir); + let Ok(contents) = fs::read_to_string(path) else { + return false; + }; + !contents.lines().any(|line| { + let t = line.trim(); + t.eq_ignore_ascii_case("Hidden=true") || t.eq_ignore_ascii_case("X-GNOME-Autostart-enabled=false") + }) +} + +/// Adds or removes the XDG autostart `.desktop` entry for `exe`. +pub fn set(enabled: bool, exe: &Path) -> io::Result<()> { + let path = autostart_file_path()?; + if let Some(config_dir) = path.parent().and_then(Path::parent) { + set_in(config_dir, enabled, exe) + } else { + Err(io::Error::new(io::ErrorKind::NotFound, "invalid XDG autostart path")) + } +} + +/// Alias for checking whether the XDG autostart entry is currently present and enabled. +pub fn is_enabled() -> bool { + dirs::config_dir().as_deref().is_some_and(is_enabled_in) +} + +/// Alias for enabling or disabling XDG autostart for the current executable. +pub fn set_enabled(enabled: bool) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + set(enabled, &exe).map_err(|e| e.to_string()) +} + +/// Whether `exe` is installed in a standard Linux application location (rather +/// than run directly from a `target/` build directory). +pub fn is_installed(exe: &Path) -> bool { + if std::env::var_os("APPIMAGE").is_some() { + return true; + } + let s = exe.to_string_lossy(); + if s.contains("/target/debug/") || s.contains("/target/release/") { + return false; + } + exe.starts_with("/usr/bin") + || exe.starts_with("/usr/local/bin") + || exe.starts_with("/opt") + || dirs::data_local_dir().is_some_and(|d| exe.starts_with(d)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_and_removes_xdg_autostart_desktop_file() { + let dir = tempfile::tempdir().unwrap(); + let exe = Path::new("/usr/bin/nectarlink-desktop"); + + assert!(!is_enabled_in(dir.path())); + set_in(dir.path(), true, exe).unwrap(); + assert!(is_enabled_in(dir.path())); + + let desktop = fs::read_to_string(autostart_file_path_in(dir.path())).unwrap(); + assert!(desktop.contains("Exec=\"/usr/bin/nectarlink-desktop\" --minimized")); + + set_in(dir.path(), false, exe).unwrap(); + assert!(!is_enabled_in(dir.path())); + // Removing again is a no-op. + set_in(dir.path(), false, exe).unwrap(); + } + + #[test] + fn detects_installed_vs_build_paths() { + assert!(is_installed(Path::new("/usr/bin/nectarlink-desktop"))); + assert!(is_installed(Path::new("/opt/nectarlink/nectarlink-desktop"))); + assert!(!is_installed(Path::new("/home/user/repo/target/debug/nectarlink-desktop"))); + assert!(!is_installed(Path::new("/home/user/repo/target/release/nectarlink-desktop"))); + } +} diff --git a/desktop/app/src/linux/ble_beacon.rs b/desktop/app/src/linux/ble_beacon.rs new file mode 100644 index 00000000..f8a39b48 --- /dev/null +++ b/desktop/app/src/linux/ble_beacon.rs @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use std::collections::HashMap; +use std::process::{Child, Command, Stdio}; +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use nectarlink_core::DeviceId; + +pub const COMPANY_ID: u16 = 0xFFFF; +pub const MAGIC: &[u8; 5] = b"NCTR\x01"; +pub const NEAR_RSSI_DBM: i16 = -72; +pub const WALK_AWAY_RSSI_DBM: i16 = -85; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const POLL_STEP: Duration = Duration::from_millis(100); + +static RUNNING: AtomicBool = AtomicBool::new(false); +static WORKER: Mutex>> = Mutex::new(None); + +#[derive(Debug, Default)] +pub struct RssiTracker { + peers: HashMap<[u8; 8], PeerRssiState>, +} + +#[derive(Debug, Clone, Default)] +struct PeerRssiState { + samples: [i16; 4], + count: usize, + idx: usize, + armed_near: bool, +} + +impl RssiTracker { + pub fn record(&mut self, prefix: [u8; 8], rssi: i16) -> bool { + let state = self.peers.entry(prefix).or_default(); + state.samples[state.idx] = rssi; + state.idx = (state.idx + 1) % state.samples.len(); + state.count = (state.count + 1).min(state.samples.len()); + if state.count < state.samples.len() { + return false; + } + let sum: i32 = state.samples.iter().map(|&v| i32::from(v)).sum(); + let avg = (sum / (state.samples.len() as i32)) as i16; + if avg >= NEAR_RSSI_DBM { + state.armed_near = true; + false + } else if state.armed_near && avg <= WALK_AWAY_RSSI_DBM { + state.armed_near = false; + true + } else { + false + } + } +} + +pub fn build_manufacturer_payload(device_id: &DeviceId) -> [u8; 13] { + let mut out = [0u8; 13]; + out[0..5].copy_from_slice(MAGIC); + out[5..13].copy_from_slice(&device_id.as_bytes()[0..8]); + out +} + +/// Validates the `NCTR\x01` magic header and extracts the peer device ID prefix. +/// Accepts either the 13-byte payload (5-byte magic + 8-byte prefix) or a 12-byte +/// payload (5-byte magic + 7-byte prefix, zero-padded in the 8th byte). +pub fn parse_manufacturer_payload(bytes: &[u8]) -> Option<[u8; 8]> { + if bytes.len() < 12 || &bytes[0..5] != MAGIC { + return None; + } + let mut prefix = [0u8; 8]; + if bytes.len() >= 13 { + prefix.copy_from_slice(&bytes[5..13]); + } else { + prefix[0..7].copy_from_slice(&bytes[5..12]); + } + Some(prefix) +} + +pub fn start(device_id: DeviceId) { + if RUNNING.swap(true, Ordering::SeqCst) { + return; + } + let handle = thread::Builder::new().name("ble-beacon".into()).spawn(move || run_worker(device_id)).ok(); + if let Ok(mut guard) = WORKER.lock() { + *guard = handle; + } +} + +pub fn stop() { + RUNNING.store(false, Ordering::SeqCst); + let handle = WORKER.lock().ok().and_then(|mut guard| guard.take()); + if let Some(h) = handle { + let _ = h.join(); + } +} + +fn walk_away_lock_enabled() -> bool { + let rules = crate::settings::automation_rules(); + !crate::automations::matching_rules(&rules, crate::automations::TRIGGER_PHONE_WALKED_AWAY).is_empty() +} + +fn start_bluetoothctl_advertisement(payload: &[u8; 13]) -> Option { + let hex_bytes = payload.iter().map(|b| format!("0x{b:02x}")).collect::>().join(" "); + let mut child = Command::new("bluetoothctl") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + let script = + format!("advertise.manufacturer 0xffff {hex_bytes}\nadvertise.name off\nadvertise on\nscan on\n"); + let _ = stdin.write_all(script.as_bytes()); + let _ = stdin.flush(); + child.stdin = Some(stdin); + } + Some(child) +} + +fn stop_bluetoothctl_advertisement(child: Option) { + let Some(mut child) = child else { return }; + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + let _ = stdin.write_all(b"scan off\nadvertise off\nquit\n"); + let _ = stdin.flush(); + } + let _ = child.kill(); + let _ = child.wait(); +} + +fn is_adapter_powered() -> bool { + if let Ok(out) = Command::new("busctl") + .args(["--system", "get-property", "org.bluez", "/org/bluez/hci0", "org.bluez.Adapter1", "Powered"]) + .output() + && out.status.success() + { + let stdout = String::from_utf8_lossy(&out.stdout); + return stdout.trim().ends_with("true"); + } + if let Ok(out) = Command::new("bluetoothctl").arg("show").output() + && out.status.success() + { + let stdout = String::from_utf8_lossy(&out.stdout); + return stdout.lines().any(|l| { + l.trim().strip_prefix("Powered:").is_some_and(|v| v.trim().eq_ignore_ascii_case("yes")) + }); + } + false +} + +fn fallback_peer_prefix(id_str: &str) -> [u8; 8] { + let mut out = [0u8; 8]; + let bytes = id_str.as_bytes(); + let start = bytes.len().saturating_sub(8); + for (dst, &src) in out.iter_mut().zip(&bytes[start..]) { + *dst = src; + } + out +} + +fn parse_rssi_from_info(info: &str) -> Option { + for line in info.lines() { + let trimmed = line.trim(); + let Some(rest) = trimmed.strip_prefix("RSSI:") else { + continue; + }; + let rest = rest.trim(); + if let Some(open) = rest.find('(') + && let Some(close) = rest[open + 1..].find(')') + && let Ok(val) = rest[open + 1..open + 1 + close].trim().parse::() + { + return Some(val); + } + if let Some(tok) = rest.split_whitespace().next() + && let Ok(val) = tok.parse::() + { + return Some(val); + } + } + None +} + +fn parse_manufacturer_from_info(info: &str) -> Option> { + let mut in_ffff_block = false; + let mut collected = Vec::new(); + for line in info.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("ManufacturerData Key:") { + if !collected.is_empty() { + break; + } + let lower = rest.to_ascii_lowercase(); + in_ffff_block = lower.contains("0xffff") || lower.contains("65535"); + continue; + } + if in_ffff_block && let Some(rest) = trimmed.strip_prefix("ManufacturerData Value:") { + for tok in rest.split_whitespace() { + let hex = tok.strip_prefix("0x").unwrap_or(tok); + if hex.len() == 2 + && let Ok(b) = u8::from_str_radix(hex, 16) + { + collected.push(b); + } else { + break; + } + } + } + } + if collected.is_empty() { None } else { Some(collected) } +} + +fn poll_bluez_devices(own_prefix: &[u8; 8], tracker: &mut RssiTracker) { + let Ok(out) = Command::new("bluetoothctl").arg("devices").output() else { + return; + }; + if !out.status.success() { + return; + } + let devices = super::bt_hfp::parse_bluetoothctl_devices(&String::from_utf8_lossy(&out.stdout)); + for (mac, _) in devices { + let Ok(info_out) = Command::new("bluetoothctl").args(["info", &mac]).output() else { + continue; + }; + if !info_out.status.success() { + continue; + } + let info = String::from_utf8_lossy(&info_out.stdout); + let Some(rssi) = parse_rssi_from_info(&info) else { + continue; + }; + let connected = super::bt_hfp::parse_bluetoothctl_connected(&info); + let paired = info + .lines() + .any(|l| l.trim().strip_prefix("Paired:").is_some_and(|v| v.trim().eq_ignore_ascii_case("yes"))); + + let peer_prefix = if let Some(mfg_bytes) = parse_manufacturer_from_info(&info) + && let Some(prefix) = parse_manufacturer_payload(&mfg_bytes) + { + if &prefix == own_prefix { + continue; + } + Some(prefix) + } else if connected && paired && walk_away_lock_enabled() { + Some(fallback_peer_prefix(&mac)) + } else { + None + }; + + if let Some(prefix) = peer_prefix + && tracker.record(prefix, rssi) + { + tracing::info!("BLE walk-away threshold reached; locking workstation and firing trigger"); + let _ = super::shell::lock_workstation(); + crate::automations::fire_trigger(crate::automations::TRIGGER_PHONE_WALKED_AWAY); + } + } +} + +fn run_worker(device_id: DeviceId) { + if !is_adapter_powered() { + tracing::debug!("BLE beacon: /org/bluez/hci0 unavailable or not powered; exiting worker"); + RUNNING.store(false, Ordering::SeqCst); + return; + } + + let payload = build_manufacturer_payload(&device_id); + let mut own_prefix = [0u8; 8]; + own_prefix.copy_from_slice(&device_id.as_bytes()[0..8]); + + let adv_child = start_bluetoothctl_advertisement(&payload); + let mut tracker = RssiTracker::default(); + + while RUNNING.load(Ordering::Relaxed) { + poll_bluez_devices(&own_prefix, &mut tracker); + + let steps = (POLL_INTERVAL.as_millis() / POLL_STEP.as_millis()).max(1); + for _ in 0..steps { + if !RUNNING.load(Ordering::Relaxed) { + break; + } + thread::sleep(POLL_STEP); + } + } + + stop_bluetoothctl_advertisement(adv_child); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_13_byte_manufacturer_payload_with_magic_and_id_prefix() { + let mut raw = [0u8; 32]; + for (i, b) in raw.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(3); + } + let id = DeviceId(raw); + let payload = build_manufacturer_payload(&id); + assert_eq!(payload.len(), 13); + assert_eq!(&payload[0..5], b"NCTR\x01"); + assert_eq!(&payload[5..13], &raw[0..8]); + assert_eq!(COMPANY_ID, 0xFFFF); + } + + #[test] + fn manufacturer_payload_roundtrips_through_parser() { + let mut raw = [0u8; 32]; + for (i, b) in raw.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(0x10); + } + let id = DeviceId(raw); + let payload = build_manufacturer_payload(&id); + let parsed = parse_manufacturer_payload(&payload).expect("valid payload should parse"); + assert_eq!(&parsed[..], &raw[0..8]); + + // Also supports 12-byte payloads (5-byte magic + 7-byte prefix). + let parsed_12 = parse_manufacturer_payload(&payload[0..12]).expect("12-byte payload should parse"); + assert_eq!(&parsed_12[0..7], &raw[0..7]); + assert_eq!(parsed_12[7], 0); + } + + #[test] + fn parser_rejects_short_or_wrong_magic_payloads() { + assert_eq!(parse_manufacturer_payload(b""), None); + assert_eq!(parse_manufacturer_payload(b"NCTR\x01short"), None); + assert_eq!(parse_manufacturer_payload(b"NCTR\x0212345678"), None); + assert_eq!(parse_manufacturer_payload(b"XXXX\x0112345678"), None); + } + + #[test] + fn rssi_tracker_fires_walk_away_only_after_armed_near() { + let mut tracker = RssiTracker::default(); + let peer = [1, 2, 3, 4, 5, 6, 7, 8]; + for _ in 0..4 { + assert!(!tracker.record(peer, -90)); + } + for _ in 0..4 { + assert!(!tracker.record(peer, -65)); + } + assert!(!tracker.record(peer, -88)); + assert!(!tracker.record(peer, -90)); + assert!(!tracker.record(peer, -92)); + assert!(tracker.record(peer, -90)); + assert!(!tracker.record(peer, -92)); + } + + #[test] + fn rssi_tracker_arms_at_near_threshold_and_fires_once_on_walk_away() { + let mut tracker = RssiTracker::default(); + let peer = [9, 8, 7, 6, 5, 4, 3, 2]; + // Exactly at NEAR_RSSI_DBM (-72) for 4 samples arms the tracker. + for _ in 0..4 { + assert!(!tracker.record(peer, NEAR_RSSI_DBM)); + } + // Dropping to WALK_AWAY_RSSI_DBM (-85) across 4 samples triggers once. + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(!tracker.record(peer, WALK_AWAY_RSSI_DBM)); + assert!(tracker.record(peer, WALK_AWAY_RSSI_DBM)); + // Subsequent distant samples do not re-trigger without re-arming near. + for _ in 0..4 { + assert!(!tracker.record(peer, -95)); + } + } + + #[test] + fn start_and_stop_never_panic_when_ble_radio_is_absent_or_present() { + let id = DeviceId([0x42; 32]); + start(id); + stop(); + } + + #[test] + fn start_and_stop_are_idempotent_without_bluez() { + let id = DeviceId([0x24; 32]); + start(id); + start(id); + stop(); + stop(); + } +} diff --git a/desktop/app/src/linux/browser.rs b/desktop/app/src/linux/browser.rs new file mode 100644 index 00000000..a39c48b9 --- /dev/null +++ b/desktop/app/src/linux/browser.rs @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Find the window that owns a media session on Linux, and read the page +//! address and the `