diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cc0faf4..e772e15c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,35 @@ jobs: - name: Release build run: cargo build --locked --release -p nectarlink-desktop + desktop-linux: + name: Linux app (x64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install the pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@v2 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgl1-mesa-dev libxkbcommon-dev libvulkan-dev libx11-dev + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: linux + target: desktop + arch: linux_gcc_64 + cache: true + aqtsource: git+https://github.com/miurahr/aqtinstall.git + - name: Clippy + run: cargo clippy --locked -p nectarlink-desktop -p nectarlink-vcam -p s1-qt-rust --all-targets -- -D warnings + - name: Tests + run: cargo test --locked -p nectarlink-desktop -p nectarlink-vcam + - name: Release build + run: cargo build --locked --release -p nectarlink-desktop -p nectarlink-vcam + android: name: Android app runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 671a8fc7..0bf629b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,6 +2334,7 @@ dependencies = [ "clap", "dirs", "hostname", + "libc", "nectarlink-core", "qrcode", "serde", @@ -2392,6 +2393,7 @@ dependencies = [ "dirs", "embed-resource", "hostname", + "libc", "material-colors", "nectarlink-core", "nectarlink-vcam", @@ -2442,6 +2444,7 @@ version = "0.1.4" dependencies = [ "ab_glyph", "embed-resource", + "libc", "windows", "windows-core", ] diff --git a/core/nectarlink-cli/Cargo.toml b/core/nectarlink-cli/Cargo.toml index fc5c9768..cd694694 100644 --- a/core/nectarlink-cli/Cargo.toml +++ b/core/nectarlink-cli/Cargo.toml @@ -31,6 +31,10 @@ windows = { workspace = true, features = [ "Win32_System_Threading", ] } +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + + [dev-dependencies] tempfile.workspace = true diff --git a/core/nectarlink-cli/src/main.rs b/core/nectarlink-cli/src/main.rs index e07f3504..71693db6 100644 --- a/core/nectarlink-cli/src/main.rs +++ b/core/nectarlink-cli/src/main.rs @@ -5460,7 +5460,7 @@ fn default_command_title(first_token: &str) -> String { } enum TaskTransport { - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] DesktopDir(PathBuf), Standalone { node: Node, @@ -5470,12 +5470,14 @@ enum TaskTransport { impl TaskTransport { async fn send(&self, device_query: Option<&str>, task: &TaskNotify) -> Result<()> { - // Only the desktop app's queue (Windows) picks the phone by name here. - #[cfg(not(windows))] + // Only the desktop app's queue (Windows / Linux) picks the phone by name here. + #[cfg(not(any(windows, target_os = "linux")))] let _ = device_query; match self { #[cfg(windows)] TaskTransport::DesktopDir(dir) => win_watch::queue_desktop_task_notify(dir, device_query, task), + #[cfg(target_os = "linux")] + TaskTransport::DesktopDir(dir) => linux_watch::queue_desktop_task_notify(dir, device_query, task), TaskTransport::Standalone { node, targets } => { let mut sent_any = false; let mut last_err = None; @@ -5496,24 +5498,31 @@ impl TaskTransport { async fn shutdown(self) { match self { TaskTransport::Standalone { node, .. } => node.shutdown().await, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] TaskTransport::DesktopDir(_) => {} } } } async fn connect_task_transport(cli: &Cli, device_query: Option<&str>) -> Result { - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] { let desktop_dir = match &cli.data_dir { Some(dir) => Some(dir.clone()), None => dirs::data_local_dir().map(|d| d.join("Nectarlink")), }; + #[cfg(windows)] if let Some(dir) = desktop_dir && win_watch::is_desktop_running(&dir) { return Ok(TaskTransport::DesktopDir(dir)); } + #[cfg(target_os = "linux")] + if let Some(dir) = desktop_dir + && linux_watch::is_desktop_running(&dir) + { + return Ok(TaskTransport::DesktopDir(dir)); + } } let node = Box::pin(start_node(cli)).await?; let targets = match device_query { @@ -5624,9 +5633,61 @@ async fn run_notify_when( ); return Ok(0); } - #[cfg(not(windows))] + #[cfg(target_os = "linux")] + { + let pid: u32 = target[0].parse()?; + let watched = linux_watch::open_process(pid)?; + let title = title_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| watched.name.clone()); + let started = watched.started_epoch_ms; + let initial_elapsed = (now_unix_ms().max(0) as u64).saturating_sub(started); + let task_id = format!("pid-{pid}-{started}"); + let transport = connect_task_transport(cli, device).await?; + let running = TaskNotify { + id: task_id.clone(), + title: title.clone(), + active: true, + elapsed_ms: initial_elapsed, + exit_code: None, + }; + transport.send(device, &running).await?; + let mut wait_handle = tokio::task::spawn_blocking(move || linux_watch::wait_process(watched)); + let elapsed = tokio::select! { + res = &mut wait_handle => res.context("process watch task panicked")?, + _ = tokio::signal::ctrl_c() => { + let dismissed = TaskNotify { + id: task_id, + title, + active: false, + elapsed_ms: 0, + exit_code: None, + }; + let _ = transport.send(device, &dismissed).await; + transport.shutdown().await; + return Ok(130); + } + }; + let total_elapsed = elapsed.as_millis() as u64; + let finished = TaskNotify { + id: task_id, + title: title.clone(), + active: false, + elapsed_ms: total_elapsed, + exit_code: Some(0), + }; + let took = TaskNotify::format_took(total_elapsed); + let send_res = transport.send(device, &finished).await; + transport.shutdown().await; + send_res?; + println!("{title} finished ({took})."); + return Ok(0); + } + #[cfg(not(any(windows, target_os = "linux")))] { - bail!("watching a process by PID is only supported on Windows"); + bail!("watching a process by PID is only supported on Windows and Linux"); } } @@ -5841,6 +5902,197 @@ mod win_watch { } } +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +mod linux_watch { + use std::{ + fs, + io::Write, + os::unix::{ + io::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + net::UnixStream, + }, + path::Path, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + }; + + use anyhow::{Context, Result, anyhow}; + use nectarlink_core::TaskNotify; + use serde::Serialize; + + static REQUEST_SEQ: AtomicU64 = AtomicU64::new(0); + + #[derive(Serialize)] + #[serde(tag = "kind", rename_all = "camelCase")] + enum DesktopRequest<'a> { + TaskNotify { + #[serde(skip_serializing_if = "Option::is_none")] + device: Option<&'a str>, + task: &'a TaskNotify, + }, + } + + pub fn is_desktop_running(data_dir: &Path) -> bool { + let lock_path = data_dir.join("instance.lock"); + if let Ok(file) = fs::File::open(&lock_path) { + // SAFETY: `file.as_raw_fd()` is a valid open file descriptor for the duration of this block. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + // SAFETY: Releasing the non-blocking probe lock immediately before closing `file`. + unsafe { + let _ = libc::flock(file.as_raw_fd(), libc::LOCK_UN); + } + } else { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EWOULDBLOCK) + || err.raw_os_error() == Some(libc::EAGAIN) + || err.kind() == std::io::ErrorKind::WouldBlock + { + return true; + } + } + } + let sock_path = data_dir.join("instance.sock"); + UnixStream::connect(sock_path).is_ok() + } + + pub fn queue_desktop_task_notify(data_dir: &Path, device: Option<&str>, task: &TaskNotify) -> Result<()> { + let dir = data_dir.join("requests"); + fs::create_dir_all(&dir).context("can't create desktop requests directory")?; + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + let seq = REQUEST_SEQ.fetch_add(1, Ordering::Relaxed); + let name = format!("{nanos:024}-{}-{seq:04}", std::process::id()); + let partial = dir.join(format!("{name}.tmp")); + let final_path = dir.join(format!("{name}.json")); + let payload = serde_json::to_vec(&DesktopRequest::TaskNotify { device, task })?; + let mut file = fs::File::create(&partial)?; + file.write_all(&payload)?; + file.sync_all()?; + drop(file); + fs::rename(&partial, &final_path)?; + + let sock_path = data_dir.join("instance.sock"); + if let Ok(mut stream) = UnixStream::connect(&sock_path) { + let _ = writeln!(stream, "{}", final_path.display()); + let _ = stream.flush(); + } + Ok(()) + } + + #[derive(Debug)] + pub struct WatchedProcess { + pub pid: u32, + pub name: String, + pub started_epoch_ms: u64, + pidfd: Option, + } + + fn parse_proc_stat_comm(stat_content: &str) -> Option { + let open = stat_content.find('(')?; + let close = stat_content.rfind(')')?; + if close <= open + 1 { + return None; + } + let comm = stat_content[open + 1..close].trim(); + (!comm.is_empty()).then(|| comm.to_owned()) + } + + fn parse_started_epoch_ms(stat_content: &str) -> Option { + let close = stat_content.rfind(')')?; + let after_comm = stat_content.get(close + 1..)?; + // Field 22 in `/proc//stat` is `starttime` (19th 0-indexed token after `(comm)`). + let starttime_ticks: u64 = after_comm.split_whitespace().nth(19)?.parse().ok()?; + + let proc_stat = fs::read_to_string("/proc/stat").ok()?; + let btime_secs: u64 = proc_stat + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .split_whitespace() + .next()? + .parse() + .ok()?; + + // SAFETY: `sysconf(_SC_CLK_TCK)` is a thread-safe POSIX query with no pointer arguments. + let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if clk_tck <= 0 { + return None; + } + let offset_ms = (u128::from(starttime_ticks) * 1000) / (clk_tck as u128); + Some(btime_secs.saturating_mul(1000).saturating_add(offset_ms as u64)) + } + + pub fn open_process(pid: u32) -> Result { + let stat_path = format!("/proc/{pid}/stat"); + let stat_content = fs::read_to_string(&stat_path) + .map_err(|_| anyhow!("cannot open PID {pid}: process does not exist"))?; + + let name = fs::read_to_string(format!("/proc/{pid}/comm")) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .or_else(|| parse_proc_stat_comm(&stat_content)) + .unwrap_or_else(|| format!("PID {pid}")); + + let started_epoch_ms = parse_started_epoch_ms(&stat_content).unwrap_or(0); + + let pidfd = if (pid as libc::pid_t) > 0 { + // SAFETY: `SYS_pidfd_open` with flags=0 returns a new file descriptor on success or -1 on error. + let raw = unsafe { libc::syscall(libc::SYS_pidfd_open, pid as libc::pid_t, 0 as libc::c_uint) }; + if raw >= 0 { + // SAFETY: `raw` is a valid, freshly opened file descriptor returned by `pidfd_open`. + Some(unsafe { OwnedFd::from_raw_fd(raw as RawFd) }) + } else { + None + } + } else { + None + }; + + Ok(WatchedProcess { pid, name, started_epoch_ms, pidfd }) + } + + fn proc_stat_alive(pid: u32) -> bool { + let Ok(stat_content) = fs::read_to_string(format!("/proc/{pid}/stat")) else { + return false; + }; + let Some(close) = stat_content.rfind(')') else { + return false; + }; + let state = stat_content.get(close + 1..).and_then(|s| s.split_whitespace().next()).unwrap_or(""); + !matches!(state, "Z" | "X" | "x") + } + + pub fn wait_process(proc: WatchedProcess) -> Duration { + let started_wait = Instant::now(); + if let Some(fd) = proc.pidfd.as_ref() { + let mut pfd = libc::pollfd { fd: fd.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + loop { + // SAFETY: `pfd` is a valid single-element `pollfd` referencing an open `pidfd`. + let rc = unsafe { libc::poll(&mut pfd, 1, -1) }; + if rc >= 0 { + break; + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::EINTR) { + break; + } + } + } else { + while proc_stat_alive(proc.pid) { + std::thread::sleep(Duration::from_millis(200)); + } + } + let waited = started_wait.elapsed(); + if proc.started_epoch_ms == 0 { + waited + } else { + let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64); + Duration::from_millis(now_ms.saturating_sub(proc.started_epoch_ms)).max(waited) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -6004,4 +6256,86 @@ mod tests { assert_eq!(json["device"], "pixel"); assert_eq!(json["task"]["id"], "test-1"); } + + #[cfg(target_os = "linux")] + #[test] + fn linux_watch_opens_self_and_rejects_invalid_pid() { + let me = linux_watch::open_process(std::process::id()).unwrap(); + assert_eq!(me.pid, std::process::id()); + assert!(!me.name.is_empty()); + assert!(me.started_epoch_ms > 1_700_000_000_000); + + let err = linux_watch::open_process(u32::MAX).unwrap_err(); + assert!(err.to_string().contains("process does not exist"), "unexpected error: {err}"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_watch_waits_for_short_lived_child() { + let mut child = std::process::Command::new("sh").args(["-c", "sleep 0.1"]).spawn().unwrap(); + let watched = linux_watch::open_process(child.id()).unwrap(); + assert!(!watched.name.is_empty()); + let wait_start = std::time::Instant::now(); + let elapsed = linux_watch::wait_process(watched); + let wall = wait_start.elapsed(); + let _ = child.wait(); + assert!(elapsed >= Duration::from_millis(50), "expected >= 50ms elapsed, got {elapsed:?}"); + assert!(wall < Duration::from_secs(5), "wait_process took too long: {wall:?}"); + } + + #[cfg(target_os = "linux")] + #[test] + #[allow(unsafe_code)] + fn linux_desktop_dir_lock_detection_and_request_queueing() { + use std::{ + io::Read, + os::unix::{io::AsRawFd, net::UnixListener}, + }; + + let dir = tempfile::tempdir().unwrap(); + assert!(!linux_watch::is_desktop_running(dir.path())); + + let lock_file = std::fs::File::create(dir.path().join("instance.lock")).unwrap(); + // Unlocked file alone does not indicate a running instance. + assert!(!linux_watch::is_desktop_running(dir.path())); + + // SAFETY: Acquiring an exclusive flock on `instance.lock` to simulate a running desktop instance. + let rc = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!(rc, 0); + assert!(linux_watch::is_desktop_running(dir.path())); + + let listener = UnixListener::bind(dir.path().join("instance.sock")).unwrap(); + let task = TaskNotify { + id: "test-linux-1".into(), + title: "cargo build".into(), + active: true, + elapsed_ms: 0, + exit_code: None, + }; + linux_watch::queue_desktop_task_notify(dir.path(), Some("pixel"), &task).unwrap(); + + let (mut stream, _) = listener.accept().unwrap(); + let mut notified_path = String::new(); + stream.read_to_string(&mut notified_path).unwrap(); + assert!(notified_path.ends_with(".json\n"), "unexpected socket payload: {notified_path:?}"); + + let entries: Vec<_> = std::fs::read_dir(dir.path().join("requests")) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .collect(); + assert_eq!(entries.len(), 1); + assert_eq!(notified_path.trim(), entries[0].to_string_lossy()); + let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&entries[0]).unwrap()).unwrap(); + assert_eq!(json["kind"], "taskNotify"); + assert_eq!(json["device"], "pixel"); + assert_eq!(json["task"]["id"], "test-linux-1"); + + // SAFETY: Unlocking `instance.lock` and removing `instance.sock`. + unsafe { + let _ = libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN); + } + drop(listener); + let _ = std::fs::remove_file(dir.path().join("instance.sock")); + assert!(!linux_watch::is_desktop_running(dir.path())); + } } diff --git a/desktop/app/Cargo.toml b/desktop/app/Cargo.toml index dc2710b6..0eaaa449 100644 --- a/desktop/app/Cargo.toml +++ b/desktop/app/Cargo.toml @@ -114,6 +114,9 @@ windows = { workspace = true, features = [ "Win32_Media_MediaFoundation", ] } +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + [build-dependencies] cxx-qt-build = { workspace = true, features = ["link_qt_object_files"] } embed-resource.workspace = true diff --git a/desktop/app/build.rs b/desktop/app/build.rs index c1144107..0803f6b8 100644 --- a/desktop/app/build.rs +++ b/desktop/app/build.rs @@ -237,6 +237,120 @@ END embed_resource::compile(&rc_path, embed_resource::NONE).manifest_required().expect("compile resources"); } +fn find_lrelease() -> Option { + if let Ok(custom) = std::env::var("LRELEASE") { + let p = PathBuf::from(custom); + if p.is_file() { + return Some(p); + } + } + if let Some(qmake) = std::env::var_os("QMAKE").map(PathBuf::from) + && let Some(bin_dir) = qmake.parent() + { + for name in ["lrelease", "lrelease.exe", "lrelease-qt6"] { + let candidate = bin_dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + for candidate in [ + "/usr/lib/qt6/bin/lrelease", + "/usr/lib64/qt6/bin/lrelease", + "/usr/bin/lrelease-qt6", + "/usr/bin/lrelease", + ] { + let p = PathBuf::from(candidate); + if p.is_file() { + return Some(p); + } + } + None +} + +fn offered_languages(manifest_dir: &Path) -> std::collections::HashSet { + let mut set = std::collections::HashSet::new(); + let i18n_rs = manifest_dir.join("src").join("i18n.rs"); + println!("cargo:rerun-if-changed={}", i18n_rs.display()); + let Ok(src) = fs::read_to_string(&i18n_rs) else { + return set; + }; + let Some((_, rest)) = src.split_once("pub const LANGUAGES") else { + return set; + }; + let Some((table, _)) = rest.split_once("];") else { + return set; + }; + for part in table.split('(').skip(1) { + if let Some(after_quote) = part.strip_prefix('"') + && let Some((code, _)) = after_quote.split_once('"') + && !code.is_empty() + { + set.insert(code.to_string()); + } + } + set +} + +fn compile_translations() { + let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else { + return; + }; + let manifest_path = Path::new(&manifest_dir); + let offered = offered_languages(manifest_path); + let i18n_dir = manifest_path.join("i18n"); + println!("cargo:rerun-if-changed={}", i18n_dir.display()); + let Ok(entries) = fs::read_dir(&i18n_dir) else { + return; + }; + let ts_files: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("ts")) + .collect(); + for ts in &ts_files { + println!("cargo:rerun-if-changed={}", ts.display()); + } + if ts_files.is_empty() { + return; + } + let Some(lrelease) = find_lrelease() else { + println!( + "cargo:warning=lrelease not found; skipping .qm compilation (install qt6-l10n-tools or set LRELEASE)" + ); + return; + }; + let Ok(out_dir) = std::env::var("OUT_DIR") else { + return; + }; + let out_path = PathBuf::from(out_dir); + let profile_dir = out_path.ancestors().nth(3).map(Path::to_path_buf); + + for ts in ts_files { + let Some(stem) = ts.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Some(lang) = stem.strip_prefix("nectarlink_") else { + continue; + }; + if !offered.contains(lang) { + continue; + } + let qm_name = format!("{stem}.qm"); + let out_qm = out_path.join(&qm_name); + let compiled = std::process::Command::new(&lrelease) + .arg("-silent") + .arg(&ts) + .arg("-qm") + .arg(&out_qm) + .status() + .is_ok_and(|s| s.success()); + if compiled && let Some(ref target_dir) = profile_dir { + let _ = fs::copy(&out_qm, target_dir.join(&qm_name)); + } + } +} + fn main() { println!("cargo:rerun-if-changed=src/mark.rs"); for qml in QML.iter().chain(QML_SINGLETONS.iter()) { @@ -245,6 +359,9 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { windows_resources(); } + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { + compile_translations(); + } let module = QmlModule::new("app.nectarlink") .qml_files(QML.iter().map(|f| QmlFile::from(*f))) .qml_files(QML_SINGLETONS.iter().map(|f| QmlFile::from(*f).singleton(true))); @@ -288,6 +405,11 @@ fn main() { println!("cargo:rustc-link-lib=ole32"); println!("cargo:rustc-link-lib=shell32"); println!("cargo:rustc-link-lib=user32"); + } else if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { + println!("cargo:rustc-link-lib=Qt6Quick"); + println!( + "cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN:$ORIGIN/lib:$ORIGIN/../lib:$ORIGIN/../lib/nectarlink/lib" + ); } else if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { println!("cargo:rustc-link-lib=framework=AppKit"); println!("cargo:rustc-link-lib=framework=CoreGraphics"); @@ -295,4 +417,10 @@ fn main() { println!("cargo:rustc-link-lib=framework=AudioToolbox"); println!("cargo:rustc-link-lib=framework=IOKit"); } + 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/src/bridge/app.rs b/desktop/app/src/bridge/app.rs index 9c405931..bc299ae7 100644 --- a/desktop/app/src/bridge/app.rs +++ b/desktop/app/src/bridge/app.rs @@ -1770,7 +1770,7 @@ impl qobject::AppController { pub fn logs_url(&self) -> QString { let dir = crate::logging::logs_dir(&core_host::host().data_dir); - QString::from(&format!("file:///{}", dir.to_string_lossy().replace('\\', "/"))) + QString::from(&crate::icons::file_url(&dir)) } pub fn open_phone_storage(&self, device: &QString) { @@ -1967,9 +1967,16 @@ impl qobject::AppController { .map(|p| String::from(&p)) .filter(|p| !p.is_empty()) .or_else(|| { - raw.strip_prefix("file:///") - .or_else(|| raw.strip_prefix("file://")) - .map(|s| s.replace('/', "\\")) + #[cfg(windows)] + { + raw.strip_prefix("file:///") + .or_else(|| raw.strip_prefix("file://")) + .map(|s| s.replace('/', "\\")) + } + #[cfg(not(windows))] + { + raw.strip_prefix("file://").map(str::to_owned) + } }); if let Some(local_str) = local { let pb = std::path::PathBuf::from(&local_str); diff --git a/desktop/app/src/i18n.rs b/desktop/app/src/i18n.rs index 344d16c1..9a8f7b12 100644 --- a/desktop/app/src/i18n.rs +++ b/desktop/app/src/i18n.rs @@ -30,13 +30,39 @@ fn exe_dir() -> Option { std::env::current_exe().ok().and_then(|exe| exe.parent().map(Path::to_path_buf)) } +fn has_qm_files(dir: &Path) -> bool { + std::fs::read_dir(dir).is_ok_and(|entries| { + entries.flatten().any(|e| e.path().extension().and_then(|s| s.to_str()) == Some("qm")) + }) +} + +fn select_translation_dir(exe_dir: &Path) -> PathBuf { + if has_qm_files(exe_dir) { + return exe_dir.to_path_buf(); + } + #[cfg(target_os = "linux")] + { + if let Some(rel_share) = exe_dir.parent().map(|p| p.join("share").join("nectarlink")) + && has_qm_files(&rel_share) + { + return rel_share; + } + let sys_share = Path::new("/usr/share/nectarlink"); + if has_qm_files(sys_share) { + return sys_share.to_path_buf(); + } + } + exe_dir.to_path_buf() +} + /// Uses `language` ("" for the system's) from now on, retranslating the /// open windows. Call on the Qt thread, after the `QGuiApplication` exists. pub fn install(language: &str) { let language = if is_known(language) { language } else { "" }; - let Some(dir) = exe_dir() else { return }; + let Some(exe) = exe_dir() else { return }; + let dir = select_translation_dir(&exe); let installed = crate::bridge::native::ffi::install_translator(&dir.to_string_lossy(), language); - tracing::debug!(language, installed, "translator"); + tracing::debug!(language, dir = %dir.display(), installed, "translator"); } /// `text` (from `context`) in the app's language. Use [`tr!`], which also diff --git a/desktop/app/src/linux/audio.rs b/desktop/app/src/linux/audio.rs new file mode 100644 index 00000000..7f4ee91f --- /dev/null +++ b/desktop/app/src/linux/audio.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::AudioOutputDevice; + +pub fn speaker_state() -> Option<(u8, bool)> { + None +} +pub fn set_speaker_volume(_level: u8) -> Result<(), String> { + Ok(()) +} +pub fn set_speaker_mute(_muted: bool) -> Result<(), String> { + Ok(()) +} +pub fn speaker_volume_up() -> Result<(u8, bool), String> { + Ok((50, false)) +} +pub fn speaker_volume_down() -> Result<(u8, bool), String> { + Ok((50, false)) +} +pub fn toggle_speaker_mute() -> Result { + Ok(false) +} +pub fn mic_muted() -> Option { + None +} +pub fn toggle_mic_mute() -> Result { + Ok(false) +} +pub fn output_devices() -> Vec { + Vec::new() +} +pub fn set_default_output_device(_id: &str) -> Result<(), String> { + Ok(()) +} +pub fn watch(_changed: fn()) {} diff --git a/desktop/app/src/linux/audio_out.rs b/desktop/app/src/linux/audio_out.rs new file mode 100644 index 00000000..21aee306 --- /dev/null +++ b/desktop/app/src/linux/audio_out.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::io; + +pub struct Player; + +impl Player { + pub fn start(_rate: u32, _channels: u16) -> io::Result { + Ok(Player) + } + pub fn start_on(_device: Option, _rate: u32, _channels: u16) -> io::Result { + Ok(Player) + } + pub fn start_for_device(_rate: u32, _channels: u16, _device_id: Option<&str>) -> io::Result { + Ok(Player) + } + pub fn push(&self, _data: Vec) {} +} diff --git a/desktop/app/src/linux/autostart.rs b/desktop/app/src/linux/autostart.rs new file mode 100644 index 00000000..248b5fbf --- /dev/null +++ b/desktop/app/src/linux/autostart.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{io, path::Path}; + +pub fn command(exe: &Path) -> String { + format!("\"{}\" --minimized", exe.display()) +} +pub fn set(_enabled: bool, _exe: &Path) -> io::Result<()> { + Ok(()) +} +pub fn is_enabled() -> bool { + false +} +pub fn set_enabled(_enabled: bool) -> Result<(), String> { + Ok(()) +} +pub fn is_installed(_exe: &Path) -> bool { + false +} diff --git a/desktop/app/src/linux/ble_beacon.rs b/desktop/app/src/linux/ble_beacon.rs new file mode 100644 index 00000000..d167ec00 --- /dev/null +++ b/desktop/app/src/linux/ble_beacon.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::DeviceId; + +pub fn start(_device_id: DeviceId) { + let _ = crate::automations::TRIGGER_PHONE_WALKED_AWAY; +} +pub fn stop() {} diff --git a/desktop/app/src/linux/browser.rs b/desktop/app/src/linux/browser.rs new file mode 100644 index 00000000..c859dd88 --- /dev/null +++ b/desktop/app/src/linux/browser.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables, non_camel_case_types, clippy::upper_case_acronyms)] + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct HWND(pub *mut core::ffi::c_void); +unsafe impl Send for HWND {} +unsafe impl Sync for HWND {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RECT { + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Window { + pub hwnd: HWND, + pub program: String, + pub title: String, +} + +pub fn programs_for(_player: &str) -> Vec { + Vec::new() +} +pub fn is_browser(_program: &str) -> bool { + false +} +pub fn windows() -> Vec { + Vec::new() +} +pub fn window_for(_player: &str, _title: &str) -> Option { + None +} +pub fn use_physical_pixels() {} + +pub struct Automation; + +impl Automation { + pub fn new() -> Result { + Err("not supported on Linux yet".into()) + } + pub fn page_url(&self, _window: &Window) -> Option { + None + } + pub fn player_rect(&self, _window: &Window) -> Option { + None + } + pub fn page_rect(&self, _window: &Window) -> Option { + None + } +} + +pub fn is_minimized(_hwnd: HWND) -> bool { + false +} +pub fn restore_behind(_hwnd: HWND) {} +pub fn frame_of(_hwnd: HWND) -> Option { + None +} diff --git a/desktop/app/src/linux/bt_hfp.rs b/desktop/app/src/linux/bt_hfp.rs new file mode 100644 index 00000000..b0e56678 --- /dev/null +++ b/desktop/app/src/linux/bt_hfp.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HfpStatus { + pub available: bool, + pub connected: bool, + pub device_name: String, + pub detail: String, +} + +pub(crate) fn parse_bluetoothctl_devices(output: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for raw_line in output.lines() { + let line = raw_line.trim(); + let Some(rest) = line.strip_prefix("Device ") else { + continue; + }; + let rest = rest.trim(); + let mut parts = rest.splitn(2, char::is_whitespace); + let Some(mac) = parts.next().map(str::trim).filter(|m| !m.is_empty()) else { + continue; + }; + let name = parts.next().map(str::trim).unwrap_or_default().to_owned(); + out.push((mac.to_owned(), name)); + } + out +} + +pub(crate) fn parse_bluetoothctl_connected(info_output: &str) -> bool { + info_output.lines().any(|line| { + let trimmed = line.trim(); + trimmed.strip_prefix("Connected:").map(|v| v.trim().eq_ignore_ascii_case("yes")).unwrap_or(false) + }) +} + +pub fn probe_status(_phone_name: Option<&str>) -> HfpStatus { + HfpStatus::default() +} +pub fn connect_audio(_phone_name: Option<&str>) -> Result { + Ok(HfpStatus::default()) +} +pub fn disconnect_audio() {} +pub fn open_bluetooth_settings() {} diff --git a/desktop/app/src/linux/clipboard.rs b/desktop/app/src/linux/clipboard.rs new file mode 100644 index 00000000..436f10d6 --- /dev/null +++ b/desktop/app/src/linux/clipboard.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{path::PathBuf, sync::Mutex}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Image { + Png(Vec), + Bitmap(Vec), +} + +impl Image { + pub fn to_png(&self) -> Result, String> { + match self { + Image::Png(bytes) => Ok(bytes.clone()), + Image::Bitmap(_) => Err("not supported on Linux yet".into()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Clip { + Text { text: String, html: Option, rtf: Option }, + Image(Image), + Private, + Empty, +} + +static FALLBACK_TEXT: Mutex> = Mutex::new(None); + +pub fn start(_on_copy: F) {} + +pub fn read() -> Clip { + match FALLBACK_TEXT.lock().unwrap_or_else(|e| e.into_inner()).clone() { + Some(text) => Clip::Text { text, html: None, rtf: None }, + None => Clip::Empty, + } +} + +pub fn read_for_paste() -> Clip { + read() +} + +pub fn read_image_png() -> Option> { + None +} + +pub fn write(text: &str) -> Result<(), String> { + crate::mirror::note_remote_clipboard(text); + *FALLBACK_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_owned()); + Ok(()) +} + +pub fn write_rich(text: &str, _html: Option<&str>, _rtf: Option<&str>) -> Result<(), String> { + write(text) +} + +pub fn write_private(text: &str) -> Result<(), String> { + write(text) +} + +pub fn write_image(_mime: &str, _bytes: &[u8]) -> Result<(), String> { + Ok(()) +} + +pub fn read_files() -> Option> { + None +} + +pub fn write_files(_paths: &[PathBuf]) -> bool { + false +} diff --git a/desktop/app/src/linux/doctor.rs b/desktop/app/src/linux/doctor.rs new file mode 100644 index 00000000..857233c5 --- /dev/null +++ b/desktop/app/src/linux/doctor.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{io, path::Path}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Firewall { + pub on: bool, + pub allowed: bool, + pub blocked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Network { + pub name: String, + pub trusted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Adapters { + pub local_address: bool, + pub vpn: bool, +} + +pub fn firewall(_exe: &Path) -> io::Result { + Ok(Firewall { on: false, allowed: true, blocked: false }) +} +pub fn networks() -> io::Result> { + Ok(Vec::new()) +} +pub fn adapters() -> Adapters { + Adapters { local_address: true, vpn: false } +} +pub fn fix_firewall(_exe: &Path) -> io::Result { + Ok(false) +} +pub fn open_firewall_settings() -> bool { + false +} diff --git a/desktop/app/src/linux/elevated.rs b/desktop/app/src/linux/elevated.rs new file mode 100644 index 00000000..0c64444b --- /dev/null +++ b/desktop/app/src/linux/elevated.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Linux privilege check (stub). + +use std::path::Path; + +#[derive(Debug)] +pub enum Error { + Declined, + Other(String), +} + +#[derive(Debug)] +pub struct Process; + +impl Process { + pub fn exit_code(&self) -> Option { + Some(1) + } +} + +pub fn is_elevated() -> bool { + // SAFETY: geteuid has no preconditions and is always safe to call on Linux. + unsafe { libc::geteuid() == 0 } +} + +pub fn start(_program: &Path, _params: &str) -> Result { + Err(Error::Other("elevated execution not supported on Linux yet".into())) +} + +pub fn run_elevated(_exe: &Path, _args: &[&str]) -> Result { + Err("elevated execution not supported on Linux yet".into()) +} diff --git a/desktop/app/src/linux/h264.rs b/desktop/app/src/linux/h264.rs new file mode 100644 index 00000000..91d1e3b7 --- /dev/null +++ b/desktop/app/src/linux/h264.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::io; + +#[derive(Debug, Clone)] +pub struct Picture { + pub width: u32, + pub height: u32, + pub stride: usize, + pub plane_rows: usize, + pub nv12: Vec, + pub colors: Colors, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Colors { + pub bt601: bool, + pub full_range: bool, +} + +pub struct Decoder; + +impl Decoder { + pub fn new() -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + } + pub fn new_for_codec(_codec: &str) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + } + pub fn new_gpu(_codec: &str) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) + } + pub fn on_gpu(&self) -> bool { + false + } + pub fn codec(&self) -> &str { + "h264" + } + pub fn decode(&mut self, _data: &[u8], _time_us: u64) -> io::Result> { + Ok(Vec::new()) + } + pub fn flush(&mut self) {} +} + +pub fn to_bgrx(_picture: &Picture, _width: u32, _height: u32) -> Vec { + Vec::new() +} diff --git a/desktop/app/src/linux/http.rs b/desktop/app/src/linux/http.rs new file mode 100644 index 00000000..01e2273f --- /dev/null +++ b/desktop/app/src/linux/http.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::Path; + +pub fn get(_url: &str, _limit: usize) -> Result, String> { + Err("not supported on Linux yet".into()) +} +pub fn get_retrying(_url: &str, _limit: usize) -> Result, String> { + Err("not supported on Linux yet".into()) +} +pub fn download( + _url: &str, + _file: &Path, + _limit: u64, + _progress: impl FnMut(u64, u64), +) -> Result<(), String> { + Err("not supported on Linux yet".into()) +} +pub fn get_json(_url: &str) -> Result { + Err("not supported on Linux yet".into()) +} +pub fn download_to_file(_url: &str, _dest: &Path, _on_progress: impl FnMut(u64, u64)) -> Result<(), String> { + Err("not supported on Linux yet".into()) +} diff --git a/desktop/app/src/linux/icon.rs b/desktop/app/src/linux/icon.rs new file mode 100644 index 00000000..49762cbf --- /dev/null +++ b/desktop/app/src/linux/icon.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use std::fs; +use std::io::Result; +use std::path::Path; + +use crate::mark; + +use super::image::{Bitmap, encode_png}; + +pub fn write_png(path: &Path, size: u32) -> Result<()> { + let rgba = mark::render(size as usize); + let mut bgra = Vec::with_capacity(rgba.len()); + for px in rgba.as_chunks::<4>().0 { + bgra.push(px[2]); + bgra.push(px[1]); + bgra.push(px[0]); + bgra.push(px[3]); + } + let bitmap = Bitmap { width: size, height: size, bgra }; + let png = encode_png(&bitmap)?; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + fs::write(path, png) +} diff --git a/desktop/app/src/linux/image.rs b/desktop/app/src/linux/image.rs new file mode 100644 index 00000000..73167dd1 --- /dev/null +++ b/desktop/app/src/linux/image.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::io; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bitmap { + pub width: u32, + pub height: u32, + pub bgra: Vec, +} + +pub fn encode_png(_bitmap: &Bitmap) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) +} +pub fn encode_jpeg(_bitmap: &Bitmap, _quality: u8) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) +} +pub fn decode(_bytes: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) +} +pub fn scale_to(bitmap: &Bitmap, _max: u32) -> Bitmap { + bitmap.clone() +} +pub fn crop_square(bitmap: &Bitmap) -> Bitmap { + bitmap.clone() +} +pub fn crop_square_jpeg(_bytes: &[u8], _max_side: u32, _quality: u8) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on Linux yet")) +} +pub fn bmp_from_dib(_dib: &[u8]) -> Option> { + None +} +pub fn dibv5(_bitmap: &Bitmap) -> Vec { + Vec::new() +} diff --git a/desktop/app/src/linux/input.rs b/desktop/app/src/linux/input.rs new file mode 100644 index 00000000..9396fb0f --- /dev/null +++ b/desktop/app/src/linux/input.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::{ButtonAction, KeyMod, MouseButton, SlideAction, TouchAction}; + +pub fn is_available() -> bool { + false +} +pub fn move_relative(_dx: f32, _dy: f32) {} +pub fn move_absolute(_x: f32, _y: f32) {} +pub fn mirror_touch(_action: TouchAction, _x: f32, _y: f32) {} +pub fn mouse_button(_button: MouseButton, _action: ButtonAction) {} +pub fn release_held_buttons() {} +pub fn scroll(_dx: f32, _dy: f32) {} +pub fn type_text(_text: &str) {} +pub fn press_key(_key: &str, _mods: &[KeyMod]) {} +pub fn slide(_action: SlideAction) {} +pub fn is_user_active_within_ms(_max_idle_ms: u32) -> bool { + false +} diff --git a/desktop/app/src/linux/jumplist.rs b/desktop/app/src/linux/jumplist.rs new file mode 100644 index 00000000..18aae30f --- /dev/null +++ b/desktop/app/src/linux/jumplist.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::Path; + +pub const ARG: &str = "--task"; + +pub fn register(_custom_data_dir: Option<&Path>) {} +pub fn unregister() {} diff --git a/desktop/app/src/linux/media_sessions.rs b/desktop/app/src/linux/media_sessions.rs new file mode 100644 index 00000000..0a8f18a3 --- /dev/null +++ b/desktop/app/src/linux/media_sessions.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::{MediaAction, MediaError, MediaPlayer}; + +pub fn says_video(_id: &str) -> bool { + false +} +pub fn start) + Send + Sync + 'static>(_on_change: F) {} +pub fn command(_player: &str, _action: MediaAction, _position: Option) -> Result<(), MediaError> { + Ok(()) +} +pub fn pause_for_call() {} +pub fn resume_after_call() {} diff --git a/desktop/app/src/linux/mod.rs b/desktop/app/src/linux/mod.rs new file mode 100644 index 00000000..f1538cb6 --- /dev/null +++ b/desktop/app/src/linux/mod.rs @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Linux platform integration (`desktop/app/src/linux/`), matching the public +//! API of `desktop/app/src/win/`. + +#![allow(dead_code, unsafe_code)] + +pub mod audio; +pub mod audio_out; +pub mod autostart; +pub mod ble_beacon; +pub mod browser; +pub mod bt_hfp; +pub mod clipboard; +pub mod doctor; +pub mod elevated; +pub mod h264; +pub mod http; +pub mod icon; +pub mod image; +pub mod input; +pub mod jumplist; +pub mod media_sessions; +pub mod mp4; +pub mod net; +pub mod share_target; +pub mod shell; +pub mod shortcut; +pub mod single_instance; +pub mod smtc; +pub mod sound; +pub mod toast; +pub mod tray; +pub mod vcam; +pub mod vmic; +pub mod wallpaper; +pub mod widget; +pub mod wifi; +pub mod wol; + +use std::{fs, io::Read, process::Command}; + +/// A stable machine identifier from `/etc/machine-id` or `/var/lib/dbus/machine-id`. +pub fn machine_guid() -> Option { + for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] { + if let Ok(contents) = fs::read_to_string(path) { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_owned()); + } + } + } + None +} + +/// Human-readable Linux distribution and kernel version (e.g. `"Ubuntu 24.04 LTS (6.8.0)"`). +pub fn os_version() -> String { + let distro = fs::read_to_string("/etc/os-release") + .ok() + .and_then(|text| parse_os_release_pretty_name(&text)) + .unwrap_or_else(|| "Linux".to_owned()); + let kernel = fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()); + match kernel { + Some(k) => format!("{distro} ({k})"), + None => distro, + } +} + +fn parse_os_release_pretty_name(contents: &str) -> Option { + for line in contents.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("PRETTY_NAME=") { + let unquoted = rest.trim().trim_matches('"').trim_matches('\'').trim(); + if !unquoted.is_empty() { + return Some(unquoted.to_owned()); + } + } + } + None +} + +/// Returns a synthetic build number (`22621`) so Windows 11 feature gates in shared UI stay enabled. +pub fn os_build_number() -> u32 { + 22621 +} + +/// Whether this PC is a laptop (battery present in `/sys/class/power_supply` or mobile DMI chassis). +pub fn is_laptop() -> bool { + if let Ok(entries) = fs::read_dir("/sys/class/power_supply") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("BAT") { + return true; + } + if let Ok(kind) = fs::read_to_string(entry.path().join("type")) + && kind.trim().eq_ignore_ascii_case("Battery") + { + return true; + } + } + } + if let Ok(chassis) = fs::read_to_string("/sys/class/dmi/id/chassis_type") { + let c = chassis.trim(); + // 8 = Portable, 9 = Laptop, 10 = Notebook, 14 = Sub Notebook, + // 30 = Tablet, 31 = Convertible, 32 = Detachable + if matches!(c, "8" | "9" | "10" | "14" | "30" | "31" | "32") { + return true; + } + } + false +} + +/// Whether power-saver mode is active (`/sys/firmware/acpi/platform_profile` or `powerprofilesctl`). +pub fn power_saver() -> bool { + if let Ok(profile) = fs::read_to_string("/sys/firmware/acpi/platform_profile") + && profile.trim().eq_ignore_ascii_case("low-power") + { + return true; + } + if let Ok(output) = Command::new("powerprofilesctl").arg("get").output() + && output.status.success() + { + let text = String::from_utf8_lossy(&output.stdout); + if text.trim().eq_ignore_ascii_case("power-saver") { + return true; + } + } + false +} + +/// Parses GTK_THEME, gsettings color-scheme, and kdeglobals ColorScheme to decide dark mode. +pub(crate) fn parse_system_dark_sources( + gtk_theme: Option<&str>, + gsettings_color_scheme: Option<&str>, + kdeglobals: Option<&str>, +) -> bool { + if let Some(gtk) = gtk_theme { + let lower = gtk.to_ascii_lowercase(); + if lower.contains("dark") { + return true; + } + if lower.contains("light") { + return false; + } + } + if let Some(gs) = gsettings_color_scheme { + let text = gs.to_ascii_lowercase(); + if text.contains("prefer-dark") { + return true; + } + if text.contains("prefer-light") || text.contains("default") { + return false; + } + } + if let Some(kde) = kdeglobals { + for line in kde.lines() { + if let Some(scheme) = line.trim().strip_prefix("ColorScheme=") { + return scheme.to_ascii_lowercase().contains("dark"); + } + } + } + true +} + +/// Whether the desktop prefers a dark color scheme. +pub fn system_dark() -> bool { + let gtk_theme = std::env::var("GTK_THEME").ok(); + let gsettings = Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "color-scheme"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + let kdeglobals = dirs::config_dir().and_then(|c| fs::read_to_string(c.join("kdeglobals")).ok()); + parse_system_dark_sources(gtk_theme.as_deref(), gsettings.as_deref(), kdeglobals.as_deref()) +} + +/// Whether desktop animations are turned off. +pub fn reduce_motion() -> bool { + if let Ok(output) = + Command::new("gsettings").args(["get", "org.gnome.desktop.interface", "enable-animations"]).output() + && output.status.success() + { + let text = String::from_utf8_lossy(&output.stdout); + if text.trim() == "false" { + return true; + } + } + false +} + +/// Returns the usable desktop work area `(x, y, width, height)` in physical pixels. +pub fn work_area() -> Option<(i32, i32, i32, i32)> { + if let Ok(output) = Command::new("xprop").args(["-root", "_NET_WORKAREA"]).output() + && output.status.success() + { + let text = String::from_utf8_lossy(&output.stdout); + if let Some((_, rhs)) = text.split_once('=') { + let nums: Vec = + rhs.split(',').filter_map(|p| p.trim().parse::().ok()).take(4).collect(); + if let [x, y, w, h] = nums.as_slice() + && *w > 0 + && *h > 0 + { + return Some((*x, *y, *w, *h)); + } + } + } + Some((0, 0, 1920, 1080)) +} + +/// Fills `buf` with cryptographically strong random bytes from `/dev/urandom`. +pub fn secure_random(buf: &mut [u8]) -> bool { + if buf.is_empty() { + return true; + } + let Ok(mut file) = fs::File::open("/dev/urandom") else { + return false; + }; + file.read_exact(buf).is_ok() +} + +/// Runs `f` on the current thread (no-op COM wrapper on Linux). +pub fn with_com(f: impl FnOnce() -> T) -> T { + f() +} + +/// Milliseconds from UTC to local time at `at_ms` (Unix epoch ms). +/// East of UTC is positive. +pub fn local_offset_ms(at_ms: i64) -> i64 { + let secs = at_ms.div_euclid(1_000) as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + // SAFETY: `localtime_r` is thread-safe and writes into caller-provided `tm`. + let res = unsafe { libc::localtime_r(&secs, &mut tm) }; + if res.is_null() { + return 0; + } + (tm.tm_gmtoff as i64).saturating_mul(1_000) +} + +/// Bytes a normal user can still write to the drive holding `path`, or +/// `None` if `path` doesn't exist yet. +pub fn free_space(path: &std::path::Path) -> Option { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + // SAFETY: `c_path` is a valid NUL-terminated string and `stat` is valid writable memory. + if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 { + return None; + } + let block_size = if stat.f_frsize > 0 { stat.f_frsize as u64 } else { stat.f_bsize as u64 }; + Some((stat.f_bavail as u64).saturating_mul(block_size)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_os_info_without_panicking() { + assert!(!os_version().is_empty()); + assert_eq!(os_build_number(), 22621); + let _ = machine_guid(); + let _ = is_laptop(); + let _ = power_saver(); + let _ = system_dark(); + let _ = reduce_motion(); + assert!(work_area().is_some()); + } + + #[test] + fn parses_os_release_pretty_name() { + let sample = "NAME=\"Ubuntu\"\nPRETTY_NAME=\"Ubuntu 24.04.1 LTS\"\nID=ubuntu\n"; + assert_eq!(parse_os_release_pretty_name(sample).as_deref(), Some("Ubuntu 24.04.1 LTS")); + } + + #[test] + fn parses_system_dark_across_gtk_gnome_and_kde_sources() { + assert!(parse_system_dark_sources(Some("Adwaita-dark"), Some("'prefer-light'"), None)); + assert!(!parse_system_dark_sources(Some("Adwaita-light"), Some("'prefer-dark'"), None)); + assert!(parse_system_dark_sources(None, Some("'prefer-dark'\n"), None)); + assert!(!parse_system_dark_sources(None, Some("'prefer-light'\n"), None)); + assert!(!parse_system_dark_sources(None, Some("'default'\n"), None)); + assert!(parse_system_dark_sources(None, None, Some("[General]\nColorScheme=BreezeDark\n"))); + assert!(!parse_system_dark_sources(None, None, Some("[General]\nColorScheme=BreezeLight\n"))); + assert!(parse_system_dark_sources(None, None, None)); + } + + #[test] + fn fills_secure_random_bytes() { + let mut buf = [0u8; 32]; + assert!(secure_random(&mut buf)); + assert!(buf.iter().any(|&b| b != 0)); + } + + #[test] + fn runs_closure_with_com() { + assert_eq!(with_com(|| 42), 42); + } + + #[test] + fn local_offset_ms_returns_reasonable_timezone_offset() { + let offset = local_offset_ms(1_700_000_000_000); + assert!((-14 * 3_600_000..=14 * 3_600_000).contains(&offset)); + } + + #[test] + fn free_space_queries_existing_directory() { + let temp = std::env::temp_dir(); + assert!(free_space(&temp).is_some()); + assert!(free_space(std::path::Path::new("/nonexistent/nectarlink/path/xyz")).is_none()); + } +} diff --git a/desktop/app/src/linux/mp4.rs b/desktop/app/src/linux/mp4.rs new file mode 100644 index 00000000..a20fab51 --- /dev/null +++ b/desktop/app/src/linux/mp4.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{path::PathBuf, time::Instant}; + +pub fn local_timestamp_filename(prefix: &str, ext: &str) -> String { + format!("{prefix}.{ext}") +} + +pub fn merge_sps_pps(_dest: &mut Vec, _incoming: &[u8]) {} + +pub struct MirrorRecorder { + started: Instant, +} + +impl MirrorRecorder { + pub fn new( + _path: PathBuf, + _width: u32, + _height: u32, + _fps: u32, + _audio_cfg: Option<(u32, u8)>, + ) -> MirrorRecorder { + MirrorRecorder { started: Instant::now() } + } + pub fn started_at(&self) -> Instant { + self.started + } + pub fn started_epoch_ms(&self) -> i64 { + 0 + } + pub fn set_sps_pps(&mut self, _sps_pps: &[u8]) {} + pub fn on_config(&mut self, _width: u32, _height: u32) {} + pub fn on_audio_config(&mut self, _rate: u32, _channels: u8) {} + pub fn on_packet(&mut self, _keyframe: bool, _time_us: u64, _data: &[u8]) -> Result<(), String> { + Ok(()) + } + pub fn on_audio(&mut self, _time_us: u64, _pcm: &[u8]) -> Result<(), String> { + Ok(()) + } + pub fn finish(self) -> Result { + Err("not supported on Linux yet".into()) + } +} diff --git a/desktop/app/src/linux/net.rs b/desktop/app/src/linux/net.rs new file mode 100644 index 00000000..d95170c7 --- /dev/null +++ b/desktop/app/src/linux/net.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Watching for IP address and network route changes on Linux via a Netlink +//! `NETLINK_ROUTE` socket (with a `/proc/net/route` fallback), debounced so a +//! single network transition triggers one callback. + +use std::{ + fs, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +const DEBOUNCE: Duration = Duration::from_millis(500); + +const AF_NETLINK: i32 = 16; +const SOCK_RAW: i32 = 3; +const SOCK_CLOEXEC: i32 = 0o2000000; +const NETLINK_ROUTE: i32 = 0; +const SOL_SOCKET: i32 = 1; +const SO_RCVTIMEO: i32 = 20; +const RTMGRP_LINK: u32 = 1; +const RTMGRP_IPV4_IFADDR: u32 = 0x10; +const RTMGRP_IPV4_ROUTE: u32 = 0x40; +const RTMGRP_IPV6_IFADDR: u32 = 0x100; + +#[repr(C)] +struct SockAddrNl { + nl_family: u16, + nl_pad: u16, + nl_pid: u32, + nl_groups: u32, +} + +#[repr(C)] +struct Timeval { + tv_sec: i64, + tv_usec: i64, +} + +unsafe extern "C" { + fn socket(domain: i32, ty: i32, protocol: i32) -> i32; + fn setsockopt(fd: i32, level: i32, optname: i32, optval: *const std::ffi::c_void, optlen: u32) -> i32; + fn bind(fd: i32, addr: *const std::ffi::c_void, addrlen: u32) -> i32; + fn recv(fd: i32, buf: *mut std::ffi::c_void, len: usize, flags: i32) -> isize; + fn close(fd: i32) -> i32; +} + +static STOP_FLAG: Mutex>> = Mutex::new(None); + +fn proc_network_snapshot() -> String { + let route = fs::read_to_string("/proc/net/route").unwrap_or_default(); + let inet6 = fs::read_to_string("/proc/net/if_inet6").unwrap_or_default(); + let fib_trie = fs::read_to_string("/proc/net/arp").unwrap_or_default(); + format!("{route}\n---\n{inet6}\n---\n{fib_trie}") +} + +/// Calls `callback` whenever an IP address or route is added or removed on the PC. +/// Replacing an earlier watch stops the previous background thread. +pub fn watch(callback: impl Fn() + Send + Sync + 'static) { + stop(); + let stop_flag = Arc::new(AtomicBool::new(false)); + *STOP_FLAG.lock().unwrap_or_else(|e| e.into_inner()) = Some(stop_flag.clone()); + + let _ = std::thread::Builder::new().name("net-watch".into()).spawn(move || { + // Try opening an AF_NETLINK route socket subscribed to link + IPv4/IPv6 address groups. + // SAFETY: creating a standard non-privileged NETLINK_ROUTE socket with a receive timeout. + let fd = unsafe { socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE) }; + if fd >= 0 { + let bound = unsafe { + let addr = SockAddrNl { + nl_family: AF_NETLINK as u16, + nl_pad: 0, + nl_pid: 0, + nl_groups: RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR | RTMGRP_IPV4_ROUTE, + }; + let tv = Timeval { tv_sec: 1, tv_usec: 0 }; + let _ = setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + (&raw const tv).cast(), + std::mem::size_of::() as u32, + ); + bind(fd, (&raw const addr).cast(), std::mem::size_of::() as u32) == 0 + }; + if bound { + let mut buf = [0u8; 4096]; + let mut last_fired: Option = None; + while !stop_flag.load(Ordering::Relaxed) { + // SAFETY: `buf` is a live stack buffer and `fd` is our open socket. + let n = unsafe { recv(fd, buf.as_mut_ptr().cast(), buf.len(), 0) }; + if n > 0 && !stop_flag.load(Ordering::Relaxed) { + let now = Instant::now(); + if last_fired.is_none_or(|prev| now.duration_since(prev) >= DEBOUNCE) { + last_fired = Some(now); + callback(); + } + } + } + // SAFETY: closing our own socket fd. + unsafe { + let _ = close(fd); + } + return; + } + // SAFETY: closing our own socket fd when bind failed. + unsafe { + let _ = close(fd); + } + } + + // Fallback: poll `/proc/net/route` and `/proc/net/if_inet6`. + let mut last_snapshot = proc_network_snapshot(); + while !stop_flag.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_secs(2)); + if stop_flag.load(Ordering::Relaxed) { + break; + } + let current = proc_network_snapshot(); + if current != last_snapshot { + last_snapshot = current; + callback(); + } + } + }); +} + +/// Alias for [`watch`]. +pub fn watch_address_changes(on_change: impl Fn() + Send + Sync + 'static) { + watch(on_change); +} + +/// Stops watching for network address changes. +pub fn stop() { + if let Some(flag) = STOP_FLAG.lock().unwrap_or_else(|e| e.into_inner()).take() { + flag.store(true, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_and_stops_net_watcher_cleanly() { + watch(|| {}); + stop(); + } +} diff --git a/desktop/app/src/linux/share_target.rs b/desktop/app/src/linux/share_target.rs new file mode 100644 index 00000000..f1351ed3 --- /dev/null +++ b/desktop/app/src/linux/share_target.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::{Path, PathBuf}; +use tokio::sync::mpsc::UnboundedSender; + +pub const ARG: &str = "--share"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SharedPayload { + pub files: Vec, + pub url: Option, + pub text: Option, +} + +impl SharedPayload { + pub fn is_empty(&self) -> bool { + self.files.is_empty() && self.url.is_none() && self.text.is_none() + } +} + +pub fn register_listener(_tx: UnboundedSender) -> Result<(), String> { + Ok(()) +} +pub fn take_activated_share(_data_dir: &Path) -> Option { + None +} +pub fn parse_targets(targets: &[String]) -> SharedPayload { + let mut payload = SharedPayload::default(); + for raw in targets { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("mailto:") + || lower.starts_with("tel:") + || lower.starts_with("nectarlink:") + { + if payload.url.is_none() { + payload.url = Some(trimmed.to_owned()); + } + } else { + payload.files.push(PathBuf::from(trimmed)); + } + } + payload +} diff --git a/desktop/app/src/linux/shell.rs b/desktop/app/src/linux/shell.rs new file mode 100644 index 00000000..8d09c4fe --- /dev/null +++ b/desktop/app/src/linux/shell.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::Path; + +pub fn open_url(_url: &str) -> Result<(), String> { + Ok(()) +} +pub fn open_mailto(_address: &str) -> Result<(), String> { + Ok(()) +} +pub fn open_maps(_query: &str) -> Result<(), String> { + Ok(()) +} +pub fn is_url_scheme_registered(_scheme: &str) -> bool { + false +} +pub fn open_file(_path: &Path) -> Result<(), String> { + Ok(()) +} +pub fn open_folder(_path: &Path) -> Result<(), String> { + Ok(()) +} +pub fn select_in_explorer(_path: &Path) -> Result<(), String> { + Ok(()) +} +pub fn run(_program: &Path, _arguments: &str) -> Result<(), String> { + Ok(()) +} +pub fn lock() -> Result<(), String> { + Ok(()) +} +pub fn lock_workstation() -> Result<(), String> { + Ok(()) +} +pub fn sleep() -> Result<(), String> { + Ok(()) +} +pub fn sleep_system() -> Result<(), String> { + Ok(()) +} +pub fn take_screenshot() -> Result<(), String> { + Ok(()) +} +pub fn launch_app(_path: &Path) -> Result<(), String> { + Ok(()) +} +pub fn launch_executable(_path: &Path, _args: &[&str]) -> Result<(), String> { + Ok(()) +} +pub fn run_command(_command: &str) -> Result<(), String> { + Ok(()) +} +pub fn recycle_file(path: &Path) -> Result<(), String> { + std::fs::remove_file(path).map_err(|e| e.to_string()) +} diff --git a/desktop/app/src/linux/shortcut.rs b/desktop/app/src/linux/shortcut.rs new file mode 100644 index 00000000..35762b32 --- /dev/null +++ b/desktop/app/src/linux/shortcut.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{ + io, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shortcut { + pub target: PathBuf, + pub arguments: String, + pub description: String, +} + +pub fn write(path: &Path, shortcut: &Shortcut) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, format!("{}\n{}", shortcut.target.display(), shortcut.arguments)) +} + +pub fn target(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + text.lines().next().map(PathBuf::from) +} + +pub fn arguments(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + text.lines().nth(1).map(str::to_owned) +} + +pub fn startup_dir() -> Option { + None +} +pub fn send_to_dir() -> Option { + None +} +pub fn recent_dir() -> Option { + None +} diff --git a/desktop/app/src/linux/single_instance.rs b/desktop/app/src/linux/single_instance.rs new file mode 100644 index 00000000..711e51f3 --- /dev/null +++ b/desktop/app/src/linux/single_instance.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{io, path::Path}; + +pub struct PrimaryInstance; + +pub enum Instance { + Primary(PrimaryInstance), + Secondary, +} + +pub fn acquire(_data_dir: &Path) -> io::Result { + Ok(Instance::Primary(PrimaryInstance)) +} + +pub fn wake(_data_dir: &Path) {} + +impl PrimaryInstance { + pub fn watch(&'static self, _on_activate: impl Fn() + Send + 'static) {} +} diff --git a/desktop/app/src/linux/smtc.rs b/desktop/app/src/linux/smtc.rs new file mode 100644 index 00000000..8fb2ba63 --- /dev/null +++ b/desktop/app/src/linux/smtc.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use nectarlink_core::{DeviceId, MediaAction}; + +use crate::state::PlayerView; + +#[derive(Debug, Clone)] +pub struct Shown { + pub device: DeviceId, + pub device_name: String, + pub view: PlayerView, +} + +pub fn start) + Send + Sync + 'static>(_on_command: F) {} + +pub fn show(_current: Option) {} diff --git a/desktop/app/src/linux/sound.rs b/desktop/app/src/linux/sound.rs new file mode 100644 index 00000000..4deb3b68 --- /dev/null +++ b/desktop/app/src/linux/sound.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +pub fn start_ringing() {} +pub fn stop_ringing() {} diff --git a/desktop/app/src/linux/toast.rs b/desktop/app/src/linux/toast.rs new file mode 100644 index 00000000..00d807c6 --- /dev/null +++ b/desktop/app/src/linux/toast.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::PathBuf; + +pub const AUMID: &str = "Nectarlink.Desktop"; +pub const CALL_ANSWER: &str = "answer"; +pub const CALL_DECLINE: &str = "decline"; +pub const CALL_END: &str = "end"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToastEvent { + Opened { device: String, key: String }, + Action { device: String, key: String, action: String }, + Reply { device: String, key: String, action: String, text: String }, + Dismissed { device: String, key: String }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Toast { + pub device: String, + pub key: String, + pub title: String, + pub body: String, + pub attribution: String, + pub icon: Option, + pub image: Option, + pub actions: Vec<(String, String)>, + pub reply: Option<(String, String)>, + pub silent: bool, + pub progress: Option, + pub call: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Progress { + pub status: String, + pub value: Option, + pub label: String, +} + +pub fn enabled() -> bool { + false +} +pub fn set_process_id() {} +pub fn start(_icon: PathBuf, _on_event: F) {} +pub fn show(_toast: Toast) {} +pub fn update_progress(_device: &str, _key: &str, _progress: Progress) {} +pub fn remove(_device: &str, _key: &str) {} +pub fn remove_device(_device: &str) {} +pub fn unregister() {} diff --git a/desktop/app/src/linux/tray.rs b/desktop/app/src/linux/tray.rs new file mode 100644 index 00000000..e8429986 --- /dev/null +++ b/desktop/app/src/linux/tray.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::io; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayEvent { + Open, + CommandPalette, + Shelf, + TakePhoto, + ScanDocument, + FindPhone, + OpenLinkOnPhone, + Quit, + Resumed, + AppearanceChanged, +} + +#[derive(Debug, Clone)] +pub struct MenuLabels { + pub open: String, + pub command_palette: String, + pub shelf: String, + pub take_photo: String, + pub scan_document: String, + pub find_phone: String, + pub open_link: String, + pub quit: String, +} + +pub const MOD_ALT: u32 = 0x0001; +pub const MOD_CONTROL: u32 = 0x0002; +pub const MOD_SHIFT: u32 = 0x0004; +pub const MOD_WIN: u32 = 0x0008; +pub const MOD_NOREPEAT: u32 = 0x4000; + +pub fn record_foreground_window() {} +pub fn snapshot_target_window() -> Option<(isize, String)> { + None +} +pub fn is_window_alive_and_visible(_hwnd: isize) -> bool { + false +} +pub fn focus_window(_hwnd: isize) -> bool { + false +} +pub fn send_paste_fallback_if_not_foreground(_hwnd: isize) {} +pub fn apply_no_activate_style_by_title(_title: &str) {} +pub fn parse_hotkey(_spec: &str) -> Option<(u32, u32)> { + None +} +pub fn update_hotkeys(_photo_spec: &str, _scan_spec: &str, _palette_spec: &str, _shelf_spec: &str) {} + +pub struct Tray; + +impl Tray { + pub fn create( + _tooltip: &str, + _labels: MenuLabels, + _handler: impl Fn(TrayEvent) + 'static, + ) -> io::Result { + Ok(Tray) + } + pub fn set_tooltip(&self, _text: &str) {} + pub fn set_find_phone_enabled(&self, _enabled: bool) {} +} diff --git a/desktop/app/src/linux/vcam.rs b/desktop/app/src/linux/vcam.rs new file mode 100644 index 00000000..8f1d6cda --- /dev/null +++ b/desktop/app/src/linux/vcam.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::PathBuf; + +pub const CLSID_STR: &str = "{8E14549A-DB61-4309-AFA1-3578E927E935}"; +pub const FRIENDLY_NAME: &str = "NectarLink Virtual Camera"; +pub const REGISTER_ARG: &str = "--register-vcam"; +pub const UNREGISTER_ARG: &str = "--unregister-vcam"; + +pub fn dll_path() -> Option { + None +} +pub fn is_registered() -> bool { + false +} +pub fn ensure_registered() -> bool { + false +} +pub fn register_hklm() -> Result<(), String> { + Err("not supported on Linux yet".into()) +} +pub fn unregister_hklm() -> Result<(), String> { + Ok(()) +} +pub fn run_elevated(_arg: &'static str, on_done: impl FnOnce(bool) + Send + 'static) { + on_done(false); +} + +#[derive(Debug)] +pub struct VirtualCameraHandle; + +impl VirtualCameraHandle { + pub fn start() -> Result { + Err("not supported on Linux yet".into()) + } +} diff --git a/desktop/app/src/linux/vmic.rs b/desktop/app/src/linux/vmic.rs new file mode 100644 index 00000000..ad98bd0d --- /dev/null +++ b/desktop/app/src/linux/vmic.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +pub fn is_available() -> bool { + false +} +pub fn cleanup_stale_sources() {} + +pub struct VirtualMic; + +impl VirtualMic { + pub fn new_for_phone(_rate: u32, _input_channels: u8, _phone_name: Option<&str>) -> Self { + Self + } + pub fn is_active(&self) -> bool { + false + } + pub fn set_gain_db(&mut self, _gain_db: f32) {} + pub fn set_muted(&mut self, _muted: bool) {} + pub fn push_pcm(&mut self, _pcm: &[u8]) -> Result { + Ok(0.0) + } +} diff --git a/desktop/app/src/linux/wallpaper.rs b/desktop/app/src/linux/wallpaper.rs new file mode 100644 index 00000000..4b9be79b --- /dev/null +++ b/desktop/app/src/linux/wallpaper.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::path::Path; + +pub fn seed() -> Option { + None +} +pub fn seed_from_path(_path: &Path) -> Option { + None +} +pub fn artwork_scheme_from_path(_path: &Path) -> Option { + None +} diff --git a/desktop/app/src/linux/widget.rs b/desktop/app/src/linux/widget.rs new file mode 100644 index 00000000..2bebeaf1 --- /dev/null +++ b/desktop/app/src/linux/widget.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +use std::{path::Path, process::ExitCode}; + +pub const ARG: &str = "--widget-provider"; +pub const WIDGET_ID: &str = "NectarlinkPhoneStatus"; + +pub fn write_state_snapshot(_data_dir: &Path) {} +pub fn run_provider(_data_dir: &Path) -> ExitCode { + ExitCode::SUCCESS +} diff --git a/desktop/app/src/linux/wifi.rs b/desktop/app/src/linux/wifi.rs new file mode 100644 index 00000000..b9030a8a --- /dev/null +++ b/desktop/app/src/linux/wifi.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Linux Wi-Fi scan and connection helper for Instant Hotspot (stub). + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reach { + NotInRange, + InRange, + Connected, +} + +pub fn saved_networks() -> Vec { + Vec::new() +} + +pub fn scan() {} + +pub fn reach(_profile: &str) -> Reach { + Reach::NotInRange +} + +pub fn connect(_profile: &str) -> Result<(), String> { + Err("Wi-Fi hotspot connection not supported on Linux yet".into()) +} diff --git a/desktop/app/src/linux/wol.rs b/desktop/app/src/linux/wol.rs new file mode 100644 index 00000000..82ca300b --- /dev/null +++ b/desktop/app/src/linux/wol.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code, unused_variables)] + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WakeSupport { + Enabled, + Disabled, + Unknown, +} + +impl WakeSupport { + pub fn as_str(self) -> &'static str { + match self { + WakeSupport::Enabled => "enabled", + WakeSupport::Disabled => "disabled", + WakeSupport::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WakeAdapter { + pub label: String, + pub wired: bool, + pub mac: String, + pub broadcasts: Vec, + pub support: WakeSupport, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WakeStatus { + pub info: nectarlink_core::PcWakeInfo, + pub adapters: Vec, +} + +impl WakeStatus { + pub fn primary(&self) -> Option<&WakeAdapter> { + self.adapters.first() + } + pub fn state_str(&self) -> &'static str { + "none" + } +} + +pub fn query_wake_status() -> WakeStatus { + WakeStatus { info: nectarlink_core::PcWakeInfo::default(), adapters: Vec::new() } +} diff --git a/desktop/app/src/main.rs b/desktop/app/src/main.rs index f7c1c138..24e20ea3 100644 --- a/desktop/app/src/main.rs +++ b/desktop/app/src/main.rs @@ -58,6 +58,9 @@ mod updater; mod webcam; #[cfg(windows)] mod win; +#[cfg(target_os = "linux")] +#[path = "linux/mod.rs"] +mod win; #[cfg(target_os = "macos")] #[path = "mac/mod.rs"] mod win; diff --git a/desktop/packaging/linux/build-appimage.sh b/desktop/packaging/linux/build-appimage.sh new file mode 100755 index 00000000..279e6879 --- /dev/null +++ b/desktop/packaging/linux/build-appimage.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Builds an AppDir layout (including bundled Qt 6 runtime libraries, plugins, +# and QML modules) and invokes appimagetool (if present in PATH) to produce +# dist/Nectarlink--.AppImage. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +DESKTOP_DIR="${REPO_ROOT}/desktop/app" + +VERSION="${VERSION:-$(awk -F'"' '/^version[[:space:]]*=[[:space:]]*"/ { print $2; exit }' "${REPO_ROOT}/Cargo.toml")}" +if [[ -z "${VERSION}" ]]; then + echo "error: could not determine version from ${REPO_ROOT}/Cargo.toml" >&2 + exit 1 +fi + +ARCH="${ARCH:-$(uname -m)}" + +# Locate Qt 6 qmake if available so we can bundle the matching Qt 6.12+ runtime. +if [[ -z "${QMAKE:-}" ]]; then + if [[ -n "${QT_ROOT_DIR:-}" && -x "${QT_ROOT_DIR}/bin/qmake" ]]; then + export QMAKE="${QT_ROOT_DIR}/bin/qmake" + elif [[ -x "${REPO_ROOT}/.qt6-sysroot/6.12.0/gcc_64/bin/qmake" ]]; then + export QMAKE="${REPO_ROOT}/.qt6-sysroot/6.12.0/gcc_64/bin/qmake" + elif command -v qmake6 >/dev/null 2>&1; then + export QMAKE="$(command -v qmake6)" + elif command -v qmake >/dev/null 2>&1; then + export QMAKE="$(command -v qmake)" + fi +fi + +BIN_PATH="${BIN_PATH:-${REPO_ROOT}/target/release/nectarlink-desktop}" +if [[ ! -f "${BIN_PATH}" ]]; then + echo "Building nectarlink-desktop (release)..." + (cd "${REPO_ROOT}" && cargo build --release -p nectarlink-desktop) +fi + +STAGE_DIR="$(mktemp -d -t nectarlink-appimage-XXXXXX)" +trap 'rm -rf "${STAGE_DIR}"' EXIT + +APPDIR="${STAGE_DIR}/NectarLink.AppDir" +mkdir -p \ + "${APPDIR}/usr/bin" \ + "${APPDIR}/usr/lib" \ + "${APPDIR}/usr/share/nectarlink" \ + "${APPDIR}/usr/share/applications" \ + "${APPDIR}/usr/share/metainfo" \ + "${APPDIR}/usr/share/icons/hicolor/256x256/apps" \ + "${APPDIR}/usr/share/icons/hicolor/512x512/apps" \ + "${APPDIR}/usr/share/icons/hicolor/scalable/apps" + +install -m 0755 "${BIN_PATH}" "${APPDIR}/usr/bin/nectarlink-desktop" + +for qm in "$(dirname "${BIN_PATH}")"/nectarlink_*.qm; do + if [[ -f "${qm}" ]]; then + install -m 0644 "${qm}" "${APPDIR}/usr/share/nectarlink/$(basename "${qm}")" + fi +done + +install -m 0644 "${SCRIPT_DIR}/nectarlink.desktop" "${APPDIR}/usr/share/applications/nectarlink.desktop" +install -m 0644 "${SCRIPT_DIR}/nectarlink.desktop" "${APPDIR}/nectarlink.desktop" +install -m 0644 "${SCRIPT_DIR}/org.nectarlink.desktop.metainfo.xml" "${APPDIR}/usr/share/metainfo/org.nectarlink.desktop.metainfo.xml" + +if [[ -f "${REPO_ROOT}/assets/brand/icon.svg" ]]; then + install -m 0644 "${REPO_ROOT}/assets/brand/icon.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/nectarlink.svg" + install -m 0644 "${REPO_ROOT}/assets/brand/icon.svg" "${APPDIR}/nectarlink.svg" +fi +if [[ -f "${REPO_ROOT}/assets/brand/icon-512.png" ]]; then + install -m 0644 "${REPO_ROOT}/assets/brand/icon-512.png" "${APPDIR}/usr/share/icons/hicolor/512x512/apps/nectarlink.png" + install -m 0644 "${REPO_ROOT}/assets/brand/icon-512.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/nectarlink.png" + install -m 0644 "${REPO_ROOT}/assets/brand/icon-512.png" "${APPDIR}/nectarlink.png" +fi + +bundle_qt_runtime() { + local qmake_bin="${QMAKE:-}" + if [[ -z "${qmake_bin}" || ! -x "${qmake_bin}" ]]; then + return 0 + fi + local qt_libs qt_plugins qt_qml + qt_libs="$("${qmake_bin}" -query QT_INSTALL_LIBS 2>/dev/null || true)" + qt_plugins="$("${qmake_bin}" -query QT_INSTALL_PLUGINS 2>/dev/null || true)" + qt_qml="$("${qmake_bin}" -query QT_INSTALL_QML 2>/dev/null || true)" + if [[ -z "${qt_libs}" || ! -d "${qt_libs}" ]]; then + return 0 + fi + + echo "Bundling Qt runtime from ${qt_libs}..." + mkdir -p "${APPDIR}/usr/lib" "${APPDIR}/usr/plugins" "${APPDIR}/usr/qml" + + local pattern + for pattern in \ + "libQt6Core.so*" \ + "libQt6Gui.so*" \ + "libQt6Qml*.so*" \ + "libQt6Quick*.so*" \ + "libQt6Network.so*" \ + "libQt6OpenGL.so*" \ + "libQt6Svg.so*" \ + "libQt6DBus.so*" \ + "libQt6XcbQpa.so*" \ + "libQt6WaylandClient.so*" \ + "libQt6WaylandEglClientHwIntegration.so*" \ + "libQt6WlShellIntegration.so*" \ + "libQt6ShaderTools.so*" \ + "libicui18n.so*" \ + "libicuuc.so*" \ + "libicudata.so*" + do + compgen -G "${qt_libs}/${pattern}" >/dev/null && cp -a "${qt_libs}"/${pattern} "${APPDIR}/usr/lib/" || true + done + + if [[ -n "${qt_plugins}" && -d "${qt_plugins}" ]]; then + local plug_dir + for plug_dir in \ + platforms \ + platformthemes \ + wayland-decoration-client \ + wayland-graphics-integration-client \ + wayland-shell-integration \ + xcbglintegrations \ + imageformats \ + iconengines \ + tls + do + if [[ -d "${qt_plugins}/${plug_dir}" ]]; then + cp -a "${qt_plugins}/${plug_dir}" "${APPDIR}/usr/plugins/" + fi + done + fi + + if [[ -n "${qt_qml}" && -d "${qt_qml}" ]]; then + local qml_dir + for qml_dir in QtQuick QtQml QtCore Qt; do + if [[ -d "${qt_qml}/${qml_dir}" ]]; then + cp -a "${qt_qml}/${qml_dir}" "${APPDIR}/usr/qml/" + fi + done + fi + + cat > "${APPDIR}/usr/bin/qt.conf" <<'QTEOF' +[Paths] +Prefix = .. +Libraries = lib +Plugins = plugins +Qml2Imports = qml +QTEOF +} + +bundle_qt_runtime + +cat > "${APPDIR}/AppRun" <<'EOF' +#!/usr/bin/env sh +HERE="$(dirname "$(readlink -f "$0")")" +export PATH="${HERE}/usr/bin:${PATH}" +export QT_PLUGIN_PATH="${HERE}/usr/plugins${QT_PLUGIN_PATH:+:${QT_PLUGIN_PATH}}" +export QML2_IMPORT_PATH="${HERE}/usr/qml${QML2_IMPORT_PATH:+:${QML2_IMPORT_PATH}}" +exec "${HERE}/usr/bin/nectarlink-desktop" "$@" +EOF +chmod 0755 "${APPDIR}/AppRun" + +OUT_DIR="${OUT_DIR:-${REPO_ROOT}/dist}" +mkdir -p "${OUT_DIR}" +OUT_APPIMAGE="${OUT_DIR}/Nectarlink-${VERSION}-${ARCH}.AppImage" + +if command -v appimagetool >/dev/null 2>&1; then + ARCH="${ARCH}" appimagetool "${APPDIR}" "${OUT_APPIMAGE}" + echo "Built ${OUT_APPIMAGE}" +else + echo "appimagetool not found in PATH; AppDir staged at ${APPDIR}" >&2 + exit 0 +fi diff --git a/desktop/packaging/linux/build-deb.sh b/desktop/packaging/linux/build-deb.sh new file mode 100755 index 00000000..6a6d33c9 --- /dev/null +++ b/desktop/packaging/linux/build-deb.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Builds a Debian/Ubuntu .deb package for NectarLink Desktop: +# dist/nectarlink__.deb + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +DESKTOP_DIR="${REPO_ROOT}/desktop/app" + +VERSION="${VERSION:-$(awk -F'"' '/^version[[:space:]]*=[[:space:]]*"/ { print $2; exit }' "${REPO_ROOT}/Cargo.toml")}" +if [[ -z "${VERSION}" ]]; then + echo "error: could not determine version from ${REPO_ROOT}/Cargo.toml" >&2 + exit 1 +fi + +ARCH="${ARCH:-$(dpkg --print-architecture 2>/dev/null || uname -m)}" +case "${ARCH}" in + x86_64|amd64) DEB_ARCH="amd64" ;; + aarch64|arm64) DEB_ARCH="arm64" ;; + *) DEB_ARCH="${ARCH}" ;; +esac + +# Locate Qt 6 qmake if available so we can bundle a private Qt 6.12+ runtime +# when built against a non-system Qt SDK (e.g. aqtinstall on Ubuntu 24.04). +if [[ -z "${QMAKE:-}" ]]; then + if [[ -n "${QT_ROOT_DIR:-}" && -x "${QT_ROOT_DIR}/bin/qmake" ]]; then + export QMAKE="${QT_ROOT_DIR}/bin/qmake" + elif [[ -x "${REPO_ROOT}/.qt6-sysroot/6.12.0/gcc_64/bin/qmake" ]]; then + export QMAKE="${REPO_ROOT}/.qt6-sysroot/6.12.0/gcc_64/bin/qmake" + fi +fi + +BIN_PATH="${BIN_PATH:-${REPO_ROOT}/target/release/nectarlink-desktop}" +if [[ ! -f "${BIN_PATH}" ]]; then + echo "Building nectarlink-desktop (release)..." + (cd "${REPO_ROOT}" && cargo build --release -p nectarlink-desktop) +fi + +STAGE_DIR="$(mktemp -d -t nectarlink-deb-XXXXXX)" +trap 'rm -rf "${STAGE_DIR}"' EXIT + +PKG_ROOT="${STAGE_DIR}/nectarlink_${VERSION}_${DEB_ARCH}" +mkdir -p \ + "${PKG_ROOT}/DEBIAN" \ + "${PKG_ROOT}/usr/bin" \ + "${PKG_ROOT}/usr/lib/nectarlink" \ + "${PKG_ROOT}/usr/share/nectarlink" \ + "${PKG_ROOT}/usr/share/applications" \ + "${PKG_ROOT}/usr/share/metainfo" \ + "${PKG_ROOT}/usr/share/icons/hicolor/256x256/apps" \ + "${PKG_ROOT}/usr/share/icons/hicolor/512x512/apps" \ + "${PKG_ROOT}/usr/share/icons/hicolor/scalable/apps" + +install -m 0755 "${BIN_PATH}" "${PKG_ROOT}/usr/lib/nectarlink/nectarlink-desktop" +ln -sf ../lib/nectarlink/nectarlink-desktop "${PKG_ROOT}/usr/bin/nectarlink-desktop" + +for qm in "$(dirname "${BIN_PATH}")"/nectarlink_*.qm; do + if [[ -f "${qm}" ]]; then + install -m 0644 "${qm}" "${PKG_ROOT}/usr/share/nectarlink/$(basename "${qm}")" + fi +done + +install -m 0644 "${SCRIPT_DIR}/nectarlink.desktop" "${PKG_ROOT}/usr/share/applications/nectarlink.desktop" +install -m 0644 "${SCRIPT_DIR}/org.nectarlink.desktop.metainfo.xml" "${PKG_ROOT}/usr/share/metainfo/org.nectarlink.desktop.metainfo.xml" + +if [[ -f "${REPO_ROOT}/assets/brand/icon-512.png" ]]; then + install -m 0644 "${REPO_ROOT}/assets/brand/icon-512.png" "${PKG_ROOT}/usr/share/icons/hicolor/512x512/apps/nectarlink.png" + install -m 0644 "${REPO_ROOT}/assets/brand/icon-512.png" "${PKG_ROOT}/usr/share/icons/hicolor/256x256/apps/nectarlink.png" +fi +if [[ -f "${REPO_ROOT}/assets/brand/icon.svg" ]]; then + install -m 0644 "${REPO_ROOT}/assets/brand/icon.svg" "${PKG_ROOT}/usr/share/icons/hicolor/scalable/apps/nectarlink.svg" +fi + +QT_DEPENDS="" +bundle_private_qt_if_needed() { + local qmake_bin="${QMAKE:-}" + if [[ -z "${qmake_bin}" || ! -x "${qmake_bin}" ]]; then + QT_DEPENDS=", libqt6core6t64 | libqt6core6, libqt6gui6, libqt6qml6, libqt6quick6, libqt6network6, libqt6opengl6, libqt6dbus6, libqt6svg6, qml6-module-qtquick, qml6-module-qtquick-Window, qml6-module-qtquick-controls, qml6-module-qtquick-layouts, qml6-module-qtqml-workerscript" + return 0 + fi + local qt_libs qt_plugins qt_qml + qt_libs="$("${qmake_bin}" -query QT_INSTALL_LIBS 2>/dev/null || true)" + qt_plugins="$("${qmake_bin}" -query QT_INSTALL_PLUGINS 2>/dev/null || true)" + qt_qml="$("${qmake_bin}" -query QT_INSTALL_QML 2>/dev/null || true)" + if [[ -z "${qt_libs}" || "${qt_libs}" == /usr/* ]]; then + QT_DEPENDS=", libqt6core6t64 | libqt6core6, libqt6gui6, libqt6qml6, libqt6quick6, libqt6network6, libqt6opengl6, libqt6dbus6, libqt6svg6, qml6-module-qtquick, qml6-module-qtquick-window, qml6-module-qtquick-controls, qml6-module-qtquick-layouts, qml6-module-qtqml-workerscript" + return 0 + fi + + echo "Bundling private Qt runtime from ${qt_libs} into /usr/lib/nectarlink..." + local dest="${PKG_ROOT}/usr/lib/nectarlink" + mkdir -p "${dest}/lib" "${dest}/plugins" "${dest}/qml" + + local pattern + for pattern in \ + "libQt6Core.so*" \ + "libQt6Gui.so*" \ + "libQt6Qml*.so*" \ + "libQt6Quick*.so*" \ + "libQt6Network.so*" \ + "libQt6OpenGL.so*" \ + "libQt6Svg.so*" \ + "libQt6DBus.so*" \ + "libQt6XcbQpa.so*" \ + "libQt6WaylandClient.so*" \ + "libQt6WaylandEglClientHwIntegration.so*" \ + "libQt6WlShellIntegration.so*" \ + "libQt6ShaderTools.so*" \ + "libicui18n.so*" \ + "libicuuc.so*" \ + "libicudata.so*" + do + compgen -G "${qt_libs}/${pattern}" >/dev/null && cp -a "${qt_libs}"/${pattern} "${dest}/lib/" || true + done + + if [[ -n "${qt_plugins}" && -d "${qt_plugins}" ]]; then + local plug_dir + for plug_dir in \ + platforms \ + platformthemes \ + wayland-decoration-client \ + wayland-graphics-integration-client \ + wayland-shell-integration \ + xcbglintegrations \ + imageformats \ + iconengines \ + tls + do + if [[ -d "${qt_plugins}/${plug_dir}" ]]; then + cp -a "${qt_plugins}/${plug_dir}" "${dest}/plugins/" + fi + done + fi + + if [[ -n "${qt_qml}" && -d "${qt_qml}" ]]; then + local qml_dir + for qml_dir in QtQuick QtQml QtCore Qt; do + if [[ -d "${qt_qml}/${qml_dir}" ]]; then + cp -a "${qt_qml}/${qml_dir}" "${dest}/qml/" + fi + done + fi + + cat > "${dest}/qt.conf" <<'QTEOF' +[Paths] +Prefix = . +Libraries = lib +Plugins = plugins +Qml2Imports = qml +QTEOF +} + +bundle_private_qt_if_needed + +INSTALLED_SIZE="$(du -sk "${PKG_ROOT}/usr" | awk '{print $1}')" + +cat > "${PKG_ROOT}/DEBIAN/control" < +Homepage: https://github.com/thtbee/nectarlink +Description: Connect your Android phone and Linux PC over LAN + NectarLink links your Android phone and Linux desktop over your local + network with end-to-end encryption, clipboard sync, notification + mirroring, file transfer, screen/camera/audio streaming, and SMS/calls. +EOF + +cat > "${PKG_ROOT}/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true +fi +exit 0 +EOF +chmod 0755 "${PKG_ROOT}/DEBIAN/postinst" + +cat > "${PKG_ROOT}/DEBIAN/postrm" <<'EOF' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true +fi +exit 0 +EOF +chmod 0755 "${PKG_ROOT}/DEBIAN/postrm" + +OUT_DIR="${OUT_DIR:-${REPO_ROOT}/dist}" +mkdir -p "${OUT_DIR}" +OUT_DEB="${OUT_DIR}/nectarlink_${VERSION}_${DEB_ARCH}.deb" + +dpkg-deb --root-owner-group --build "${PKG_ROOT}" "${OUT_DEB}" +echo "Built ${OUT_DEB}" diff --git a/desktop/packaging/linux/build-tarball.sh b/desktop/packaging/linux/build-tarball.sh new file mode 100755 index 00000000..41f1d3eb --- /dev/null +++ b/desktop/packaging/linux/build-tarball.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Builds a portable Linux tarball for NectarLink Desktop: +# dist/Nectarlink--linux-.tar.gz +# including install.sh and uninstall.sh helper scripts. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +DESKTOP_DIR="${REPO_ROOT}/desktop/app" + +VERSION="${VERSION:-$(awk -F'"' '/^version[[:space:]]*=[[:space:]]*"/ { print $2; exit }' "${REPO_ROOT}/Cargo.toml")}" +if [[ -z "${VERSION}" ]]; then + echo "error: could not determine version from ${REPO_ROOT}/Cargo.toml" >&2 + exit 1 +fi + +ARCH="${ARCH:-$(uname -m)}" +# Locate Qt 6 qmake if available so we can bundle a private Qt 6.12+ runtime +# when built against a non-system Qt SDK (e.g. aqtinstall on Ubuntu 24.04). +if [[ -z "${QMAKE:-}" ]]; then + if [[ -n "${QT_ROOT_DIR:-}" && -x "${QT_ROOT_DIR}/bin/qmake" ]]; then + export QMAKE="${QT_ROOT_DIR}/bin/qmake" + elif [[ -x "${REPO_ROOT}/.qt6-sysroot/6.12.0/gcc_64/bin/qmake" ]]; then + export QMAKE="${REPO_ROOT}/.qt6-sysroot/6.12.0/gcc_64/bin/qmake" + fi +fi + +BIN_PATH="${BIN_PATH:-${REPO_ROOT}/target/release/nectarlink-desktop}" +if [[ ! -f "${BIN_PATH}" ]]; then + echo "Building nectarlink-desktop (release)..." + (cd "${REPO_ROOT}" && cargo build --release -p nectarlink-desktop) +fi + +STAGE_DIR="$(mktemp -d -t nectarlink-tar-XXXXXX)" +trap 'rm -rf "${STAGE_DIR}"' EXIT + +BUNDLE_NAME="Nectarlink-${VERSION}-linux-${ARCH}" +BUNDLE_DIR="${STAGE_DIR}/${BUNDLE_NAME}" +mkdir -p "${BUNDLE_DIR}/share/applications" "${BUNDLE_DIR}/share/metainfo" "${BUNDLE_DIR}/share/icons" "${BUNDLE_DIR}/share/nectarlink" + +install -m 0755 "${BIN_PATH}" "${BUNDLE_DIR}/nectarlink-desktop" +printf '%s\n' "${VERSION}" > "${BUNDLE_DIR}/VERSION" +for qm in "$(dirname "${BIN_PATH}")"/nectarlink_*.qm; do + if [[ -f "${qm}" ]]; then + install -m 0644 "${qm}" "${BUNDLE_DIR}/$(basename "${qm}")" + install -m 0644 "${qm}" "${BUNDLE_DIR}/share/nectarlink/$(basename "${qm}")" + fi +done +install -m 0644 "${SCRIPT_DIR}/nectarlink.desktop" "${BUNDLE_DIR}/share/applications/nectarlink.desktop" +install -m 0644 "${SCRIPT_DIR}/org.nectarlink.desktop.metainfo.xml" "${BUNDLE_DIR}/share/metainfo/org.nectarlink.desktop.metainfo.xml" + +if [[ -f "${REPO_ROOT}/assets/brand/icon.svg" ]]; then + install -m 0644 "${REPO_ROOT}/assets/brand/icon.svg" "${BUNDLE_DIR}/share/icons/nectarlink.svg" +fi +if [[ -f "${REPO_ROOT}/assets/brand/icon-512.png" ]]; then + install -m 0644 "${REPO_ROOT}/assets/brand/icon-512.png" "${BUNDLE_DIR}/share/icons/nectarlink.png" +fi + +bundle_private_qt_if_needed() { + local qmake_bin="${QMAKE:-}" + if [[ -z "${qmake_bin}" || ! -x "${qmake_bin}" ]]; then + return 0 + fi + local qt_libs qt_plugins qt_qml + qt_libs="$("${qmake_bin}" -query QT_INSTALL_LIBS 2>/dev/null || true)" + qt_plugins="$("${qmake_bin}" -query QT_INSTALL_PLUGINS 2>/dev/null || true)" + qt_qml="$("${qmake_bin}" -query QT_INSTALL_QML 2>/dev/null || true)" + if [[ -z "${qt_libs}" || "${qt_libs}" == /usr/* ]]; then + return 0 + fi + + echo "Bundling private Qt runtime from ${qt_libs} into tarball..." + mkdir -p "${BUNDLE_DIR}/lib" "${BUNDLE_DIR}/plugins" "${BUNDLE_DIR}/qml" + + local pattern + for pattern in \ + "libQt6Core.so*" \ + "libQt6Gui.so*" \ + "libQt6Qml*.so*" \ + "libQt6Quick*.so*" \ + "libQt6Network.so*" \ + "libQt6OpenGL.so*" \ + "libQt6Svg.so*" \ + "libQt6DBus.so*" \ + "libQt6XcbQpa.so*" \ + "libQt6WaylandClient.so*" \ + "libQt6WaylandEglClientHwIntegration.so*" \ + "libQt6WlShellIntegration.so*" \ + "libQt6ShaderTools.so*" \ + "libicui18n.so*" \ + "libicuuc.so*" \ + "libicudata.so*" + do + compgen -G "${qt_libs}/${pattern}" >/dev/null && cp -a "${qt_libs}"/${pattern} "${BUNDLE_DIR}/lib/" || true + done + + if [[ -n "${qt_plugins}" && -d "${qt_plugins}" ]]; then + local plug_dir + for plug_dir in \ + platforms \ + platformthemes \ + wayland-decoration-client \ + wayland-graphics-integration-client \ + wayland-shell-integration \ + xcbglintegrations \ + imageformats \ + iconengines \ + tls + do + if [[ -d "${qt_plugins}/${plug_dir}" ]]; then + cp -a "${qt_plugins}/${plug_dir}" "${BUNDLE_DIR}/plugins/" + fi + done + fi + + if [[ -n "${qt_qml}" && -d "${qt_qml}" ]]; then + local qml_dir + for qml_dir in QtQuick QtQml QtCore Qt; do + if [[ -d "${qt_qml}/${qml_dir}" ]]; then + cp -a "${qt_qml}/${qml_dir}" "${BUNDLE_DIR}/qml/" + fi + done + fi + + cat > "${BUNDLE_DIR}/qt.conf" <<'QTEOF' +[Paths] +Prefix = . +Libraries = lib +Plugins = plugins +Qml2Imports = qml +QTEOF +} + +bundle_private_qt_if_needed + +cat > "${BUNDLE_DIR}/install.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then + PREFIX="/opt/nectarlink" + BIN_LINK="/usr/local/bin/nectarlink-desktop" + APPS_DIR="/usr/share/applications" + META_DIR="/usr/share/metainfo" + ICON_DIR="/usr/share/icons/hicolor/scalable/apps" + PNG_ICON_DIR="/usr/share/icons/hicolor/512x512/apps" +else + PREFIX="${XDG_DATA_HOME:-$HOME/.local/share}/nectarlink-app" + BIN_LINK="${HOME}/.local/bin/nectarlink-desktop" + APPS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" + META_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/metainfo" + ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps" + PNG_ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/512x512/apps" +fi + +mkdir -p "${PREFIX}" "$(dirname "${BIN_LINK}")" "${APPS_DIR}" "${META_DIR}" "${ICON_DIR}" "${PNG_ICON_DIR}" +install -m 0755 "${SELF_DIR}/nectarlink-desktop" "${PREFIX}/nectarlink-desktop" +[[ -f "${SELF_DIR}/VERSION" ]] && install -m 0644 "${SELF_DIR}/VERSION" "${PREFIX}/VERSION" +[[ -f "${SELF_DIR}/qt.conf" ]] && install -m 0644 "${SELF_DIR}/qt.conf" "${PREFIX}/qt.conf" +for dir in lib plugins qml; do + if [[ -d "${SELF_DIR}/${dir}" ]]; then + rm -rf "${PREFIX:?}/${dir}" + cp -a "${SELF_DIR}/${dir}" "${PREFIX}/${dir}" + fi +done +for qm in "${SELF_DIR}"/nectarlink_*.qm; do + [[ -f "${qm}" ]] && install -m 0644 "${qm}" "${PREFIX}/$(basename "${qm}")" +done +ln -sf "${PREFIX}/nectarlink-desktop" "${BIN_LINK}" +install -m 0644 "${SELF_DIR}/share/applications/nectarlink.desktop" "${APPS_DIR}/nectarlink.desktop" +install -m 0644 "${SELF_DIR}/share/metainfo/org.nectarlink.desktop.metainfo.xml" "${META_DIR}/org.nectarlink.desktop.metainfo.xml" +[[ -f "${SELF_DIR}/share/icons/nectarlink.svg" ]] && install -m 0644 "${SELF_DIR}/share/icons/nectarlink.svg" "${ICON_DIR}/nectarlink.svg" +[[ -f "${SELF_DIR}/share/icons/nectarlink.png" ]] && install -m 0644 "${SELF_DIR}/share/icons/nectarlink.png" "${PNG_ICON_DIR}/nectarlink.png" + +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q "${APPS_DIR}" || true +fi +echo "Installed NectarLink to ${PREFIX} (${BIN_LINK})" +EOF +chmod 0755 "${BUNDLE_DIR}/install.sh" + +cat > "${BUNDLE_DIR}/uninstall.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then + rm -rf /opt/nectarlink + rm -f /usr/local/bin/nectarlink-desktop + rm -f /usr/share/applications/nectarlink.desktop + rm -f /usr/share/metainfo/org.nectarlink.desktop.metainfo.xml + rm -f /usr/share/icons/hicolor/scalable/apps/nectarlink.svg + rm -f /usr/share/icons/hicolor/512x512/apps/nectarlink.png +else + PREFIX="${XDG_DATA_HOME:-$HOME/.local/share}/nectarlink-app" + rm -rf "${PREFIX}" + rm -f "${HOME}/.local/bin/nectarlink-desktop" + rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/applications/nectarlink.desktop" + rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/metainfo/org.nectarlink.desktop.metainfo.xml" + rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps/nectarlink.svg" + rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/512x512/apps/nectarlink.png" +fi +echo "Uninstalled NectarLink." +EOF +chmod 0755 "${BUNDLE_DIR}/uninstall.sh" + +OUT_DIR="${OUT_DIR:-${REPO_ROOT}/dist}" +mkdir -p "${OUT_DIR}" +OUT_TAR="${OUT_DIR}/${BUNDLE_NAME}.tar.gz" + +tar -C "${STAGE_DIR}" -czf "${OUT_TAR}" "${BUNDLE_NAME}" +echo "Built ${OUT_TAR}" diff --git a/desktop/packaging/linux/nectarlink.desktop b/desktop/packaging/linux/nectarlink.desktop new file mode 100644 index 00000000..091ac9b6 --- /dev/null +++ b/desktop/packaging/linux/nectarlink.desktop @@ -0,0 +1,35 @@ +[Desktop Entry] +Type=Application +Version=1.5 +Name=NectarLink +GenericName=Phone Link +Comment=Connect your Android phone and Linux PC over LAN +Exec=nectarlink-desktop %U +Icon=nectarlink +Terminal=false +Categories=Network;Utility;FileTransfer;Telephony; +Keywords=android;phone;sync;clipboard;notifications;files;mirror;sms;calls; +StartupNotify=true +StartupWMClass=nectarlink-desktop +MimeType=x-scheme-handler/nectarlink;x-scheme-handler/tel;x-scheme-handler/sms;x-scheme-handler/smsto;x-scheme-handler/mms;x-scheme-handler/mmsto; +Actions=send-files;send-clipboard;find-phone;take-photo;open-timeline; + +[Desktop Action send-files] +Name=Send files to phone +Exec=nectarlink-desktop --task send-files + +[Desktop Action send-clipboard] +Name=Send clipboard to phone +Exec=nectarlink-desktop --task send-clipboard + +[Desktop Action find-phone] +Name=Find my phone +Exec=nectarlink-desktop --task find-phone + +[Desktop Action take-photo] +Name=Take photo with phone +Exec=nectarlink-desktop --task take-photo + +[Desktop Action open-timeline] +Name=Open Timeline +Exec=nectarlink-desktop --task open-timeline diff --git a/desktop/packaging/linux/org.nectarlink.desktop.metainfo.xml b/desktop/packaging/linux/org.nectarlink.desktop.metainfo.xml new file mode 100644 index 00000000..8186cac5 --- /dev/null +++ b/desktop/packaging/linux/org.nectarlink.desktop.metainfo.xml @@ -0,0 +1,38 @@ + + + org.nectarlink.desktop + NectarLink + Connect your Android phone and Linux PC over LAN + CC0-1.0 + GPL-3.0-or-later + + thtbee + + +

+ NectarLink links your Android phone and Linux desktop over your local network with end-to-end encryption. +

+
    +
  • Sync clipboard text and images bidirectionally
  • +
  • Mirror phone notifications and reply inline from your desktop
  • +
  • Send and receive files, folders, and browsable phone storage via SFTP
  • +
  • Stream phone screen, camera, and audio with low latency
  • +
  • Read and send SMS/MMS messages and manage phone calls
  • +
+
+ nectarlink.desktop + https://github.com/thtbee/nectarlink + https://github.com/thtbee/nectarlink/issues + + nectarlink-desktop + x-scheme-handler/tel + x-scheme-handler/sms + x-scheme-handler/smsto + x-scheme-handler/mms + x-scheme-handler/mmsto + + + + + +
diff --git a/desktop/vcam/Cargo.toml b/desktop/vcam/Cargo.toml index 6b148d5d..4297d50d 100644 --- a/desktop/vcam/Cargo.toml +++ b/desktop/vcam/Cargo.toml @@ -13,8 +13,13 @@ publish = false name = "nectarlink_vcam" crate-type = ["cdylib", "rlib"] -[target.'cfg(windows)'.dependencies] +[dependencies] ab_glyph = "0.2" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Media_KernelStreaming", diff --git a/desktop/vcam/src/lib.rs b/desktop/vcam/src/lib.rs index 72e93e44..4bddc1ef 100644 --- a/desktop/vcam/src/lib.rs +++ b/desktop/vcam/src/lib.rs @@ -17,12 +17,10 @@ use windows::{ core::{GUID, HRESULT, IUnknown, Interface}, }; -#[cfg(windows)] pub mod shm; #[cfg(windows)] pub mod source; -#[cfg(windows)] pub use shm::{ DEFAULT_MAPPING_NAME, LOCAL_MAPPING_NAME, MAX_FRAME_BYTES, MAX_HEIGHT, MAX_WIDTH, SharedFrameMapping, bgrx_to_nv12, fit_bgrx_aspect, render_placeholder_bgrx, @@ -30,60 +28,11 @@ pub use shm::{ #[cfg(windows)] pub use source::{SAMPLE_DURATION_100NS, VcamClassFactory, VcamMediaSource, VcamMediaStream}; +/// CLSID of the NectarLink Virtual Camera Media Foundation media source: +/// `{8E6C3B74-5D4A-4B9E-9A12-7C8F1E2D3A40}`. #[cfg(windows)] pub const CLSID_NECTARLINK_VCAM: GUID = GUID::from_u128(0x8e6c3b74_5d4a_4b9e_9a12_7c8f1e2d3a40); -#[cfg(not(windows))] -pub const DEFAULT_MAPPING_NAME: &str = ""; -#[cfg(not(windows))] -pub const LOCAL_MAPPING_NAME: &str = ""; -#[cfg(not(windows))] -pub const MAX_FRAME_BYTES: usize = 0; -#[cfg(not(windows))] -pub const MAX_HEIGHT: u32 = 0; -#[cfg(not(windows))] -pub const MAX_WIDTH: u32 = 0; -#[cfg(not(windows))] -pub const SAMPLE_DURATION_100NS: i64 = 0; -#[cfg(not(windows))] -pub const CLSID_NECTARLINK_VCAM: [u8; 16] = [0; 16]; - -#[cfg(not(windows))] -#[derive(Debug)] -pub struct SharedFrameMapping; - -#[cfg(not(windows))] -impl SharedFrameMapping { - pub fn open_or_create(_name: &str) -> Result { - Err("virtual camera is only supported on Windows".into()) - } - pub fn open_existing(_name: &str) -> Result { - Err("virtual camera is only supported on Windows".into()) - } - pub fn write_frame( - &self, - _device_name: &str, - _w: u32, - _h: u32, - _timestamp: u64, - _bgrx: &[u8], - _mirror: bool, - ) { - } - pub fn set_idle(&self, _device_name: &str) {} -} - -#[cfg(not(windows))] -pub fn render_placeholder_bgrx(_bgrx: &mut [u8], _w: u32, _h: u32, _text: &str) {} -#[cfg(not(windows))] -pub fn bgrx_to_nv12(_bgrx: &[u8], _w: u32, _h: u32) -> Vec { - Vec::new() -} -#[cfg(not(windows))] -pub fn fit_bgrx_aspect(_bgrx: &[u8], _sw: u32, _sh: u32, _tw: u32, _th: u32) -> Vec { - Vec::new() -} - /// Standard COM DLL entry point returning an `IClassFactory` for /// [`CLSID_NECTARLINK_VCAM`]. /// @@ -125,10 +74,11 @@ pub extern "system" fn DllCanUnloadNow() -> HRESULT { if source::ACTIVE_OBJECTS.load(Ordering::Relaxed) == 0 { S_OK } else { S_FALSE } } -#[cfg(all(test, windows))] +#[cfg(test)] #[allow(unsafe_code)] mod tests { use super::*; + #[cfg(windows)] use windows::Win32::{ Media::{ KernelStreaming::PINNAME_VIDEO_CAPTURE, @@ -145,6 +95,7 @@ mod tests { System::Com::StructuredStorage::PROPVARIANT, }; + #[cfg(windows)] #[test] fn dll_get_class_object_activates_media_source_in_process() { unsafe { @@ -164,6 +115,7 @@ mod tests { } } + #[cfg(windows)] #[test] fn media_source_delivers_placeholder_and_live_shared_memory_samples() { unsafe { diff --git a/desktop/vcam/src/shm.rs b/desktop/vcam/src/shm.rs index af215dff..462da72e 100644 --- a/desktop/vcam/src/shm.rs +++ b/desktop/vcam/src/shm.rs @@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering}; use ab_glyph::{Font, FontRef, GlyphId, PxScale, ScaleFont, point}; +#[cfg(windows)] use windows::{ Win32::{ Foundation::{CloseHandle, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, LocalFree}, @@ -31,11 +32,13 @@ pub const DEFAULT_MAPPING_NAME: &str = "Global\\NectarlinkVcamFrame_v2"; /// created (the media source running in-process, as in tests). pub const LOCAL_MAPPING_NAME: &str = "Local\\NectarlinkVcamFrame_v2"; /// Wide-string mapping name for local v2 virtual camera frame transport. +#[cfg(windows)] pub const MAPPING_NAME: PCWSTR = w!("Local\\NectarlinkVirtualCamera_v2"); /// Who may use the global mapping: the system, the Frame Server's /// LocalService account and administrators fully, signed-in (interactive) /// users read and write (the app), nobody else. +#[cfg(windows)] const GLOBAL_SDDL: PCWSTR = w!("D:P(A;;GA;;;SY)(A;;GA;;;LS)(A;;GA;;;BA)(A;;GRGW;;;IU)"); pub const MAX_WIDTH: u32 = 3840; @@ -65,11 +68,19 @@ pub struct SharedHeader { } /// Shared-memory mapping handle and view. Safe to share across threads. +#[cfg(windows)] pub struct SharedFrameMapping { handle: HANDLE, view: MEMORY_MAPPED_VIEW_ADDRESS, } +/// Shared-memory mapping file descriptor and mapped view on Unix (`/dev/shm`). +#[cfg(unix)] +pub struct SharedFrameMapping { + fd: std::os::fd::OwnedFd, + view: *mut std::ffi::c_void, +} + #[allow(unsafe_code)] // SAFETY: the underlying OS file mapping handle and mapped view address are // process-wide and synchronized via atomic operations in `SharedHeader`. @@ -77,12 +88,48 @@ unsafe impl Send for SharedFrameMapping {} #[allow(unsafe_code)] unsafe impl Sync for SharedFrameMapping {} +#[cfg(windows)] impl std::fmt::Debug for SharedFrameMapping { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SharedFrameMapping").field("view", &self.view.Value).finish() } } +#[cfg(unix)] +impl std::fmt::Debug for SharedFrameMapping { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SharedFrameMapping").field("view", &self.view).finish() + } +} + +#[cfg(unix)] +#[allow(unsafe_code)] +fn unix_shm_path(name: &str) -> std::path::PathBuf { + use std::os::unix::fs::MetadataExt; + let sanitized: String = name + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }) + .collect(); + // SAFETY: `geteuid` has no preconditions and is always safe to call on POSIX. + let uid = unsafe { libc::geteuid() }; + let file_name = format!("nectarlink_shm_{uid}_{sanitized}"); + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR").map(std::path::PathBuf::from) + && let Ok(meta) = std::fs::symlink_metadata(&runtime_dir) + && meta.is_dir() + && meta.uid() == uid + && (meta.mode() & 0o077) == 0 + { + return runtime_dir.join(file_name); + } + let base = if std::path::Path::new("/dev/shm").is_dir() { + std::path::PathBuf::from("/dev/shm") + } else { + std::env::temp_dir() + }; + base.join(file_name) +} + +#[cfg(windows)] impl SharedFrameMapping { /// Opens or creates the shared-memory frame mapping with `name` (a /// `Global\` name gets [`GLOBAL_SDDL`]'s permissions). @@ -176,7 +223,110 @@ impl SharedFrameMapping { // SAFETY: `HEADER_BYTES` is within `TOTAL_MAPPING_BYTES`. unsafe { (self.view.Value as *mut u8).add(HEADER_BYTES) } } +} +#[cfg(unix)] +impl SharedFrameMapping { + /// Opens or creates the shared-memory frame mapping with `name` in + /// `$XDG_RUNTIME_DIR` or `/dev/shm` with `0600` owner-only permissions. + #[allow(unsafe_code)] + pub fn open_or_create(name: &str) -> std::io::Result { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + let path = unix_shm_path(name); + // SAFETY: `geteuid` has no preconditions. + let uid = unsafe { libc::geteuid() }; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path)?; + let meta = file.metadata()?; + if !meta.is_file() || meta.uid() != uid { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "shared frame mapping is not a regular file owned by current user", + )); + } + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.set_len(TOTAL_MAPPING_BYTES as u64)?; + Self::map(std::os::fd::OwnedFd::from(file)) + } + + /// For the camera's media source: the global mapping, or the caller's + /// session's when it may not create global objects (in-process tests). + pub fn for_camera(name: &str) -> std::io::Result { + if name == DEFAULT_MAPPING_NAME { + Self::open_or_create(DEFAULT_MAPPING_NAME).or_else(|_| Self::open_or_create(LOCAL_MAPPING_NAME)) + } else { + Self::open_or_create(name) + } + } + + /// Opens an existing mapping without creating it. + #[allow(unsafe_code)] + pub fn open_existing(name: &str) -> std::io::Result { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + let path = unix_shm_path(name); + // SAFETY: `geteuid` has no preconditions. + let uid = unsafe { libc::geteuid() }; + let file = + std::fs::OpenOptions::new().read(true).write(true).custom_flags(libc::O_NOFOLLOW).open(path)?; + let meta = file.metadata()?; + if !meta.is_file() || meta.uid() != uid { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "shared frame mapping is not a regular file owned by current user", + )); + } + let len = meta.len(); + if (len as usize) < TOTAL_MAPPING_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "shared frame mapping smaller than expected", + )); + } + Self::map(std::os::fd::OwnedFd::from(file)) + } + + #[allow(unsafe_code)] + fn map(fd: std::os::fd::OwnedFd) -> std::io::Result { + use std::os::fd::AsRawFd; + // SAFETY: `mmap` maps `TOTAL_MAPPING_BYTES` from a valid open file descriptor. + unsafe { + let view = libc::mmap( + std::ptr::null_mut(), + TOTAL_MAPPING_BYTES, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd.as_raw_fd(), + 0, + ); + if view == libc::MAP_FAILED || view.is_null() { + return Err(std::io::Error::last_os_error()); + } + let mapping = Self { fd, view }; + mapping.ensure_initialized(); + Ok(mapping) + } + } + + #[allow(unsafe_code)] + fn header(&self) -> &SharedHeader { + // SAFETY: `self.view` points to a valid page-aligned mapping of `TOTAL_MAPPING_BYTES`. + unsafe { &*(self.view as *const SharedHeader) } + } + + #[allow(unsafe_code)] + fn payload_ptr(&self) -> *mut u8 { + // SAFETY: `HEADER_BYTES` is within `TOTAL_MAPPING_BYTES`. + unsafe { (self.view as *mut u8).add(HEADER_BYTES) } + } +} + +impl SharedFrameMapping { fn ensure_initialized(&self) { let h = self.header(); if h.magic.load(Ordering::Acquire) != MAGIC { @@ -316,11 +466,18 @@ impl SharedFrameMapping { impl Drop for SharedFrameMapping { #[allow(unsafe_code)] fn drop(&mut self) { + #[cfg(windows)] // SAFETY: paired with `MapViewOfFile` and `CreateFileMappingW`. unsafe { let _ = UnmapViewOfFile(self.view); let _ = CloseHandle(self.handle); } + #[cfg(unix)] + // SAFETY: paired with `libc::mmap(TOTAL_MAPPING_BYTES)`. `self.fd` is closed by `OwnedFd::drop`. + unsafe { + let _ = libc::munmap(self.view, TOTAL_MAPPING_BYTES); + let _ = &self.fd; + } } } @@ -801,4 +958,24 @@ mod tests { assert!(DEFAULT_MAPPING_NAME.ends_with("_v2")); assert!(LOCAL_MAPPING_NAME.ends_with("_v2")); } + + #[cfg(unix)] + #[test] + #[allow(unsafe_code)] + fn unix_shm_enforces_owner_only_permissions_and_uid_scope() { + use std::os::unix::fs::MetadataExt; + let name = format!(r"Local\NectarlinkVcamPermTest_{}", std::process::id()); + let path = unix_shm_path(&name); + let uid = unsafe { libc::geteuid() }; + assert!( + path.file_name() + .and_then(|s| s.to_str()) + .is_some_and(|s| s.starts_with(&format!("nectarlink_shm_{uid}_"))) + ); + let _mapping = SharedFrameMapping::open_or_create(&name).expect("create mapping"); + let meta = std::fs::symlink_metadata(&path).expect("shm file metadata"); + assert_eq!(meta.uid(), uid); + assert_eq!(meta.mode() & 0o777, 0o600); + let _ = std::fs::remove_file(&path); + } } diff --git a/scripts/check.ps1 b/scripts/check.ps1 index 7abb13f2..570be48f 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -93,8 +93,17 @@ try { Invoke-Step "Android" { Push-Location (Join-Path $root "android") try { - if (-not $env:JAVA_HOME) { $env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" } - & .\gradlew.bat --console=plain -q testDebugUnitTest lintDebug assembleDebug + if ($IsLinux -or $IsMacOS) { + if (-not $env:JAVA_HOME) { + foreach ($candidate in @("/opt/android-studio/jbr", "$HOME/android-studio/jbr", "/usr/lib/jvm/java-21-openjdk-amd64", "/usr/lib/jvm/java-17-openjdk-amd64")) { + if (Test-Path $candidate) { $env:JAVA_HOME = $candidate; break } + } + } + & ./gradlew --console=plain -q testDebugUnitTest lintDebug assembleDebug + } else { + if (-not $env:JAVA_HOME) { $env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" } + & .\gradlew.bat --console=plain -q testDebugUnitTest lintDebug assembleDebug + } } finally { Pop-Location } diff --git a/spikes/s1-qt-rust/cpp/window_helpers.cpp b/spikes/s1-qt-rust/cpp/window_helpers.cpp index d9983f3d..6bb452db 100644 --- a/spikes/s1-qt-rust/cpp/window_helpers.cpp +++ b/spikes/s1-qt-rust/cpp/window_helpers.cpp @@ -1,7 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "window_helpers.h" +#include +#if __has_include() #include +#endif #include #include