From bc8f563987812e7cfadcb6663f0da79323dfc6dc Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:01:55 -0700 Subject: [PATCH 01/44] fix(desktop): complete shared native bridge contracts --- frontend/src-tauri/src/dictation_output.rs | 80 +- frontend/src-tauri/src/lib.rs | 3 + frontend/src-tauri/src/tools.rs | 2 +- frontend/src-tauri/src/watch_folder.rs | 664 +------------- frontend/src-tauri/src/watch_folder_core.rs | 723 +++++++++++++++ frontend/src-tauri/src/wayland_shortcut.rs | 803 +--------------- .../src-tauri/src/wayland_shortcut_core.rs | 854 ++++++++++++++++++ frontend/src-tauri/tauri.conf.json | 2 +- 8 files changed, 1687 insertions(+), 1444 deletions(-) create mode 100644 frontend/src-tauri/src/watch_folder_core.rs create mode 100644 frontend/src-tauri/src/wayland_shortcut_core.rs diff --git a/frontend/src-tauri/src/dictation_output.rs b/frontend/src-tauri/src/dictation_output.rs index 366ea887..d3494493 100644 --- a/frontend/src-tauri/src/dictation_output.rs +++ b/frontend/src-tauri/src/dictation_output.rs @@ -38,6 +38,7 @@ pub struct DictationOutput { #[derive(Default)] struct Inner { + owner_pid: Option, next_session_id: AtomicU64, operation: Mutex<()>, state: Mutex, @@ -81,6 +82,21 @@ enum ClipboardSnapshot { } impl DictationOutput { + /// Native-helper hosts identify the UI process so tray capture excludes its windows. + /// In-process callers retain the existing default (the current process). + pub fn for_owner(owner_pid: u32) -> Self { + Self { + inner: Arc::new(Inner { + owner_pid: Some(owner_pid), + ..Inner::default() + }), + } + } + + fn owner_pid(&self) -> u32 { + self.inner.owner_pid.unwrap_or_else(std::process::id) + } + /// Remember focus on tray mouse-down, before the menu itself can become /// foreground. Tauri does not emit tray pointer events on Linux; X11 can /// still capture `_NET_ACTIVE_WINDOW` at the menu action, while Wayland @@ -97,7 +113,7 @@ impl DictationOutput { // can hold the operation lock while another application becomes // foreground, so acquiring it first would capture the wrong target. let captured_at = Instant::now(); - let target = capture().filter(|target| !target.belongs_to_current_process()); + let target = capture().filter(|target| !target.belongs_to_process(self.owner_pid())); if let Ok(mut state) = self.inner.state.lock() { state.tray_target = target.map(|target| (target, captured_at)); } @@ -124,7 +140,7 @@ impl DictationOutput { .then(capture) .flatten() .filter(|target| { - origin == CaptureOrigin::Shortcut || !target.belongs_to_current_process() + origin == CaptureOrigin::Shortcut || !target.belongs_to_process(self.owner_pid()) }); let mut state = self .inner @@ -278,6 +294,12 @@ impl DictationOutput { return Ok(DeliveryOutcome::Copied); } self.schedule_restore(session_id, generation, text.to_owned()); + // SendInput/CGEventPost/XTest enqueue keystrokes; success does not mean + // the target has consumed its paste. Keep the operation lock through + // the same consumption window used by clipboard restoration, so a + // second utterance cannot replace the clipboard or reset modifier state + // (Windows AttachThreadInput) while the first Ctrl/Cmd+V is queued. + thread::sleep(CLIPBOARD_CONSUME_DELAY); Ok(DeliveryOutcome::Inserted) } @@ -777,8 +799,8 @@ struct PlatformTarget { #[cfg(target_os = "macos")] impl PlatformTarget { - fn belongs_to_current_process(&self) -> bool { - self.pid == std::process::id() as i32 + fn belongs_to_process(&self, owner_pid: u32) -> bool { + self.pid == owner_pid as i32 } } @@ -818,8 +840,8 @@ struct PlatformTarget { #[cfg(target_os = "windows")] impl PlatformTarget { - fn belongs_to_current_process(&self) -> bool { - self.pid == std::process::id() + fn belongs_to_process(&self, owner_pid: u32) -> bool { + self.pid == owner_pid } } @@ -864,12 +886,23 @@ fn activate_target(target: &PlatformTarget) -> bool { return false; } let current_thread = GetCurrentThreadId(); - let attached = target_thread != current_thread - && AttachThreadInput(current_thread, target_thread, true).as_bool(); + let foreground = GetForegroundWindow(); + let foreground_thread = if foreground.0.is_null() { + 0 + } else { + GetWindowThreadProcessId(foreground, None) + }; + // Windows grants foreground activation through the thread that owns the + // current foreground window. The recorder can become foreground when its + // Stop button is clicked, so attaching to the destination thread does not + // transfer that right and SetForegroundWindow can silently fail. + let attached = foreground_thread != 0 + && foreground_thread != current_thread + && AttachThreadInput(current_thread, foreground_thread, true).as_bool(); let _ = BringWindowToTop(hwnd); let requested = SetForegroundWindow(hwnd).as_bool(); if attached { - let _ = AttachThreadInput(current_thread, target_thread, false); + let _ = AttachThreadInput(current_thread, foreground_thread, false); } if !requested { return false; @@ -906,8 +939,8 @@ struct PlatformTarget { #[cfg(target_os = "linux")] impl PlatformTarget { - fn belongs_to_current_process(&self) -> bool { - self.pid == Some(std::process::id()) + fn belongs_to_process(&self, owner_pid: u32) -> bool { + self.pid == Some(owner_pid) } } @@ -1049,7 +1082,7 @@ struct PlatformTarget; #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] impl PlatformTarget { - fn belongs_to_current_process(&self) -> bool { + fn belongs_to_process(&self, _owner_pid: u32) -> bool { false } } @@ -1357,6 +1390,29 @@ mod tests { use std::thread; use std::time::{Duration, Instant}; + #[cfg(target_os = "windows")] + #[test] + fn helper_tray_capture_excludes_the_owner_window() { + let owner = std::process::id().wrapping_add(1); + let output = DictationOutput::for_owner(owner); + let session = output.begin_session_with( + CaptureOrigin::Tray, + || { + Some(super::PlatformTarget { + hwnd: 1, + pid: owner, + }) + }, + true, + ); + let state = output.inner.state.lock().unwrap(); + let active = state.active.as_ref().unwrap(); + assert_eq!(active.id, session); + assert!(active.target.is_none()); + assert!(active.clipboard_only); + assert_eq!(DictationOutput::default().owner_pid(), std::process::id()); + } + #[test] fn clipboard_restore_never_overwrites_a_new_user_copy() { assert!(clipboard_still_staged( diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 8a10193f..4f605827 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -23,8 +23,11 @@ pub mod tools; pub mod uninstall; pub mod updater_channel; pub mod watch_folder; +mod watch_folder_core; #[cfg(target_os = "linux")] pub mod wayland_shortcut; +#[cfg(target_os = "linux")] +pub mod wayland_shortcut_core; use std::collections::{HashMap, VecDeque}; use std::process::Child; diff --git a/frontend/src-tauri/src/tools.rs b/frontend/src-tauri/src/tools.rs index db6e00f4..cec74683 100644 --- a/frontend/src-tauri/src/tools.rs +++ b/frontend/src-tauri/src/tools.rs @@ -653,7 +653,7 @@ fn assign_job_and_resume( // Version of the Astral `uv` binary we download at first run when no system // uv is on PATH. Pinned for reproducibility — bump alongside the uv.lock // when the toolchain needs a newer uv. -pub const UV_VERSION: &str = "0.11.7"; +pub const UV_VERSION: &str = "0.12.13"; // Version of BtbN/FFmpeg-Builds we download for Linux/Windows ffmpeg first- // run setup. The string appears *twice* in each URL (once as the release tag, diff --git a/frontend/src-tauri/src/watch_folder.rs b/frontend/src-tauri/src/watch_folder.rs index 3e3716bc..520f5610 100644 --- a/frontend/src-tauri/src/watch_folder.rs +++ b/frontend/src-tauri/src/watch_folder.rs @@ -1,402 +1,24 @@ -//! Batch watch-folder IPC: native folder pick, polling scan, and upload. -//! -//! The watcher lives entirely on the client side of the app: the webview asks -//! this module (over Tauri IPC) for directory listings and asks Rust to stream -//! a settled file through the existing `POST /batch/enqueue` multipart route. -//! The Python backend only ever sees uploaded bytes — filesystem paths never -//! ride an HTTP request (same posture as `commands::authorize_host_path`). -//! -//! Access model: the folder is picked in a native dialog inside this process -//! and registered under a random session token, together with a `cap_std` -//! directory HANDLE opened at pick time. Scan/read commands resolve entries -//! relative to that handle — the pathname is never re-resolved for *access*, -//! so swapping the directory (or any component of its path) for a -//! symlink/junction later cannot redirect the watcher, on any OS. The stored -//! pathname is re-resolved only by the liveness/identity check, which stops -//! the watcher loudly when the folder is deleted, moved, or replaced. The -//! webview cannot point the commands at an arbitrary path; reads are confined -//! to files sitting directly in the folder the user explicitly picked this -//! session (non-recursive by design). -//! -//! Holding the handle must not lock the user's folder: `cap_std` opens -//! directories on Windows WITHOUT `FILE_SHARE_DELETE` (it pins the pathname -//! for its own path-based helpers), which would make Explorer refuse to -//! rename or delete a watched folder until the watch is stopped — a -//! Windows-only behaviour the other two platforms don't have. The handle is -//! therefore opened here with the full share mode (`open_dir_handle`), so -//! replacing the folder behaves identically everywhere: the OS allows it, the -//! next poll's identity check fails, and the UI stops the watcher. - -use std::collections::HashMap; -use std::fs; -use std::io::{self, Read}; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::UNIX_EPOCH; - -#[cfg(not(windows))] -use cap_std::ambient_authority; -use cap_std::fs::Dir; -use serde::Serialize; +//! Tauri adapter for shared capability-scoped batch watch folders. +pub use crate::watch_folder_core::{WatchEntry, WatchFolderSelection, WatchFolderUploadReply}; use tauri_plugin_dialog::DialogExt; -/// An authorized watch folder: the directory handle everything resolves -/// against, plus the identity the folder had when the user picked it. The -/// handle is the security boundary (operations can never leave it); the -/// identity check is the LIVENESS signal — when the folder is deleted, moved, -/// or replaced, token resolution fails loudly and the UI stops the watcher -/// instead of polling silently forever. -struct WatchedDir { - /// Fully-resolved directory path captured at pick time. - canonical: PathBuf, - /// Filesystem identity (device, inode) captured at pick time. - #[cfg(unix)] - identity: (u64, u64), - /// Filesystem identity (volume serial, file index) captured at pick time. - #[cfg(windows)] - identity: (u32, u64), - /// Directory handle captured at pick time — all scans/reads go through it. - handle: Dir, -} - -#[cfg(unix)] -fn dir_identity(meta: &fs::Metadata) -> (u64, u64) { - use std::os::unix::fs::MetadataExt; - (meta.dev(), meta.ino()) -} - -#[cfg(windows)] -fn dir_identity(dir: &Dir) -> Result<(u32, u64), String> { - use std::os::windows::io::AsRawHandle; - use windows::Win32::Foundation::HANDLE; - use windows::Win32::Storage::FileSystem::{ - GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, - }; - - let mut info = BY_HANDLE_FILE_INFORMATION::default(); - // SAFETY: `dir` owns a live directory handle for the duration of this - // call, and `info` is a valid writable output buffer. - unsafe { GetFileInformationByHandle(HANDLE(dir.as_raw_handle()), &mut info) } - .map_err(|_| "Selected watch folder identity could not be read".to_string())?; - Ok(( - info.dwVolumeSerialNumber, - ((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64, - )) -} - -/// Open a directory handle for capability-scoped access. -/// -/// Unix: `cap_std`'s own ambient open. Windows: the same -/// `FILE_FLAG_BACKUP_SEMANTICS` directory open `cap_std` performs, but with -/// `FILE_SHARE_DELETE` included so the user can still rename/delete the folder -/// while it is watched (see the module docs). Child opens stay handle-relative -/// (`CreateFileAtW` / `NtCreateFile` with a root directory) so confinement is -/// unaffected; only the liveness check observes the rename, which is the -/// intended signal. -#[cfg(not(windows))] -fn open_dir_handle(dir: &Path) -> io::Result { - Dir::open_ambient_dir(dir, ambient_authority()) -} - -#[cfg(windows)] -fn open_dir_handle(dir: &Path) -> io::Result { - use std::os::windows::fs::OpenOptionsExt; - use windows::Win32::Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - }; - - let file = fs::OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) - .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) - .open(dir)?; - if !file.metadata()?.is_dir() { - return Err(io::Error::other("not a directory")); - } - Ok(Dir::from_std_file(file)) -} - -fn authorize_watched_dir(dir: &Path) -> Result { - let canonical = fs::canonicalize(dir) - .map_err(|e| format!("Selected watch folder could not be resolved: {e}"))?; - if !canonical.is_dir() { - return Err("Selected watch folder is not a directory".into()); - } - let handle = open_dir_handle(&canonical) - .map_err(|e| format!("Selected watch folder could not be opened: {e}"))?; - #[cfg(unix)] - let identity = dir_identity( - &fs::metadata(&canonical) - .map_err(|e| format!("Selected watch folder could not be inspected: {e}"))?, - ); - #[cfg(windows)] - let identity = dir_identity(&handle)?; - Ok(WatchedDir { - canonical, - #[cfg(unix)] - identity, - #[cfg(windows)] - identity, - handle, - }) -} - -/// Re-verify a watched folder's identity: the stored path must still resolve -/// to the same canonical target (and, on unix, the same device+inode). A -/// deleted, moved, replaced, or recreated directory fails here, which is what -/// stops the watcher loudly in the UI. Reads never depend on this check for -/// confinement — they go through the pinned handle regardless. -fn verify_watched_dir(watched: &WatchedDir) -> Result<(), String> { - let canonical_now = fs::canonicalize(&watched.canonical) - .map_err(|_| "Watched folder is no longer accessible".to_string())?; - if canonical_now != watched.canonical { - return Err("Watched folder changed identity".into()); - } - #[cfg(unix)] - { - let meta = fs::metadata(&canonical_now) - .map_err(|_| "Watched folder is no longer accessible".to_string())?; - if dir_identity(&meta) != watched.identity { - return Err("Watched folder changed identity".into()); - } - } - #[cfg(windows)] - { - let current = open_dir_handle(&canonical_now) - .map_err(|_| "Watched folder is no longer accessible".to_string())?; - if dir_identity(¤t)? != watched.identity { - return Err("Watched folder changed identity".into()); - } - } - Ok(()) -} - -fn registry() -> &'static Mutex> { - static WATCHED: OnceLock>> = OnceLock::new(); - WATCHED.get_or_init(|| Mutex::new(HashMap::new())) -} - -#[derive(Serialize)] -pub struct WatchFolderSelection { - token: String, - path: String, -} - -#[derive(Serialize)] -pub struct WatchEntry { - name: String, - size: u64, - /// Modification time in ms since the Unix epoch (0 when unavailable). - mtime: u64, -} - -fn new_token() -> Result { - let mut random = [0_u8; 32]; - getrandom::fill(&mut random).map_err(|e| format!("Secure randomness unavailable: {e}"))?; - Ok(random.iter().map(|b| format!("{b:02x}")).collect()) -} - -/// Resolve a session token to a clone of its pinned directory handle, -/// re-verifying the folder's liveness/identity on every access. -fn registered_dir(token: &str) -> Result { - let map = registry() - .lock() - .map_err(|_| "Watch-folder registry poisoned".to_string())?; - let watched = map - .get(token) - .ok_or_else(|| "Watch folder is not authorized".to_string())?; - verify_watched_dir(watched)?; - watched - .handle - .try_clone() - .map_err(|e| format!("Watched folder handle could not be reused: {e}")) -} - -/// A directory entry name must be a single plain path component — anything -/// that could climb out of the watched folder is rejected. (The `cap_std` -/// handle would also refuse an escape; this keeps the error crisp and the -/// contract explicit.) -fn validate_entry_name(name: &str) -> Result<(), String> { - if name.is_empty() - || name == "." - || name == ".." - || name.contains('/') - || name.contains('\\') - || name.chars().any(|c| c.is_control()) - { - return Err("Invalid watch-folder entry name".into()); - } - Ok(()) -} - -fn mtime_ms(meta: &fs::Metadata) -> u64 { - meta.modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -fn cap_mtime_ms(meta: &cap_std::fs::Metadata) -> u64 { - meta.modified() - .ok() - .and_then(|t| t.into_std().duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -/// Non-recursive listing of the regular files in the watched folder (name, -/// size, mtime), resolved through the pinned handle. Symlinks are skipped -/// outright — the read path cannot follow them out of the folder anyway, so -/// listing them would only produce entries that can never be ingested. -fn scan_dir(dir: &Dir) -> Result, String> { - let mut entries = Vec::new(); - let read = dir - .entries() - .map_err(|e| format!("Watched folder is unreadable: {e}"))?; - for item in read.flatten() { - let Ok(file_type) = item.file_type() else { - continue; - }; - if !file_type.is_file() { - continue; - } - let Ok(meta) = item.metadata() else { continue }; - let Ok(name) = item.file_name().into_string() else { - continue; // non-UTF-8 names can't round-trip through IPC; skip - }; - entries.push(WatchEntry { - name, - size: meta.len(), - mtime: cap_mtime_ms(&meta), - }); - } - Ok(entries) -} - -/// Open a settled watched file through the pinned directory handle. A symlink -/// outside the folder cannot be opened, and the returned reader revalidates -/// size+mtime around every network read so a mutation aborts the upload. -fn open_watched_reader( - dir: &Dir, - name: &str, - expected_size: u64, - expected_mtime: u64, -) -> Result { - validate_entry_name(name)?; - let file = dir - .open(name) - .map_err(|e| format!("Watched file could not be opened: {e}"))? - .into_std(); - let meta = file - .metadata() - .map_err(|e| format!("Watched file could not be inspected: {e}"))?; - if !meta.is_file() { - return Err("Watched entry is not a regular file".into()); - } - if meta.len() != expected_size || mtime_ms(&meta) != expected_mtime { - return Err("Watched file changed after it was scanned".into()); - } - Ok(SnapshotReader { - file, - expected_size, - expected_mtime, - }) -} - -#[derive(Debug)] -struct SnapshotReader { - file: fs::File, - expected_size: u64, - expected_mtime: u64, -} - -impl SnapshotReader { - fn validate(&self) -> io::Result<()> { - let meta = self.file.metadata()?; - if !meta.is_file() - || meta.len() != self.expected_size - || mtime_ms(&meta) != self.expected_mtime - { - return Err(io::Error::other("watched file changed during upload")); - } - Ok(()) - } -} - -impl Read for SnapshotReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - self.validate()?; - let read = self.file.read(buf)?; - self.validate()?; - Ok(read) - } -} - -/// Open the native folder picker and register the chosen directory for this -/// session. Returns `None` when the user cancels. #[tauri::command] pub async fn watch_folder_pick( app: tauri::AppHandle, ) -> Result, String> { - let picked = app - .dialog() + app.dialog() .file() .blocking_pick_folder() - .and_then(|value| value.into_path().ok()); - let Some(dir) = picked else { - return Ok(None); - }; - if !dir.is_absolute() || !dir.is_dir() { - return Err("Selected watch folder is not a directory".into()); - } - let watched = authorize_watched_dir(&dir)?; - let display = watched.canonical.to_string_lossy().into_owned(); - let token = new_token()?; - registry() - .lock() - .map_err(|_| "Watch-folder registry poisoned".to_string())? - .insert(token.clone(), watched); - Ok(Some(WatchFolderSelection { - token, - path: display, - })) + .and_then(|value| value.into_path().ok()) + .map(|dir| crate::watch_folder_core::register(&dir)) + .transpose() } -/// List the files currently sitting in the watched folder (non-recursive). #[tauri::command] pub fn watch_folder_scan(token: String) -> Result, String> { - scan_dir(®istered_dir(&token)?) + crate::watch_folder_core::scan(token) } -#[derive(Serialize)] -pub struct WatchFolderUploadReply { - status: u16, - body: serde_json::Value, -} - -fn video_mime(name: &str) -> &'static str { - match Path::new(name) - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or_default() - .to_ascii_lowercase() - .as_str() - { - "mp4" | "m4v" => "video/mp4", - "mov" => "video/quicktime", - "mkv" => "video/x-matroska", - "webm" => "video/webm", - "avi" => "video/x-msvideo", - "mpg" | "mpeg" => "video/mpeg", - "wmv" => "video/x-ms-wmv", - _ => "application/octet-stream", - } -} - -/// Stream one settled watched file directly from its pinned OS handle to the -/// loopback backend. Keeping bytes out of WebView IPC avoids an O(file size) -/// renderer allocation for multi-gigabyte videos. #[tauri::command] pub async fn watch_folder_enqueue( token: String, @@ -407,272 +29,24 @@ pub async fn watch_folder_enqueue( voice_id: Option, preserve_bg: bool, ) -> Result { - let dir = registered_dir(&token)?; - let reader = open_watched_reader(&dir, &name, expected_size, expected_mtime)?; - let mime = video_mime(&name); - let url = format!("http://127.0.0.1:{}/batch/enqueue", crate::backend_port()); - + let port = crate::backend_port(); tauri::async_runtime::spawn_blocking(move || { - let part = reqwest::blocking::multipart::Part::reader_with_length(reader, expected_size) - .file_name(name) - .mime_str(mime) - .map_err(|_| "Watched file type could not be prepared".to_string())?; - let mut form = reqwest::blocking::multipart::Form::new() - .part("video", part) - .text("langs", langs.join(",")) - .text("preserve_bg", preserve_bg.to_string()); - if let Some(voice_id) = voice_id.filter(|value| !value.is_empty()) { - form = form.text("voice_id", voice_id); - } - let response = reqwest::blocking::Client::builder() - .no_proxy() - .connect_timeout(std::time::Duration::from_secs(5)) - .build() - .map_err(|_| "Watch-folder upload client could not start".to_string())? - .post(url) - .multipart(form) - .send() - .map_err(|_| "Watch-folder upload failed".to_string())?; - let status = response.status().as_u16(); - let body = response - .json::() - .map_err(|_| "Watch-folder backend returned an invalid response".to_string())?; - Ok(WatchFolderUploadReply { status, body }) + crate::watch_folder_core::enqueue( + port, + token, + name, + expected_size, + expected_mtime, + langs, + voice_id, + preserve_bg, + ) }) .await .map_err(|_| "Watch-folder upload task failed".to_string())? } -/// Drop a watch-folder authorization (watcher stopped or component unmounted). #[tauri::command] pub fn watch_folder_forget(token: String) { - if let Ok(mut map) = registry().lock() { - map.remove(&token); - } -} - -#[cfg(test)] -mod tests { - use super::{ - authorize_watched_dir, mtime_ms, open_dir_handle, open_watched_reader, scan_dir, - validate_entry_name, verify_watched_dir, Dir, - }; - use std::fs; - use std::io::Read; - use std::path::PathBuf; - - fn temp_watch_dir(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("vs-watch-{tag}-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - dir - } - - fn open_handle(dir: &std::path::Path) -> Dir { - open_dir_handle(dir).unwrap() - } - - fn snapshot(path: &std::path::Path) -> (u64, u64) { - let meta = fs::metadata(path).unwrap(); - (meta.len(), mtime_ms(&meta)) - } - - #[test] - fn entry_names_must_be_single_components() { - assert!(validate_entry_name("clip.mp4").is_ok()); - assert!(validate_entry_name("weird name (1).MOV").is_ok()); - for bad in [ - "", - ".", - "..", - "a/b.mp4", - "a\\b.mp4", - "..\\up.mp4", - "x\n.mp4", - ] { - assert!(validate_entry_name(bad).is_err(), "accepted {bad:?}"); - } - } - - #[test] - fn scan_lists_regular_files_with_size_and_mtime_and_skips_dirs() { - let dir = temp_watch_dir("scan"); - fs::create_dir_all(dir.join("nested")).unwrap(); - fs::write(dir.join("a.mp4"), b"12345").unwrap(); - fs::write(dir.join("notes.txt"), b"x").unwrap(); - - let mut entries = scan_dir(&open_handle(&dir)).unwrap(); - entries.sort_by(|a, b| a.name.cmp(&b.name)); - let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); - // Directories are skipped; filtering to *videos* is the frontend's job. - assert_eq!(names, ["a.mp4", "notes.txt"]); - assert_eq!(entries[0].size, 5); - assert!(entries[0].mtime > 0); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn snapshot_reader_streams_the_exact_bytes() { - let dir = temp_watch_dir("stream"); - fs::write(dir.join("clip.mp4"), b"0123456789").unwrap(); - let (size, mtime) = snapshot(&dir.join("clip.mp4")); - let handle = open_handle(&dir); - - let mut reader = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap(); - let mut whole = Vec::new(); - reader.read_to_end(&mut whole).unwrap(); - assert_eq!(whole, b"0123456789"); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn reads_are_bound_to_the_settled_snapshot() { - let dir = temp_watch_dir("snapshot"); - fs::write(dir.join("clip.mp4"), b"settled bytes").unwrap(); - let (size, mtime) = snapshot(&dir.join("clip.mp4")); - let handle = open_handle(&dir); - - // The file is replaced after the scan settled → the read must refuse - // rather than upload bytes the tracker never saw stabilize. - fs::write(dir.join("clip.mp4"), b"replaced with something longer").unwrap(); - let err = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap_err(); - assert!(err.contains("changed"), "unexpected error: {err}"); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn snapshot_reader_aborts_when_file_changes_during_stream() { - let dir = temp_watch_dir("mid-stream-change"); - fs::write(dir.join("clip.mp4"), b"settled bytes").unwrap(); - let (size, mtime) = snapshot(&dir.join("clip.mp4")); - let handle = open_handle(&dir); - let mut reader = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap(); - - fs::write(dir.join("clip.mp4"), b"different-length bytes").unwrap(); - let mut byte = [0_u8; 1]; - assert!(reader.read(&mut byte).is_err()); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn authorization_pins_the_directory_identity() { - let dir = temp_watch_dir("identity"); - let watched = authorize_watched_dir(&dir).unwrap(); - // Untouched directory verifies fine… - assert!(verify_watched_dir(&watched).is_ok()); - // …and a directory that disappears after authorization is refused. - // The removal itself must succeed WHILE the handle is held: a watch - // that locked the user's folder against deletion (Windows sharing - // violation, OS error 32) would be a Windows-only behaviour. - fs::remove_dir_all(&dir).unwrap(); - assert!(verify_watched_dir(&watched).is_err()); - } - - #[test] - fn a_watched_folder_can_be_renamed_by_the_user_while_watched() { - // Cross-platform contract: holding the pinned handle never blocks the - // user from moving the folder (Explorer/Finder/mv). The liveness - // check is what notices — it must refuse, not the OS. - let dir = temp_watch_dir("rename-while-watched"); - let moved = dir.with_extension("moved"); - let _ = fs::remove_dir_all(&moved); - let watched = authorize_watched_dir(&dir).unwrap(); - fs::rename(&dir, &moved).unwrap(); - assert!(verify_watched_dir(&watched).is_err()); - // The pinned handle still points at the ORIGINAL directory object. - fs::write(moved.join("clip.mp4"), b"x").unwrap(); - let names: Vec = scan_dir(&watched.handle) - .unwrap() - .into_iter() - .map(|e| e.name) - .collect(); - assert_eq!(names, ["clip.mp4"]); - drop(watched); - let _ = fs::remove_dir_all(&moved); - } - - #[cfg(unix)] - #[test] - fn a_directory_swapped_for_a_symlink_is_refused_and_never_followed() { - let dir = temp_watch_dir("dir-swap"); - let elsewhere = temp_watch_dir("dir-swap-target"); - fs::write(elsewhere.join("clip.mp4"), b"outside").unwrap(); - let (size, mtime) = snapshot(&elsewhere.join("clip.mp4")); - - let watched = authorize_watched_dir(&dir).unwrap(); - assert!(verify_watched_dir(&watched).is_ok()); - - // Replace the authorized directory itself with a symlink pointing - // somewhere else. Token resolution refuses (identity check)… - fs::remove_dir_all(&dir).unwrap(); - std::os::unix::fs::symlink(&elsewhere, &dir).unwrap(); - let err = verify_watched_dir(&watched).unwrap_err(); - assert!(err.contains("identity"), "unexpected error: {err}"); - // …and even the pinned handle cannot reach the swap target: it still - // points at the ORIGINAL (now unlinked) directory, which is empty. - assert!(scan_dir(&watched.handle).unwrap().is_empty()); - assert!(open_watched_reader(&watched.handle, "clip.mp4", size, mtime).is_err()); - - let _ = fs::remove_file(&dir); - let _ = fs::remove_dir_all(&elsewhere); - } - - #[cfg(unix)] - #[test] - fn a_recreated_directory_at_the_same_path_is_refused() { - let dir = temp_watch_dir("dir-recreate"); - let watched = authorize_watched_dir(&dir).unwrap(); - fs::remove_dir_all(&dir).unwrap(); - fs::create_dir_all(&dir).unwrap(); // same path, different inode - assert!(verify_watched_dir(&watched).is_err()); - let _ = fs::remove_dir_all(&dir); - } - - #[cfg(windows)] - #[test] - fn a_replaced_directory_at_the_same_windows_path_is_refused() { - // Same pathname, different directory object (volume serial + file - // index): the pathname check alone would pass, the identity must not. - let dir = temp_watch_dir("windows-dir-replace"); - let moved = dir.with_extension("moved"); - let _ = fs::remove_dir_all(&moved); - let watched = authorize_watched_dir(&dir).unwrap(); - fs::rename(&dir, &moved).unwrap(); - fs::create_dir_all(&dir).unwrap(); - let err = verify_watched_dir(&watched).unwrap_err(); - assert!(err.contains("identity"), "unexpected error: {err}"); - drop(watched); - let _ = fs::remove_dir_all(&dir); - let _ = fs::remove_dir_all(&moved); - } - - #[cfg(unix)] - #[test] - fn symlinks_are_never_followed_out_of_the_folder() { - let dir = temp_watch_dir("symlink"); - let secret = std::env::temp_dir().join(format!("vs-secret-{}", std::process::id())); - fs::write(&secret, b"outside the folder").unwrap(); - std::os::unix::fs::symlink(&secret, dir.join("evil.mp4")).unwrap(); - let meta = fs::metadata(dir.join("evil.mp4")).unwrap(); - let handle = open_handle(&dir); - - // Even with a "correct" snapshot of the symlink target, opening it - // through the capability handle refuses: resolution may not escape - // the watched folder. - let err = - open_watched_reader(&handle, "evil.mp4", meta.len(), mtime_ms(&meta)).unwrap_err(); - assert!( - err.contains("could not be opened"), - "unexpected error: {err}" - ); - // And the scanner never lists it in the first place. - assert!(scan_dir(&handle).unwrap().is_empty()); - - let _ = fs::remove_dir_all(&dir); - let _ = fs::remove_file(&secret); - } + crate::watch_folder_core::forget(token); } diff --git a/frontend/src-tauri/src/watch_folder_core.rs b/frontend/src-tauri/src/watch_folder_core.rs new file mode 100644 index 00000000..ab886d22 --- /dev/null +++ b/frontend/src-tauri/src/watch_folder_core.rs @@ -0,0 +1,723 @@ +//! Shared capability-scoped batch watch-folder scan and streaming upload. +//! +//! The watcher lives entirely on the client side of the app: the webview asks +//! this module (over Tauri IPC) for directory listings and asks Rust to stream +//! a settled file through the existing `POST /batch/enqueue` multipart route. +//! The Python backend only ever sees uploaded bytes — filesystem paths never +//! ride an HTTP request (same posture as `commands::authorize_host_path`). +//! +//! Access model: the folder is picked in a native dialog inside this process +//! and registered under a random session token, together with a `cap_std` +//! directory HANDLE opened at pick time. Scan/read commands resolve entries +//! relative to that handle — the pathname is never re-resolved for *access*, +//! so swapping the directory (or any component of its path) for a +//! symlink/junction later cannot redirect the watcher, on any OS. The stored +//! pathname is re-resolved only by the liveness/identity check, which stops +//! the watcher loudly when the folder is deleted, moved, or replaced. The +//! webview cannot point the commands at an arbitrary path; reads are confined +//! to files sitting directly in the folder the user explicitly picked this +//! session (non-recursive by design). +//! +//! Holding the handle must not lock the user's folder: `cap_std` opens +//! directories on Windows WITHOUT `FILE_SHARE_DELETE` (it pins the pathname +//! for its own path-based helpers), which would make Explorer refuse to +//! rename or delete a watched folder until the watch is stopped — a +//! Windows-only behaviour the other two platforms don't have. The handle is +//! therefore opened here with the full share mode (`open_dir_handle`), so +//! replacing the folder behaves identically everywhere: the OS allows it, the +//! next poll's identity check fails, and the UI stops the watcher. + +use std::collections::HashMap; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::UNIX_EPOCH; + +#[cfg(not(windows))] +use cap_std::ambient_authority; +use cap_std::fs::Dir; +use serde::Serialize; + +/// An authorized watch folder: the directory handle everything resolves +/// against, plus the identity the folder had when the user picked it. The +/// handle is the security boundary (operations can never leave it); the +/// identity check is the LIVENESS signal — when the folder is deleted, moved, +/// or replaced, token resolution fails loudly and the UI stops the watcher +/// instead of polling silently forever. +struct WatchedDir { + /// Fully-resolved directory path captured at pick time. + canonical: PathBuf, + /// Filesystem identity (device, inode) captured at pick time. + #[cfg(unix)] + identity: (u64, u64), + /// Filesystem identity (volume serial, file index) captured at pick time. + #[cfg(windows)] + identity: (u32, u64), + /// Directory handle captured at pick time — all scans/reads go through it. + handle: Dir, +} + +#[cfg(unix)] +fn dir_identity(meta: &fs::Metadata) -> (u64, u64) { + use std::os::unix::fs::MetadataExt; + (meta.dev(), meta.ino()) +} + +#[cfg(windows)] +fn dir_identity(dir: &Dir) -> Result<(u32, u64), String> { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: `dir` owns a live directory handle for the duration of this + // call, and `info` is a valid writable output buffer. + unsafe { GetFileInformationByHandle(HANDLE(dir.as_raw_handle()), &mut info) } + .map_err(|_| "Selected watch folder identity could not be read".to_string())?; + Ok(( + info.dwVolumeSerialNumber, + ((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64, + )) +} + +/// Open a directory handle for capability-scoped access. +/// +/// Unix: `cap_std`'s own ambient open. Windows: the same +/// `FILE_FLAG_BACKUP_SEMANTICS` directory open `cap_std` performs, but with +/// `FILE_SHARE_DELETE` included so the user can still rename/delete the folder +/// while it is watched (see the module docs). Child opens stay handle-relative +/// (`CreateFileAtW` / `NtCreateFile` with a root directory) so confinement is +/// unaffected; only the liveness check observes the rename, which is the +/// intended signal. +#[cfg(not(windows))] +fn open_dir_handle(dir: &Path) -> io::Result { + Dir::open_ambient_dir(dir, ambient_authority()) +} + +#[cfg(windows)] +fn open_dir_handle(dir: &Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .open(dir)?; + if !file.metadata()?.is_dir() { + return Err(io::Error::other("not a directory")); + } + Ok(Dir::from_std_file(file)) +} + +fn authorize_watched_dir(dir: &Path) -> Result { + let canonical = fs::canonicalize(dir) + .map_err(|e| format!("Selected watch folder could not be resolved: {e}"))?; + if !canonical.is_dir() { + return Err("Selected watch folder is not a directory".into()); + } + let handle = open_dir_handle(&canonical) + .map_err(|e| format!("Selected watch folder could not be opened: {e}"))?; + #[cfg(unix)] + let identity = dir_identity( + &fs::metadata(&canonical) + .map_err(|e| format!("Selected watch folder could not be inspected: {e}"))?, + ); + #[cfg(windows)] + let identity = dir_identity(&handle)?; + Ok(WatchedDir { + canonical, + #[cfg(unix)] + identity, + #[cfg(windows)] + identity, + handle, + }) +} + +/// Re-verify a watched folder's identity: the stored path must still resolve +/// to the same canonical target (and, on unix, the same device+inode). A +/// deleted, moved, replaced, or recreated directory fails here, which is what +/// stops the watcher loudly in the UI. Reads never depend on this check for +/// confinement — they go through the pinned handle regardless. +fn verify_watched_dir(watched: &WatchedDir) -> Result<(), String> { + let canonical_now = fs::canonicalize(&watched.canonical) + .map_err(|_| "Watched folder is no longer accessible".to_string())?; + if canonical_now != watched.canonical { + return Err("Watched folder changed identity".into()); + } + #[cfg(unix)] + { + let meta = fs::metadata(&canonical_now) + .map_err(|_| "Watched folder is no longer accessible".to_string())?; + if dir_identity(&meta) != watched.identity { + return Err("Watched folder changed identity".into()); + } + } + #[cfg(windows)] + { + let current = open_dir_handle(&canonical_now) + .map_err(|_| "Watched folder is no longer accessible".to_string())?; + if dir_identity(¤t)? != watched.identity { + return Err("Watched folder changed identity".into()); + } + } + Ok(()) +} + +fn registry() -> &'static Mutex> { + static WATCHED: OnceLock>> = OnceLock::new(); + WATCHED.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[derive(Serialize)] +pub struct WatchFolderSelection { + token: String, + path: String, +} + +#[derive(Serialize)] +pub struct WatchEntry { + name: String, + size: u64, + /// Modification time in ms since the Unix epoch (0 when unavailable). + mtime: u64, +} + +fn new_token() -> Result { + let mut random = [0_u8; 32]; + getrandom::fill(&mut random).map_err(|e| format!("Secure randomness unavailable: {e}"))?; + Ok(random.iter().map(|b| format!("{b:02x}")).collect()) +} + +/// Resolve a session token to a clone of its pinned directory handle, +/// re-verifying the folder's liveness/identity on every access. +fn registered_dir(token: &str) -> Result { + let map = registry() + .lock() + .map_err(|_| "Watch-folder registry poisoned".to_string())?; + let watched = map + .get(token) + .ok_or_else(|| "Watch folder is not authorized".to_string())?; + verify_watched_dir(watched)?; + watched + .handle + .try_clone() + .map_err(|e| format!("Watched folder handle could not be reused: {e}")) +} + +/// A directory entry name must be a single plain path component — anything +/// that could climb out of the watched folder is rejected. (The `cap_std` +/// handle would also refuse an escape; this keeps the error crisp and the +/// contract explicit.) +fn validate_entry_name(name: &str) -> Result<(), String> { + if name.is_empty() + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + || name.chars().any(|c| c.is_control()) + { + return Err("Invalid watch-folder entry name".into()); + } + Ok(()) +} + +fn mtime_ms(meta: &fs::Metadata) -> u64 { + meta.modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn cap_mtime_ms(meta: &cap_std::fs::Metadata) -> u64 { + meta.modified() + .ok() + .and_then(|t| t.into_std().duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Non-recursive listing of the regular files in the watched folder (name, +/// size, mtime), resolved through the pinned handle. Symlinks are skipped +/// outright — the read path cannot follow them out of the folder anyway, so +/// listing them would only produce entries that can never be ingested. +fn scan_dir(dir: &Dir) -> Result, String> { + let mut entries = Vec::new(); + let read = dir + .entries() + .map_err(|e| format!("Watched folder is unreadable: {e}"))?; + for item in read.flatten() { + let Ok(file_type) = item.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + let Ok(meta) = item.metadata() else { continue }; + let Ok(name) = item.file_name().into_string() else { + continue; // non-UTF-8 names can't round-trip through IPC; skip + }; + entries.push(WatchEntry { + name, + size: meta.len(), + mtime: cap_mtime_ms(&meta), + }); + } + Ok(entries) +} + +/// Open a settled watched file through the pinned directory handle. A symlink +/// outside the folder cannot be opened, and the returned reader revalidates +/// size+mtime around every network read so a mutation aborts the upload. +fn open_watched_reader( + dir: &Dir, + name: &str, + expected_size: u64, + expected_mtime: u64, +) -> Result { + validate_entry_name(name)?; + let file = dir + .open(name) + .map_err(|e| format!("Watched file could not be opened: {e}"))? + .into_std(); + let meta = file + .metadata() + .map_err(|e| format!("Watched file could not be inspected: {e}"))?; + if !meta.is_file() { + return Err("Watched entry is not a regular file".into()); + } + if meta.len() != expected_size || mtime_ms(&meta) != expected_mtime { + return Err("Watched file changed after it was scanned".into()); + } + Ok(SnapshotReader { + file, + expected_size, + expected_mtime, + }) +} + +#[derive(Debug)] +struct SnapshotReader { + file: fs::File, + expected_size: u64, + expected_mtime: u64, +} + +impl SnapshotReader { + fn validate(&self) -> io::Result<()> { + let meta = self.file.metadata()?; + if !meta.is_file() + || meta.len() != self.expected_size + || mtime_ms(&meta) != self.expected_mtime + { + return Err(io::Error::other("watched file changed during upload")); + } + Ok(()) + } +} + +impl Read for SnapshotReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.validate()?; + let read = self.file.read(buf)?; + self.validate()?; + Ok(read) + } +} + +/// Register a folder selected by the owning desktop shell's native dialog. +/// This function is not exposed to renderer IPC with an arbitrary path. +pub fn register(dir: &Path) -> Result { + if !dir.is_absolute() || !dir.is_dir() { + return Err("Selected watch folder is not a directory".into()); + } + let watched = authorize_watched_dir(dir)?; + let display = watched.canonical.to_string_lossy().into_owned(); + let token = new_token()?; + registry() + .lock() + .map_err(|_| "Watch-folder registry poisoned".to_string())? + .insert(token.clone(), watched); + Ok(WatchFolderSelection { + token, + path: display, + }) +} + +/// List the files currently sitting in the watched folder (non-recursive). +pub fn scan(token: String) -> Result, String> { + scan_dir(®istered_dir(&token)?) +} + +#[derive(Serialize)] +pub struct WatchFolderUploadReply { + status: u16, + body: serde_json::Value, +} + +fn video_mime(name: &str) -> &'static str { + match Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "mp4" | "m4v" => "video/mp4", + "mov" => "video/quicktime", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + "avi" => "video/x-msvideo", + "mpg" | "mpeg" => "video/mpeg", + "wmv" => "video/x-ms-wmv", + _ => "application/octet-stream", + } +} + +fn batch_endpoint(backend_url: &str) -> Result { + let mut url = reqwest::Url::parse(backend_url) + .map_err(|_| "Invalid watch-folder backend URL".to_string())?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !matches!(url.path(), "" | "/") + { + return Err("Invalid watch-folder backend URL".into()); + } + url.set_path("/batch/enqueue"); + Ok(url) +} + +/// Stream one settled watched file directly from its pinned OS handle to the +/// selected backend. Keeping bytes out of WebView IPC avoids an O(file size) +/// renderer allocation for multi-gigabyte videos. +pub fn enqueue_to( + backend_url: String, + authorization: Option, + token: String, + name: String, + expected_size: u64, + expected_mtime: u64, + langs: Vec, + voice_id: Option, + preserve_bg: bool, +) -> Result { + let dir = registered_dir(&token)?; + let reader = open_watched_reader(&dir, &name, expected_size, expected_mtime)?; + let mime = video_mime(&name); + let url = batch_endpoint(&backend_url)?; + + let part = reqwest::blocking::multipart::Part::reader_with_length(reader, expected_size) + .file_name(name) + .mime_str(mime) + .map_err(|_| "Watched file type could not be prepared".to_string())?; + let mut form = reqwest::blocking::multipart::Form::new() + .part("video", part) + .text("langs", langs.join(",")) + .text("preserve_bg", preserve_bg.to_string()); + if let Some(voice_id) = voice_id.filter(|value| !value.is_empty()) { + form = form.text("voice_id", voice_id); + } + let client = reqwest::blocking::Client::builder() + .no_proxy() + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|_| "Watch-folder upload client could not start".to_string())?; + let mut request = client.post(url).multipart(form); + if let Some(value) = authorization.filter(|value| !value.is_empty()) { + request = request.header(reqwest::header::AUTHORIZATION, value); + } + let response = request + .send() + .map_err(|_| "Watch-folder upload failed".to_string())?; + let status = response.status().as_u16(); + let body = response + .json::() + .map_err(|_| "Watch-folder backend returned an invalid response".to_string())?; + Ok(WatchFolderUploadReply { status, body }) +} + +#[allow(dead_code)] // Used by the Tauri adapter; the Electron helper calls enqueue_to. +pub fn enqueue( + backend_port: u16, + token: String, + name: String, + expected_size: u64, + expected_mtime: u64, + langs: Vec, + voice_id: Option, + preserve_bg: bool, +) -> Result { + enqueue_to( + format!("http://127.0.0.1:{backend_port}"), + None, + token, + name, + expected_size, + expected_mtime, + langs, + voice_id, + preserve_bg, + ) +} + +/// Drop a watch-folder authorization (watcher stopped or component unmounted). +pub fn forget(token: String) { + if let Ok(mut map) = registry().lock() { + map.remove(&token); + } +} + +#[cfg(test)] +mod tests { + use super::{ + authorize_watched_dir, batch_endpoint, mtime_ms, open_dir_handle, open_watched_reader, + scan_dir, validate_entry_name, verify_watched_dir, Dir, + }; + use std::fs; + use std::io::Read; + use std::path::PathBuf; + + fn temp_watch_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vs-watch-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn open_handle(dir: &std::path::Path) -> Dir { + open_dir_handle(dir).unwrap() + } + + fn snapshot(path: &std::path::Path) -> (u64, u64) { + let meta = fs::metadata(path).unwrap(); + (meta.len(), mtime_ms(&meta)) + } + + #[test] + fn entry_names_must_be_single_components() { + assert!(validate_entry_name("clip.mp4").is_ok()); + assert!(validate_entry_name("weird name (1).MOV").is_ok()); + for bad in [ + "", + ".", + "..", + "a/b.mp4", + "a\\b.mp4", + "..\\up.mp4", + "x\n.mp4", + ] { + assert!(validate_entry_name(bad).is_err(), "accepted {bad:?}"); + } + } + + #[test] + fn backend_endpoint_accepts_remote_https_without_credential_injection() { + assert_eq!( + batch_endpoint("https://gpu-box.example:3900") + .unwrap() + .as_str(), + "https://gpu-box.example:3900/batch/enqueue" + ); + for bad in [ + "file:///tmp/backend", + "https://user:secret@gpu-box.example:3900", + "https://gpu-box.example:3900/other", + "https://gpu-box.example:3900?token=secret", + ] { + assert!(batch_endpoint(bad).is_err(), "accepted {bad:?}"); + } + } + + #[test] + fn scan_lists_regular_files_with_size_and_mtime_and_skips_dirs() { + let dir = temp_watch_dir("scan"); + fs::create_dir_all(dir.join("nested")).unwrap(); + fs::write(dir.join("a.mp4"), b"12345").unwrap(); + fs::write(dir.join("notes.txt"), b"x").unwrap(); + + let mut entries = scan_dir(&open_handle(&dir)).unwrap(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + // Directories are skipped; filtering to *videos* is the frontend's job. + assert_eq!(names, ["a.mp4", "notes.txt"]); + assert_eq!(entries[0].size, 5); + assert!(entries[0].mtime > 0); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn snapshot_reader_streams_the_exact_bytes() { + let dir = temp_watch_dir("stream"); + fs::write(dir.join("clip.mp4"), b"0123456789").unwrap(); + let (size, mtime) = snapshot(&dir.join("clip.mp4")); + let handle = open_handle(&dir); + + let mut reader = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap(); + let mut whole = Vec::new(); + reader.read_to_end(&mut whole).unwrap(); + assert_eq!(whole, b"0123456789"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn reads_are_bound_to_the_settled_snapshot() { + let dir = temp_watch_dir("snapshot"); + fs::write(dir.join("clip.mp4"), b"settled bytes").unwrap(); + let (size, mtime) = snapshot(&dir.join("clip.mp4")); + let handle = open_handle(&dir); + + // The file is replaced after the scan settled → the read must refuse + // rather than upload bytes the tracker never saw stabilize. + fs::write(dir.join("clip.mp4"), b"replaced with something longer").unwrap(); + let err = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap_err(); + assert!(err.contains("changed"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn snapshot_reader_aborts_when_file_changes_during_stream() { + let dir = temp_watch_dir("mid-stream-change"); + fs::write(dir.join("clip.mp4"), b"settled bytes").unwrap(); + let (size, mtime) = snapshot(&dir.join("clip.mp4")); + let handle = open_handle(&dir); + let mut reader = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap(); + + fs::write(dir.join("clip.mp4"), b"different-length bytes").unwrap(); + let mut byte = [0_u8; 1]; + assert!(reader.read(&mut byte).is_err()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn authorization_pins_the_directory_identity() { + let dir = temp_watch_dir("identity"); + let watched = authorize_watched_dir(&dir).unwrap(); + // Untouched directory verifies fine… + assert!(verify_watched_dir(&watched).is_ok()); + // …and a directory that disappears after authorization is refused. + // The removal itself must succeed WHILE the handle is held: a watch + // that locked the user's folder against deletion (Windows sharing + // violation, OS error 32) would be a Windows-only behaviour. + fs::remove_dir_all(&dir).unwrap(); + assert!(verify_watched_dir(&watched).is_err()); + } + + #[test] + fn a_watched_folder_can_be_renamed_by_the_user_while_watched() { + // Cross-platform contract: holding the pinned handle never blocks the + // user from moving the folder (Explorer/Finder/mv). The liveness + // check is what notices — it must refuse, not the OS. + let dir = temp_watch_dir("rename-while-watched"); + let moved = dir.with_extension("moved"); + let _ = fs::remove_dir_all(&moved); + let watched = authorize_watched_dir(&dir).unwrap(); + fs::rename(&dir, &moved).unwrap(); + assert!(verify_watched_dir(&watched).is_err()); + // The pinned handle still points at the ORIGINAL directory object. + fs::write(moved.join("clip.mp4"), b"x").unwrap(); + let names: Vec = scan_dir(&watched.handle) + .unwrap() + .into_iter() + .map(|e| e.name) + .collect(); + assert_eq!(names, ["clip.mp4"]); + drop(watched); + let _ = fs::remove_dir_all(&moved); + } + + #[cfg(unix)] + #[test] + fn a_directory_swapped_for_a_symlink_is_refused_and_never_followed() { + let dir = temp_watch_dir("dir-swap"); + let elsewhere = temp_watch_dir("dir-swap-target"); + fs::write(elsewhere.join("clip.mp4"), b"outside").unwrap(); + let (size, mtime) = snapshot(&elsewhere.join("clip.mp4")); + + let watched = authorize_watched_dir(&dir).unwrap(); + assert!(verify_watched_dir(&watched).is_ok()); + + // Replace the authorized directory itself with a symlink pointing + // somewhere else. Token resolution refuses (identity check)… + fs::remove_dir_all(&dir).unwrap(); + std::os::unix::fs::symlink(&elsewhere, &dir).unwrap(); + let err = verify_watched_dir(&watched).unwrap_err(); + assert!(err.contains("identity"), "unexpected error: {err}"); + // …and even the pinned handle cannot reach the swap target: it still + // points at the ORIGINAL (now unlinked) directory, which is empty. + assert!(scan_dir(&watched.handle).unwrap().is_empty()); + assert!(open_watched_reader(&watched.handle, "clip.mp4", size, mtime).is_err()); + + let _ = fs::remove_file(&dir); + let _ = fs::remove_dir_all(&elsewhere); + } + + #[cfg(unix)] + #[test] + fn a_recreated_directory_at_the_same_path_is_refused() { + let dir = temp_watch_dir("dir-recreate"); + let watched = authorize_watched_dir(&dir).unwrap(); + fs::remove_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).unwrap(); // same path, different inode + assert!(verify_watched_dir(&watched).is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(windows)] + #[test] + fn a_replaced_directory_at_the_same_windows_path_is_refused() { + // Same pathname, different directory object (volume serial + file + // index): the pathname check alone would pass, the identity must not. + let dir = temp_watch_dir("windows-dir-replace"); + let moved = dir.with_extension("moved"); + let _ = fs::remove_dir_all(&moved); + let watched = authorize_watched_dir(&dir).unwrap(); + fs::rename(&dir, &moved).unwrap(); + fs::create_dir_all(&dir).unwrap(); + let err = verify_watched_dir(&watched).unwrap_err(); + assert!(err.contains("identity"), "unexpected error: {err}"); + drop(watched); + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&moved); + } + + #[cfg(unix)] + #[test] + fn symlinks_are_never_followed_out_of_the_folder() { + let dir = temp_watch_dir("symlink"); + let secret = std::env::temp_dir().join(format!("vs-secret-{}", std::process::id())); + fs::write(&secret, b"outside the folder").unwrap(); + std::os::unix::fs::symlink(&secret, dir.join("evil.mp4")).unwrap(); + let meta = fs::metadata(dir.join("evil.mp4")).unwrap(); + let handle = open_handle(&dir); + + // Even with a "correct" snapshot of the symlink target, opening it + // through the capability handle refuses: resolution may not escape + // the watched folder. + let err = + open_watched_reader(&handle, "evil.mp4", meta.len(), mtime_ms(&meta)).unwrap_err(); + assert!( + err.contains("could not be opened"), + "unexpected error: {err}" + ); + // And the scanner never lists it in the first place. + assert!(scan_dir(&handle).unwrap().is_empty()); + + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_file(&secret); + } +} diff --git a/frontend/src-tauri/src/wayland_shortcut.rs b/frontend/src-tauri/src/wayland_shortcut.rs index dafe35b5..760b8c88 100644 --- a/frontend/src-tauri/src/wayland_shortcut.rs +++ b/frontend/src-tauri/src/wayland_shortcut.rs @@ -1,645 +1,35 @@ -//! Wayland global shortcut support through xdg-desktop-portal. -//! -//! `tauri-plugin-global-shortcut` uses `global-hotkey`, whose Linux backend is -//! X11-only. Under XWayland its registration can still return `Ok(())`, but a -//! native Wayland compositor never sends it key events. The portal is the -//! compositor-owned, permission-aware API for this job. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{mpsc, Mutex}; -use std::time::Duration; - +//! Tauri adapter for the shared compositor-owned shortcut implementation. +pub use crate::wayland_shortcut_core::is_wayland_session; +use std::sync::Arc; use tauri::Manager; -use zbus::{ - blocking::{Connection, Proxy}, - zvariant::{OwnedObjectPath, OwnedValue, Str}, -}; - -const DESKTOP_DESTINATION: &str = "org.freedesktop.portal.Desktop"; -const DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop"; -const GLOBAL_SHORTCUTS_INTERFACE: &str = "org.freedesktop.portal.GlobalShortcuts"; -const REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request"; -const SESSION_INTERFACE: &str = "org.freedesktop.portal.Session"; -const REGISTRY_INTERFACE: &str = "org.freedesktop.host.portal.Registry"; -const SHORTCUT_ID: &str = "voice-dictation"; -static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1); -const PORTAL_LISTENER_TIMEOUT: Duration = Duration::from_secs(5); -const PORTAL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); - -type VariantMap = HashMap; - -#[derive(Clone)] -struct PortalRegistration { - connection: Connection, - session: OwnedObjectPath, -} - #[derive(Default)] -pub struct PortalShortcutState { - active: Mutex>, - revision: AtomicU64, -} - +pub struct PortalShortcutState(crate::wayland_shortcut_core::PortalShortcutState); impl PortalShortcutState { - pub fn replace(&self, app: tauri::AppHandle, accelerator: String) -> Result { - let revision = self.reserve(); - self.replace_reserved(app, accelerator, revision) - } - pub fn reserve(&self) -> u64 { - self.revision.fetch_add(1, Ordering::SeqCst) + 1 + self.0.reserve() } - - fn is_current(&self, revision: u64) -> bool { - self.revision.load(Ordering::SeqCst) == revision + pub fn replace(&self, app: tauri::AppHandle, accelerator: String) -> Result { + self.0.replace( + Arc::new(move |pressed| { + crate::dispatch_dictation_capture(&app, if pressed { "start" } else { "stop" }) + }), + accelerator, + ) } - pub fn replace_reserved( &self, app: tauri::AppHandle, accelerator: String, revision: u64, ) -> Result { - // Bind the replacement first. A declined consent dialog or unavailable - // portal therefore leaves the working shortcut and saved preference - // untouched. - let (registration, display) = bind(&accelerator)?; - if !self.is_current(revision) { - let _ = close_session(®istration); - return Err("shortcut registration was superseded by a newer request".into()); - } - let mut active = match self.active.lock() { - Ok(active) => active, - Err(_) => { - let _ = close_session(®istration); - return Err("portal shortcut lock poisoned".into()); - } - }; - if !self.is_current(revision) { - drop(active); - let _ = close_session(®istration); - return Err("shortcut registration was superseded by a newer request".into()); - } - if let Err(error) = start_listener(app, registration.clone()) { - let _ = close_session(®istration); - return Err(error); - } - let previous = active.replace(registration); - drop(active); - if let Some(previous) = previous { - if let Err(error) = close_session(&previous) { - log::warn!("Could not close the previous Wayland shortcut session: {error}"); - } - } - Ok(display) - } -} - -const DESKTOP_ID: &str = "com.debpalash.omnivoice-studio"; - -fn user_entry_path() -> Option { - dirs_next::data_dir().map(|dir| { - dir.join("applications") - .join(format!("{DESKTOP_ID}.desktop")) - }) -} - -/// A packaged (system-dir) entry — deb installs manage their own; never touch. -fn system_entry_exists() -> bool { - let filename = format!("{DESKTOP_ID}.desktop"); - std::env::var_os("XDG_DATA_DIRS") - .map(|dirs| { - std::env::split_paths(&dirs) - .any(|dir| dir.join("applications").join(&filename).is_file()) - }) - .unwrap_or_else(|| { - ["/usr/local/share", "/usr/share"].iter().any(|dir| { - std::path::Path::new(dir) - .join("applications") - .join(&filename) - .is_file() - }) - }) -} - -/// The `[Desktop Entry]` group's Exec target, unquoted. `None` when the main -/// group has no usable Exec line — which GLib treats the same as a missing -/// program. Scoped to the main group deliberately: a `[Desktop Action …]` -/// group carries its own `Exec=`, and accepting it would retain an entry GLib -/// still cannot resolve (CodeRabbit, #1526). -fn entry_exec_target(content: &str) -> Option { - let mut in_main_group = false; - let mut exec = None; - for line in content.lines() { - let line = line.trim_start(); - if line.starts_with('[') { - in_main_group = line == "[Desktop Entry]"; - continue; - } - if in_main_group { - if let Some(value) = line.strip_prefix("Exec=") { - exec = Some(value); - break; - } - } - } - let raw = exec?.trim(); - let unquoted = raw - .strip_prefix('"') - .and_then(|rest| rest.split('"').next()) - .unwrap_or_else(|| raw.split_whitespace().next().unwrap_or(raw)); - if unquoted.is_empty() { - return None; - } - Some(std::path::PathBuf::from(unquoted)) -} - -/// Whether a user-local identity entry must be rewritten before the portal -/// will accept it. -/// -/// GLib refuses to resolve a desktop entry whose Exec program does not exist -/// (`GDesktopAppInfo` returns NULL), and the portal then rejects the bind with -/// "App info not found" — the shortcut silently dies for the whole session. -/// A dev entry pointing at a `target/debug` binary goes stale exactly this -/// way: a `cargo clean`, a moved checkout, or anything that relocates the -/// binary breaks system-wide dictation with only a log line to show for it. -fn entry_needs_rewrite(content: &str, exec_exists: impl Fn(&std::path::Path) -> bool) -> bool { - match entry_exec_target(content) { - Some(target) => !exec_exists(&target), - None => true, - } -} - -fn desktop_exec_path() -> Result { - // AppImage's current_exe() points inside its transient mount. APPIMAGE is - // the stable launcher path the desktop entry must retain. - if let Some(path) = std::env::var_os("APPIMAGE").filter(|path| !path.is_empty()) { - return Ok(path.into()); - } - std::env::current_exe().map_err(|error| format!("could not locate VoiceStudio: {error}")) -} - -fn desktop_exec_value(path: &std::path::Path) -> String { - let escaped = path - .to_string_lossy() - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('`', "\\`") - .replace('$', "\\$"); - format!("\"{escaped}\"") -} - -/// The host portal resolves un-sandboxed apps through their desktop entry. -/// Deb packages already install one; dev builds and standalone AppImages may -/// not. Add an invisible identity entry only when none exists. -fn ensure_desktop_identity() -> Result<(), String> { - if system_entry_exists() { - return Ok(()); - } - let path = user_entry_path().ok_or("could not locate the user data directory")?; - if let Ok(existing) = std::fs::read_to_string(&path) { - if !entry_needs_rewrite(&existing, |target| target.exists()) { - return Ok(()); - } - // Stale: GLib returns NULL for an entry whose Exec is gone, and the - // portal then refuses the bind ("App info not found"). Rewrite with - // where the app actually is NOW. The user dir with our app id is ours - // to manage — packaged entries live in the system dirs handled above. - log::info!( - "Wayland portal identity at {} points at a missing program — rewriting", - path.display() - ); - } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("could not create applications directory: {error}"))?; - } - let entry = format!( - "[Desktop Entry]\nType=Application\nName=VoiceStudio\nExec={}\nTerminal=false\nNoDisplay=true\nStartupWMClass=VoiceStudio\nX-VoiceStudio-Generated=true\n", - desktop_exec_value(&desktop_exec_path()?) - ); - std::fs::write(&path, entry) - .map_err(|error| format!("could not create {}: {error}", path.display()))?; - log::info!("Installed Wayland portal identity at {}", path.display()); - Ok(()) -} - -pub fn is_wayland_session() -> bool { - std::env::var("XDG_SESSION_TYPE") - .map(|kind| kind.eq_ignore_ascii_case("wayland")) - .unwrap_or(false) - || std::env::var_os("WAYLAND_DISPLAY").is_some() -} - -/// Convert Tauri's cross-platform accelerator spelling to the portal format. -/// The portal may still let the user choose a different chord in its consent -/// dialog, so an unknown spelling is deliberately omitted rather than guessed. -fn portal_trigger(accelerator: &str) -> Option { - let mut modifiers: Vec = Vec::new(); - let mut key = None; - - for part in accelerator - .split('+') - .map(str::trim) - .filter(|part| !part.is_empty()) - { - match part.to_ascii_lowercase().as_str() { - "cmdorctrl" | "commandorcontrol" | "ctrl" | "control" => { - if !modifiers.iter().any(|modifier| modifier == "CTRL") { - modifiers.push("CTRL".into()); - } - } - "shift" => modifiers.push("SHIFT".into()), - "alt" | "option" => modifiers.push("ALT".into()), - "cmd" | "command" | "super" | "meta" => modifiers.push("LOGO".into()), - _ if key.is_none() => key = xkb_key_name(part), - _ => return None, - } - } - - let key = key?; - if modifiers.is_empty() { - return None; - } - modifiers.push(key); - Some(modifiers.join("+")) -} - -fn xkb_key_name(key: &str) -> Option { - let lower = key.to_ascii_lowercase(); - if let Some(letter) = lower.strip_prefix("key") { - if letter.len() == 1 - && letter - .chars() - .all(|character| character.is_ascii_alphabetic()) - { - return Some(letter.to_owned()); - } - } - if let Some(digit) = lower.strip_prefix("digit") { - if digit.len() == 1 && digit.chars().all(|character| character.is_ascii_digit()) { - return Some(digit.to_owned()); - } - } - if lower.len() == 1 - && lower - .chars() - .all(|character| character.is_ascii_alphanumeric()) - { - return Some(lower); - } - Some( - match lower.as_str() { - "space" => "space", - "enter" | "return" => "Return", - "escape" | "esc" => "Escape", - "tab" => "Tab", - "backspace" => "BackSpace", - "delete" => "Delete", - "insert" => "Insert", - "home" => "Home", - "end" => "End", - "pageup" => "Page_Up", - "pagedown" => "Page_Down", - "arrowup" | "up" => "Up", - "arrowdown" | "down" => "Down", - "arrowleft" | "left" => "Left", - "arrowright" | "right" => "Right", - "minus" => "minus", - "equal" => "equal", - "comma" => "comma", - "period" => "period", - "slash" => "slash", - "semicolon" => "semicolon", - "quote" | "apostrophe" => "apostrophe", - "bracketleft" => "bracketleft", - "bracketright" => "bracketright", - "backslash" => "backslash", - "backquote" | "grave" => "grave", - _ if lower.strip_prefix('f').is_some_and(|digits| { - digits - .parse::() - .is_ok_and(|number| (1..=35).contains(&number)) - }) => - { - return Some(key.to_ascii_uppercase()); - } - _ => return None, - } - .into(), - ) -} - -fn variant_string(value: &str) -> OwnedValue { - OwnedValue::from(Str::from(value)) -} - -fn trigger_description(shortcuts: Vec<(String, VariantMap)>) -> Option { - shortcuts - .into_iter() - .find(|(id, _)| id == SHORTCUT_ID) - .and_then(|(_, mut properties)| properties.remove("trigger_description")) - .and_then(|value| String::try_from(value).ok()) - .filter(|description| !description.trim().is_empty()) -} - -fn request_path(connection: &Connection, token: &str) -> Result { - let sender = connection - .unique_name() - .ok_or("session bus did not assign a unique name")? - .as_str() - .trim_start_matches(':') - .replace('.', "_"); - OwnedObjectPath::try_from(format!("{DESKTOP_PATH}/request/{sender}/{token}")) - .map_err(|error| format!("invalid portal request path: {error}")) -} - -fn response_for(connection: &Connection, token: &str, call: F) -> Result -where - F: FnOnce() -> Result, -{ - // Subscribe before making the request: a fast portal is allowed to answer - // immediately after returning the request handle. - let expected = request_path(connection, token)?; - let listener_connection = connection.clone(); - let listener_path = expected.clone(); - let (ready_tx, ready_rx) = mpsc::sync_channel(1); - let (response_tx, response_rx) = mpsc::sync_channel(1); - std::thread::Builder::new() - .name("wayland-portal-response".into()) - .spawn(move || { - let request = match Proxy::new( - &listener_connection, - DESKTOP_DESTINATION, - listener_path.as_str(), - REQUEST_INTERFACE, - ) { - Ok(request) => request, - Err(error) => { - let _ = ready_tx.send(Err(format!("portal request listener: {error}"))); - return; - } - }; - let mut responses = match request.receive_signal("Response") { - Ok(responses) => responses, - Err(error) => { - let _ = ready_tx.send(Err(format!("portal response listener: {error}"))); - return; - } - }; - if ready_tx.send(Ok(())).is_err() { - return; - } - let response = responses - .next() - .ok_or_else(|| "portal closed before answering the shortcut request".to_string()); - let _ = response_tx.send(response); - }) - .map_err(|error| format!("could not start the portal response listener: {error}"))?; - receive_with_timeout( - &ready_rx, - PORTAL_LISTENER_TIMEOUT, - "portal response listener", - )?; - - let returned = call().map_err(|error| format!("portal request failed: {error}"))?; - if returned != expected { - return Err(format!( - "portal returned unexpected request path {returned} (expected {expected})" - )); - } - - let message = match receive_with_timeout( - &response_rx, - PORTAL_RESPONSE_TIMEOUT, - "portal shortcut request", - ) { - Ok(message) => message, - Err(error) => { - if let Ok(request) = Proxy::new( - connection, - DESKTOP_DESTINATION, - expected.as_str(), - REQUEST_INTERFACE, - ) { - let _ = request.call::<_, _, ()>("Close", &()); - } - return Err(error); - } - }; - let (code, results): (u32, VariantMap) = message - .body() - .deserialize() - .map_err(|error| format!("invalid portal response: {error}"))?; - if code != 0 { - return Err(format!( - "portal shortcut request was declined (response {code})" - )); - } - Ok(results) -} - -fn receive_with_timeout( - receiver: &mpsc::Receiver>, - timeout: Duration, - operation: &str, -) -> Result { - match receiver.recv_timeout(timeout) { - Ok(result) => result, - Err(mpsc::RecvTimeoutError::Timeout) => Err(format!( - "{operation} timed out after {} seconds", - timeout.as_secs() - )), - Err(mpsc::RecvTimeoutError::Disconnected) => { - Err(format!("{operation} stopped before completing")) - } - } -} - -fn bind(accelerator: &str) -> Result<(PortalRegistration, String), String> { - ensure_desktop_identity()?; - let connection = Connection::session() - .map_err(|error| format!("could not connect to the desktop portal: {error}"))?; - - // GNOME's host portal uses the installed desktop entry to associate this - // un-sandboxed process with its desktop id. - let registry = Proxy::new( - &connection, - DESKTOP_DESTINATION, - DESKTOP_PATH, - REGISTRY_INTERFACE, - ) - .map_err(|error| format!("could not open the portal registry: {error}"))?; - let registry_options: VariantMap = HashMap::new(); - if let Err(error) = registry.call::<_, _, ()>("Register", &(DESKTOP_ID, registry_options)) { - // Development builds and portable AppImages may not have a desktop - // entry for the host registry to resolve. Portal v1 does not require - // this handshake, so continue and let CreateSession be authoritative. - log::warn!("Wayland portal host registration skipped: {error}"); - } - - let portal = Proxy::new( - &connection, - DESKTOP_DESTINATION, - DESKTOP_PATH, - GLOBAL_SHORTCUTS_INTERFACE, - ) - .map_err(|error| format!("could not open the global-shortcuts portal: {error}"))?; - - let process = std::process::id(); - let sequence = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let create_token = format!("vs_create_{process}_{sequence}"); - let session_token = format!("vs_session_{process}_{sequence}"); - let mut create_options = VariantMap::new(); - create_options.insert("handle_token".into(), variant_string(&create_token)); - create_options.insert( - "session_handle_token".into(), - variant_string(&session_token), - ); - let mut create_results = response_for(&connection, &create_token, || { - portal.call("CreateSession", &(create_options,)) - })?; - let session_value = create_results - .remove("session_handle") - .ok_or("portal did not return a shortcut session")?; - // The portal specification declares an object path, but deployed portal - // versions historically returned a string. Accept both wire formats. - let session = match session_value - .try_clone() - .ok() - .and_then(|value| OwnedObjectPath::try_from(value).ok()) - { - Some(path) => path, - None => { - let path = String::try_from(session_value).map_err(|error| { - format!("portal returned an invalid shortcut session handle: {error}") - })?; - OwnedObjectPath::try_from(path) - .map_err(|error| format!("portal returned an invalid session path: {error}"))? - } - }; - - let mut shortcut_info = VariantMap::new(); - shortcut_info.insert("description".into(), variant_string("VoiceStudio")); - if let Some(trigger) = portal_trigger(&accelerator) { - shortcut_info.insert("preferred_trigger".into(), variant_string(&trigger)); - } - let shortcuts = vec![(SHORTCUT_ID.to_string(), shortcut_info)]; - let bind_token = format!("vs_bind_{process}_{sequence}"); - let mut bind_options = VariantMap::new(); - bind_options.insert("handle_token".into(), variant_string(&bind_token)); - let mut bind_results = response_for(&connection, &bind_token, || { - portal.call( - "BindShortcuts", - &(session.clone(), shortcuts, "", bind_options), + self.0.replace_reserved( + Arc::new(move |pressed| { + crate::dispatch_dictation_capture(&app, if pressed { "start" } else { "stop" }) + }), + accelerator, + revision, ) - })?; - - let display = bind_results - .remove("shortcuts") - .and_then(|value| Vec::<(String, VariantMap)>::try_from(value).ok()) - .and_then(trigger_description) - .unwrap_or_else(|| crate::dictation_shortcut::display_accelerator(accelerator)); - - drop(portal); - drop(registry); - Ok(( - PortalRegistration { - connection, - session, - }, - display, - )) -} - -fn close_session(registration: &PortalRegistration) -> Result<(), String> { - let session = Proxy::new( - ®istration.connection, - DESKTOP_DESTINATION, - registration.session.as_str(), - SESSION_INTERFACE, - ) - .map_err(|error| format!("could not open the shortcut session: {error}"))?; - session - .call::<_, _, ()>("Close", &()) - .map_err(|error| format!("could not close the shortcut session: {error}")) -} - -/// Read the session handle and shortcut id out of an `Activated`/`Deactivated` -/// signal. -/// -/// The portal declares `(o session_handle, s shortcut_id, t timestamp, -/// a{sv} options)` — the timestamp is **64-bit**. Deserializing the body into a -/// `u32` field fails zbus' signature check, so every key press was discarded as -/// an invalid signal and dictation never started on any Wayland compositor. The -/// 32-bit spelling stays as a fallback so a non-conforming portal degrades to -/// working rather than to silence. -fn shortcut_signal_target(message: &zbus::Message) -> Result<(OwnedObjectPath, String), String> { - let body = message.body(); - if let Ok((session, shortcut_id, _timestamp, _options)) = - body.deserialize::<(OwnedObjectPath, String, u64, VariantMap)>() - { - return Ok((session, shortcut_id)); } - body.deserialize::<(OwnedObjectPath, String, u32, VariantMap)>() - .map(|(session, shortcut_id, _timestamp, _options)| (session, shortcut_id)) - .map_err(|error| error.to_string()) -} - -fn listen(app: tauri::AppHandle, registration: PortalRegistration) -> Result<(), String> { - let portal = Proxy::new( - ®istration.connection, - DESKTOP_DESTINATION, - DESKTOP_PATH, - GLOBAL_SHORTCUTS_INTERFACE, - ) - .map_err(|error| format!("could not open the global-shortcuts portal: {error}"))?; - - log::info!("Wayland dictation shortcut registered through xdg-desktop-portal"); - let mut signals = portal - .receive_all_signals() - .map_err(|error| format!("could not listen for portal shortcuts: {error}"))?; - for message in &mut signals { - let header = message.header(); - let member = header - .member() - .map(|name| name.as_str().to_owned()) - .unwrap_or_default(); - if member != "Activated" && member != "Deactivated" { - continue; - } - let (signal_session, shortcut_id) = match shortcut_signal_target(&message) { - Ok(target) => target, - Err(error) => { - log::warn!("Invalid Wayland shortcut signal: {error}"); - continue; - } - }; - if signal_session != registration.session || shortcut_id != SHORTCUT_ID { - continue; - } - if member == "Activated" { - log::info!("Wayland shortcut pressed: dictation start"); - crate::dispatch_dictation_capture(&app, "start"); - } else { - log::info!("Wayland shortcut released: dictation stop"); - crate::dispatch_dictation_capture(&app, "stop"); - } - } - Err("global-shortcuts portal closed the session".into()) -} - -fn start_listener(app: tauri::AppHandle, registration: PortalRegistration) -> Result<(), String> { - std::thread::Builder::new() - .name("wayland-global-shortcut".into()) - .spawn(move || { - if let Err(error) = listen(app, registration) { - log::info!("Wayland shortcut listener stopped: {error}"); - } - }) - .map(|_| ()) - .map_err(|error| format!("failed to start Wayland shortcut listener: {error}")) } pub fn register_initial(app: tauri::AppHandle, accelerator: String, revision: u64) { @@ -657,160 +47,3 @@ pub fn register_initial(app: tauri::AppHandle, accelerator: String, revision: u6 log::error!("Failed to start Wayland shortcut setup: {error}"); } } - -#[cfg(test)] -mod tests { - use super::{ - desktop_exec_value, portal_trigger, receive_with_timeout, shortcut_signal_target, - trigger_description, variant_string, PortalShortcutState, VariantMap, - GLOBAL_SHORTCUTS_INTERFACE, SHORTCUT_ID, - }; - use std::collections::HashMap; - use std::path::Path; - use std::sync::mpsc; - use std::time::Duration; - use zbus::zvariant::OwnedObjectPath; - - #[test] - fn converts_tauri_accelerators_to_portal_triggers() { - assert_eq!( - portal_trigger("CmdOrCtrl+Shift+Space").as_deref(), - Some("CTRL+SHIFT+space") - ); - assert_eq!( - portal_trigger("Alt+Control+K").as_deref(), - Some("ALT+CTRL+k") - ); - assert_eq!( - portal_trigger("Super+PageUp").as_deref(), - Some("LOGO+Page_Up") - ); - assert_eq!( - portal_trigger("Ctrl+BracketLeft").as_deref(), - Some("CTRL+bracketleft") - ); - assert_eq!(portal_trigger("Cmd+Digit1").as_deref(), Some("LOGO+1")); - } - - #[test] - fn rejects_modifier_free_or_ambiguous_accelerators() { - assert_eq!(portal_trigger("Space"), None); - assert_eq!(portal_trigger("Ctrl+K+L"), None); - } - - #[test] - fn a_stale_identity_entry_is_rewritten() { - // The class from 2026-08-13: the entry's Exec pointed at a binary that - // had been moved. GLib then resolves the entry to NULL and the portal - // refuses the bind with "App info not found" — system-wide dictation - // silently dead for the whole session. - let stale = "[Desktop Entry]\nType=Application\nExec=/gone/omnivoice-studio\n"; - assert!(super::entry_needs_rewrite(stale, |_| false)); - - let healthy = "[Desktop Entry]\nType=Application\nExec=\"/opt/VoiceStudio.AppImage\"\n"; - assert!(!super::entry_needs_rewrite(healthy, |path| { - path == std::path::Path::new("/opt/VoiceStudio.AppImage") - })); - } - - #[test] - fn exec_targets_parse_quoted_legacy_and_missing_lines() { - use super::entry_exec_target; - // Current writer: quoted. - assert_eq!( - entry_exec_target("[Desktop Entry]\nExec=\"/tmp/Voice Studio/app\"\n").as_deref(), - Some(std::path::Path::new("/tmp/Voice Studio/app")) - ); - // Pre-quoting entries from older builds still parse. - assert_eq!( - entry_exec_target("[Desktop Entry]\nExec=/home/u/target/debug/omnivoice-studio\n") - .as_deref(), - Some(std::path::Path::new("/home/u/target/debug/omnivoice-studio")) - ); - // No Exec at all resolves to NULL in GLib — treat as needing rewrite. - assert_eq!(entry_exec_target("[Desktop Entry]\nType=Application\n"), None); - assert!(super::entry_needs_rewrite("[Desktop Entry]\n", |_| true)); - // An action group's Exec is NOT the entry's Exec: GLib still resolves - // the entry to NULL without a main-group Exec, so accepting this would - // keep exactly the stale entry the rewrite exists to replace. - let action_only = "[Desktop Entry]\nType=Application\n[Desktop Action new]\nExec=/bin/true\n"; - assert_eq!(entry_exec_target(action_only), None); - assert!(super::entry_needs_rewrite(action_only, |_| true)); - } - - #[test] - fn desktop_exec_paths_are_quoted_and_escaped() { - assert_eq!( - desktop_exec_value(Path::new("/tmp/Voice Studio/$build")), - "\"/tmp/Voice Studio/\\$build\"" - ); - } - - #[test] - fn uses_the_portals_effective_trigger_description() { - let mut properties = HashMap::new(); - properties.insert("trigger_description".into(), variant_string("Meta+Shift+V")); - assert_eq!( - trigger_description(vec![("voice-dictation".into(), properties)]).as_deref(), - Some("Meta+Shift+V") - ); - } - - #[test] - fn newer_rebinds_supersede_in_flight_registration() { - let state = PortalShortcutState::default(); - let startup = state.reserve(); - let changed = state.reserve(); - assert!(!state.is_current(startup)); - assert!(state.is_current(changed)); - } - - fn shortcut_signal(timestamp: T) -> zbus::Message - where - T: serde::Serialize + zbus::zvariant::Type, - { - let session = OwnedObjectPath::try_from("/org/freedesktop/portal/desktop/session/1").unwrap(); - zbus::Message::signal( - super::DESKTOP_PATH, - GLOBAL_SHORTCUTS_INTERFACE, - "Activated", - ) - .unwrap() - .build(&(session, SHORTCUT_ID, timestamp, VariantMap::new())) - .unwrap() - } - - /// The portal spells the timestamp `t`; a `u32` field made zbus reject every - /// signal, which silently killed Wayland dictation. - #[test] - fn reads_portal_signals_with_a_64_bit_timestamp() { - let message = shortcut_signal(1_786_563_484_746_u64); - let (session, shortcut_id) = shortcut_signal_target(&message) - .expect("64-bit timestamps are the portal's declared spelling"); - assert_eq!( - session.as_str(), - "/org/freedesktop/portal/desktop/session/1" - ); - assert_eq!(shortcut_id, SHORTCUT_ID); - } - - #[test] - fn still_reads_a_32_bit_timestamp_from_a_nonconforming_portal() { - let message = shortcut_signal(42_u32); - let (_session, shortcut_id) = shortcut_signal_target(&message) - .expect("a 32-bit timestamp must not drop the key press"); - assert_eq!(shortcut_id, SHORTCUT_ID); - } - - #[test] - fn portal_response_wait_is_bounded() { - let (_sender, receiver) = mpsc::channel::>(); - let error = receive_with_timeout( - &receiver, - Duration::from_millis(1), - "portal shortcut request", - ) - .unwrap_err(); - assert!(error.contains("timed out")); - } -} diff --git a/frontend/src-tauri/src/wayland_shortcut_core.rs b/frontend/src-tauri/src/wayland_shortcut_core.rs new file mode 100644 index 00000000..ce4895b5 --- /dev/null +++ b/frontend/src-tauri/src/wayland_shortcut_core.rs @@ -0,0 +1,854 @@ +//! Wayland global shortcut support through xdg-desktop-portal. +//! +//! `tauri-plugin-global-shortcut` uses `global-hotkey`, whose Linux backend is +//! X11-only. Under XWayland its registration can still return `Ok(())`, but a +//! native Wayland compositor never sends it key events. The portal is the +//! compositor-owned, permission-aware API for this job. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{mpsc, Mutex}; +use std::time::Duration; + +use std::sync::Arc; +type Callback = Arc; +use zbus::{ + blocking::{Connection, Proxy}, + zvariant::{OwnedObjectPath, OwnedValue, Str}, +}; + +const DESKTOP_DESTINATION: &str = "org.freedesktop.portal.Desktop"; +const DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop"; +const GLOBAL_SHORTCUTS_INTERFACE: &str = "org.freedesktop.portal.GlobalShortcuts"; +const REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request"; +const SESSION_INTERFACE: &str = "org.freedesktop.portal.Session"; +const REGISTRY_INTERFACE: &str = "org.freedesktop.host.portal.Registry"; +const SHORTCUT_ID: &str = "voice-dictation"; +static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1); +const PORTAL_LISTENER_TIMEOUT: Duration = Duration::from_secs(5); +const PORTAL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); + +type VariantMap = HashMap; + +#[derive(Clone)] +struct PortalRegistration { + connection: Connection, + session: OwnedObjectPath, +} + +#[derive(Default)] +pub struct PortalShortcutState { + active: Mutex>, + revision: AtomicU64, + identity: DesktopIdentity, +} + +impl PortalShortcutState { + pub fn for_desktop(desktop_id: &'static str, executable: std::path::PathBuf) -> Self { + Self { + identity: DesktopIdentity { + desktop_id, + executable: Some(executable), + }, + ..Self::default() + } + } + pub fn close(&self) { + self.reserve(); + if let Ok(mut active) = self.active.lock() { + if let Some(previous) = active.take() { + let _ = close_session(&previous); + } + } + } + + pub fn replace(&self, callback: Callback, accelerator: String) -> Result { + let revision = self.reserve(); + self.replace_reserved(callback, accelerator, revision) + } + + pub fn reserve(&self) -> u64 { + self.revision.fetch_add(1, Ordering::SeqCst) + 1 + } + + fn is_current(&self, revision: u64) -> bool { + self.revision.load(Ordering::SeqCst) == revision + } + + pub fn replace_reserved( + &self, + callback: Callback, + accelerator: String, + revision: u64, + ) -> Result { + // Bind the replacement first. A declined consent dialog or unavailable + // portal therefore leaves the working shortcut and saved preference + // untouched. + let (registration, display) = bind(&accelerator, &self.identity)?; + if !self.is_current(revision) { + let _ = close_session(®istration); + return Err("shortcut registration was superseded by a newer request".into()); + } + let mut active = match self.active.lock() { + Ok(active) => active, + Err(_) => { + let _ = close_session(®istration); + return Err("portal shortcut lock poisoned".into()); + } + }; + if !self.is_current(revision) { + drop(active); + let _ = close_session(®istration); + return Err("shortcut registration was superseded by a newer request".into()); + } + if let Err(error) = start_listener(callback, registration.clone()) { + let _ = close_session(®istration); + return Err(error); + } + let previous = active.replace(registration); + drop(active); + if let Some(previous) = previous { + if let Err(error) = close_session(&previous) { + log::warn!("Could not close the previous Wayland shortcut session: {error}"); + } + } + Ok(display) + } +} + +struct DesktopIdentity { + desktop_id: &'static str, + executable: Option, +} +impl Default for DesktopIdentity { + fn default() -> Self { + Self { + desktop_id: "com.debpalash.omnivoice-studio", + executable: None, + } + } +} + +fn user_entry_path(desktop_id: &str) -> Option { + dirs_next::data_dir().map(|dir| { + dir.join("applications") + .join(format!("{desktop_id}.desktop")) + }) +} + +/// A packaged (system-dir) entry — deb installs manage their own; never touch. +fn system_entry_exists(desktop_id: &str) -> bool { + let filename = format!("{desktop_id}.desktop"); + std::env::var_os("XDG_DATA_DIRS") + .map(|dirs| { + std::env::split_paths(&dirs) + .any(|dir| dir.join("applications").join(&filename).is_file()) + }) + .unwrap_or_else(|| { + ["/usr/local/share", "/usr/share"].iter().any(|dir| { + std::path::Path::new(dir) + .join("applications") + .join(&filename) + .is_file() + }) + }) +} + +/// The `[Desktop Entry]` group's Exec target, unquoted. `None` when the main +/// group has no usable Exec line — which GLib treats the same as a missing +/// program. Scoped to the main group deliberately: a `[Desktop Action …]` +/// group carries its own `Exec=`, and accepting it would retain an entry GLib +/// still cannot resolve (CodeRabbit, #1526). +fn entry_exec_target(content: &str) -> Option { + let mut in_main_group = false; + let mut exec = None; + for line in content.lines() { + let line = line.trim_start(); + if line.starts_with('[') { + in_main_group = line == "[Desktop Entry]"; + continue; + } + if in_main_group { + if let Some(value) = line.strip_prefix("Exec=") { + exec = Some(value); + break; + } + } + } + let raw = exec?.trim(); + let unquoted = raw + .strip_prefix('"') + .and_then(|rest| rest.split('"').next()) + .unwrap_or_else(|| raw.split_whitespace().next().unwrap_or(raw)); + if unquoted.is_empty() { + return None; + } + Some(std::path::PathBuf::from(unquoted)) +} + +/// Whether a user-local identity entry must be rewritten before the portal +/// will accept it. +/// +/// GLib refuses to resolve a desktop entry whose Exec program does not exist +/// (`GDesktopAppInfo` returns NULL), and the portal then rejects the bind with +/// "App info not found" — the shortcut silently dies for the whole session. +/// A dev entry pointing at a `target/debug` binary goes stale exactly this +/// way: a `cargo clean`, a moved checkout, or anything that relocates the +/// binary breaks system-wide dictation with only a log line to show for it. +fn entry_needs_rewrite(content: &str, exec_exists: impl Fn(&std::path::Path) -> bool) -> bool { + match entry_exec_target(content) { + Some(target) => !exec_exists(&target), + None => true, + } +} + +fn desktop_exec_path() -> Result { + // AppImage's current_exe() points inside its transient mount. APPIMAGE is + // the stable launcher path the desktop entry must retain. + if let Some(path) = std::env::var_os("APPIMAGE").filter(|path| !path.is_empty()) { + return Ok(path.into()); + } + std::env::current_exe().map_err(|error| format!("could not locate VoiceStudio: {error}")) +} + +fn desktop_exec_value(path: &std::path::Path) -> String { + let escaped = path + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('`', "\\`") + .replace('$', "\\$"); + format!("\"{escaped}\"") +} + +/// The host portal resolves un-sandboxed apps through their desktop entry. +/// Deb packages already install one; dev builds and standalone AppImages may +/// not. Add an invisible identity entry only when none exists. +fn ensure_desktop_identity(identity: &DesktopIdentity) -> Result<(), String> { + if system_entry_exists(identity.desktop_id) { + return Ok(()); + } + let path = + user_entry_path(identity.desktop_id).ok_or("could not locate the user data directory")?; + if let Ok(existing) = std::fs::read_to_string(&path) { + if !entry_needs_rewrite(&existing, |target| target.exists()) { + return Ok(()); + } + // Stale: GLib returns NULL for an entry whose Exec is gone, and the + // portal then refuses the bind ("App info not found"). Rewrite with + // where the app actually is NOW. The user dir with our app id is ours + // to manage — packaged entries live in the system dirs handled above. + log::info!( + "Wayland portal identity at {} points at a missing program — rewriting", + path.display() + ); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("could not create applications directory: {error}"))?; + } + let entry = format!( + "[Desktop Entry]\nType=Application\nName=VoiceStudio\nExec={}\nTerminal=false\nNoDisplay=true\nStartupWMClass=VoiceStudio\nX-VoiceStudio-Generated=true\n", + desktop_exec_value(&identity.executable.clone().map(Ok).unwrap_or_else(desktop_exec_path)?) + ); + std::fs::write(&path, entry) + .map_err(|error| format!("could not create {}: {error}", path.display()))?; + log::info!("Installed Wayland portal identity at {}", path.display()); + Ok(()) +} + +pub fn is_wayland_session() -> bool { + std::env::var("XDG_SESSION_TYPE") + .map(|kind| kind.eq_ignore_ascii_case("wayland")) + .unwrap_or(false) + || std::env::var_os("WAYLAND_DISPLAY").is_some() +} + +/// Convert Tauri's cross-platform accelerator spelling to the portal format. +/// The portal may still let the user choose a different chord in its consent +/// dialog, so an unknown spelling is deliberately omitted rather than guessed. +fn portal_trigger(accelerator: &str) -> Option { + let mut modifiers: Vec = Vec::new(); + let mut key = None; + + for part in accelerator + .split('+') + .map(str::trim) + .filter(|part| !part.is_empty()) + { + match part.to_ascii_lowercase().as_str() { + "cmdorctrl" | "commandorcontrol" | "ctrl" | "control" => { + if !modifiers.iter().any(|modifier| modifier == "CTRL") { + modifiers.push("CTRL".into()); + } + } + "shift" => modifiers.push("SHIFT".into()), + "alt" | "option" => modifiers.push("ALT".into()), + "cmd" | "command" | "super" | "meta" => modifiers.push("LOGO".into()), + _ if key.is_none() => key = xkb_key_name(part), + _ => return None, + } + } + + let key = key?; + if modifiers.is_empty() { + return None; + } + modifiers.push(key); + Some(modifiers.join("+")) +} + +fn xkb_key_name(key: &str) -> Option { + let lower = key.to_ascii_lowercase(); + if let Some(letter) = lower.strip_prefix("key") { + if letter.len() == 1 + && letter + .chars() + .all(|character| character.is_ascii_alphabetic()) + { + return Some(letter.to_owned()); + } + } + if let Some(digit) = lower.strip_prefix("digit") { + if digit.len() == 1 && digit.chars().all(|character| character.is_ascii_digit()) { + return Some(digit.to_owned()); + } + } + if lower.len() == 1 + && lower + .chars() + .all(|character| character.is_ascii_alphanumeric()) + { + return Some(lower); + } + Some( + match lower.as_str() { + "space" => "space", + "enter" | "return" => "Return", + "escape" | "esc" => "Escape", + "tab" => "Tab", + "backspace" => "BackSpace", + "delete" => "Delete", + "insert" => "Insert", + "home" => "Home", + "end" => "End", + "pageup" => "Page_Up", + "pagedown" => "Page_Down", + "arrowup" | "up" => "Up", + "arrowdown" | "down" => "Down", + "arrowleft" | "left" => "Left", + "arrowright" | "right" => "Right", + "minus" => "minus", + "equal" => "equal", + "comma" => "comma", + "period" => "period", + "slash" => "slash", + "semicolon" => "semicolon", + "quote" | "apostrophe" => "apostrophe", + "bracketleft" => "bracketleft", + "bracketright" => "bracketright", + "backslash" => "backslash", + "backquote" | "grave" => "grave", + _ if lower.strip_prefix('f').is_some_and(|digits| { + digits + .parse::() + .is_ok_and(|number| (1..=35).contains(&number)) + }) => + { + return Some(key.to_ascii_uppercase()); + } + _ => return None, + } + .into(), + ) +} + +fn variant_string(value: &str) -> OwnedValue { + OwnedValue::from(Str::from(value)) +} + +fn trigger_description(shortcuts: Vec<(String, VariantMap)>) -> Option { + shortcuts + .into_iter() + .find(|(id, _)| id == SHORTCUT_ID) + .and_then(|(_, mut properties)| properties.remove("trigger_description")) + .and_then(|value| String::try_from(value).ok()) + .filter(|description| !description.trim().is_empty()) +} + +fn request_path(connection: &Connection, token: &str) -> Result { + let sender = connection + .unique_name() + .ok_or("session bus did not assign a unique name")? + .as_str() + .trim_start_matches(':') + .replace('.', "_"); + OwnedObjectPath::try_from(format!("{DESKTOP_PATH}/request/{sender}/{token}")) + .map_err(|error| format!("invalid portal request path: {error}")) +} + +fn response_for(connection: &Connection, token: &str, call: F) -> Result +where + F: FnOnce() -> Result, +{ + // Subscribe before making the request: a fast portal is allowed to answer + // immediately after returning the request handle. + let expected = request_path(connection, token)?; + let listener_connection = connection.clone(); + let listener_path = expected.clone(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let (response_tx, response_rx) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("wayland-portal-response".into()) + .spawn(move || { + let request = match Proxy::new( + &listener_connection, + DESKTOP_DESTINATION, + listener_path.as_str(), + REQUEST_INTERFACE, + ) { + Ok(request) => request, + Err(error) => { + let _ = ready_tx.send(Err(format!("portal request listener: {error}"))); + return; + } + }; + let mut responses = match request.receive_signal("Response") { + Ok(responses) => responses, + Err(error) => { + let _ = ready_tx.send(Err(format!("portal response listener: {error}"))); + return; + } + }; + if ready_tx.send(Ok(())).is_err() { + return; + } + let response = responses + .next() + .ok_or_else(|| "portal closed before answering the shortcut request".to_string()); + let _ = response_tx.send(response); + }) + .map_err(|error| format!("could not start the portal response listener: {error}"))?; + receive_with_timeout( + &ready_rx, + PORTAL_LISTENER_TIMEOUT, + "portal response listener", + )?; + + let returned = call().map_err(|error| format!("portal request failed: {error}"))?; + if returned != expected { + return Err(format!( + "portal returned unexpected request path {returned} (expected {expected})" + )); + } + + let message = match receive_with_timeout( + &response_rx, + PORTAL_RESPONSE_TIMEOUT, + "portal shortcut request", + ) { + Ok(message) => message, + Err(error) => { + if let Ok(request) = Proxy::new( + connection, + DESKTOP_DESTINATION, + expected.as_str(), + REQUEST_INTERFACE, + ) { + let _ = request.call::<_, _, ()>("Close", &()); + } + return Err(error); + } + }; + let (code, results): (u32, VariantMap) = message + .body() + .deserialize() + .map_err(|error| format!("invalid portal response: {error}"))?; + if code != 0 { + return Err(format!( + "portal shortcut request was declined (response {code})" + )); + } + Ok(results) +} + +fn receive_with_timeout( + receiver: &mpsc::Receiver>, + timeout: Duration, + operation: &str, +) -> Result { + match receiver.recv_timeout(timeout) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => Err(format!( + "{operation} timed out after {} seconds", + timeout.as_secs() + )), + Err(mpsc::RecvTimeoutError::Disconnected) => { + Err(format!("{operation} stopped before completing")) + } + } +} + +fn bind( + accelerator: &str, + identity: &DesktopIdentity, +) -> Result<(PortalRegistration, String), String> { + ensure_desktop_identity(identity)?; + let connection = Connection::session() + .map_err(|error| format!("could not connect to the desktop portal: {error}"))?; + + // GNOME's host portal uses the installed desktop entry to associate this + // un-sandboxed process with its desktop id. + let registry = Proxy::new( + &connection, + DESKTOP_DESTINATION, + DESKTOP_PATH, + REGISTRY_INTERFACE, + ) + .map_err(|error| format!("could not open the portal registry: {error}"))?; + let registry_options: VariantMap = HashMap::new(); + if let Err(error) = + registry.call::<_, _, ()>("Register", &(identity.desktop_id, registry_options)) + { + // Development builds and portable AppImages may not have a desktop + // entry for the host registry to resolve. Portal v1 does not require + // this handshake, so continue and let CreateSession be authoritative. + log::warn!("Wayland portal host registration skipped: {error}"); + } + + let portal = Proxy::new( + &connection, + DESKTOP_DESTINATION, + DESKTOP_PATH, + GLOBAL_SHORTCUTS_INTERFACE, + ) + .map_err(|error| format!("could not open the global-shortcuts portal: {error}"))?; + + let process = std::process::id(); + let sequence = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let create_token = format!("vs_create_{process}_{sequence}"); + let session_token = format!("vs_session_{process}_{sequence}"); + let mut create_options = VariantMap::new(); + create_options.insert("handle_token".into(), variant_string(&create_token)); + create_options.insert( + "session_handle_token".into(), + variant_string(&session_token), + ); + let mut create_results = response_for(&connection, &create_token, || { + portal.call("CreateSession", &(create_options,)) + })?; + let session_value = create_results + .remove("session_handle") + .ok_or("portal did not return a shortcut session")?; + // The portal specification declares an object path, but deployed portal + // versions historically returned a string. Accept both wire formats. + let session = match session_value + .try_clone() + .ok() + .and_then(|value| OwnedObjectPath::try_from(value).ok()) + { + Some(path) => path, + None => { + let path = String::try_from(session_value).map_err(|error| { + format!("portal returned an invalid shortcut session handle: {error}") + })?; + OwnedObjectPath::try_from(path) + .map_err(|error| format!("portal returned an invalid session path: {error}"))? + } + }; + + let mut shortcut_info = VariantMap::new(); + shortcut_info.insert("description".into(), variant_string("VoiceStudio")); + if let Some(trigger) = portal_trigger(&accelerator) { + shortcut_info.insert("preferred_trigger".into(), variant_string(&trigger)); + } + let shortcuts = vec![(SHORTCUT_ID.to_string(), shortcut_info)]; + let bind_token = format!("vs_bind_{process}_{sequence}"); + let mut bind_options = VariantMap::new(); + bind_options.insert("handle_token".into(), variant_string(&bind_token)); + let mut bind_results = response_for(&connection, &bind_token, || { + portal.call( + "BindShortcuts", + &(session.clone(), shortcuts, "", bind_options), + ) + })?; + + let display = bind_results + .remove("shortcuts") + .and_then(|value| Vec::<(String, VariantMap)>::try_from(value).ok()) + .and_then(trigger_description) + .unwrap_or_else(|| display_accelerator(accelerator)); + + drop(portal); + drop(registry); + Ok(( + PortalRegistration { + connection, + session, + }, + display, + )) +} + +fn close_session(registration: &PortalRegistration) -> Result<(), String> { + let session = Proxy::new( + ®istration.connection, + DESKTOP_DESTINATION, + registration.session.as_str(), + SESSION_INTERFACE, + ) + .map_err(|error| format!("could not open the shortcut session: {error}"))?; + session + .call::<_, _, ()>("Close", &()) + .map_err(|error| format!("could not close the shortcut session: {error}")) +} + +/// Read the session handle and shortcut id out of an `Activated`/`Deactivated` +/// signal. +/// +/// The portal declares `(o session_handle, s shortcut_id, t timestamp, +/// a{sv} options)` — the timestamp is **64-bit**. Deserializing the body into a +/// `u32` field fails zbus' signature check, so every key press was discarded as +/// an invalid signal and dictation never started on any Wayland compositor. The +/// 32-bit spelling stays as a fallback so a non-conforming portal degrades to +/// working rather than to silence. +fn shortcut_signal_target(message: &zbus::Message) -> Result<(OwnedObjectPath, String), String> { + let body = message.body(); + if let Ok((session, shortcut_id, _timestamp, _options)) = + body.deserialize::<(OwnedObjectPath, String, u64, VariantMap)>() + { + return Ok((session, shortcut_id)); + } + body.deserialize::<(OwnedObjectPath, String, u32, VariantMap)>() + .map(|(session, shortcut_id, _timestamp, _options)| (session, shortcut_id)) + .map_err(|error| error.to_string()) +} + +fn listen(callback: Callback, registration: PortalRegistration) -> Result<(), String> { + let portal = Proxy::new( + ®istration.connection, + DESKTOP_DESTINATION, + DESKTOP_PATH, + GLOBAL_SHORTCUTS_INTERFACE, + ) + .map_err(|error| format!("could not open the global-shortcuts portal: {error}"))?; + + log::info!("Wayland dictation shortcut registered through xdg-desktop-portal"); + let mut signals = portal + .receive_all_signals() + .map_err(|error| format!("could not listen for portal shortcuts: {error}"))?; + for message in &mut signals { + let header = message.header(); + let member = header + .member() + .map(|name| name.as_str().to_owned()) + .unwrap_or_default(); + if member != "Activated" && member != "Deactivated" { + continue; + } + let (signal_session, shortcut_id) = match shortcut_signal_target(&message) { + Ok(target) => target, + Err(error) => { + log::warn!("Invalid Wayland shortcut signal: {error}"); + continue; + } + }; + if signal_session != registration.session || shortcut_id != SHORTCUT_ID { + continue; + } + if member == "Activated" { + log::info!("Wayland shortcut pressed: dictation start"); + callback(true); + } else { + log::info!("Wayland shortcut released: dictation stop"); + callback(false); + } + } + Err("global-shortcuts portal closed the session".into()) +} + +fn start_listener(callback: Callback, registration: PortalRegistration) -> Result<(), String> { + std::thread::Builder::new() + .name("wayland-global-shortcut".into()) + .spawn(move || { + if let Err(error) = listen(callback, registration) { + log::info!("Wayland shortcut listener stopped: {error}"); + } + }) + .map(|_| ()) + .map_err(|error| format!("failed to start Wayland shortcut listener: {error}")) +} + +#[cfg(test)] +mod tests { + use super::{ + desktop_exec_value, portal_trigger, receive_with_timeout, shortcut_signal_target, + trigger_description, variant_string, PortalShortcutState, VariantMap, + GLOBAL_SHORTCUTS_INTERFACE, SHORTCUT_ID, + }; + use std::collections::HashMap; + use std::path::Path; + use std::sync::mpsc; + use std::time::Duration; + use zbus::zvariant::OwnedObjectPath; + + #[test] + fn converts_tauri_accelerators_to_portal_triggers() { + assert_eq!( + portal_trigger("CmdOrCtrl+Shift+Space").as_deref(), + Some("CTRL+SHIFT+space") + ); + assert_eq!( + portal_trigger("Alt+Control+K").as_deref(), + Some("ALT+CTRL+k") + ); + assert_eq!( + portal_trigger("Super+PageUp").as_deref(), + Some("LOGO+Page_Up") + ); + assert_eq!( + portal_trigger("Ctrl+BracketLeft").as_deref(), + Some("CTRL+bracketleft") + ); + assert_eq!(portal_trigger("Cmd+Digit1").as_deref(), Some("LOGO+1")); + } + + #[test] + fn rejects_modifier_free_or_ambiguous_accelerators() { + assert_eq!(portal_trigger("Space"), None); + assert_eq!(portal_trigger("Ctrl+K+L"), None); + } + + #[test] + fn a_stale_identity_entry_is_rewritten() { + // The class from 2026-08-13: the entry's Exec pointed at a binary that + // had been moved. GLib then resolves the entry to NULL and the portal + // refuses the bind with "App info not found" — system-wide dictation + // silently dead for the whole session. + let stale = "[Desktop Entry]\nType=Application\nExec=/gone/omnivoice-studio\n"; + assert!(super::entry_needs_rewrite(stale, |_| false)); + + let healthy = "[Desktop Entry]\nType=Application\nExec=\"/opt/VoiceStudio.AppImage\"\n"; + assert!(!super::entry_needs_rewrite(healthy, |path| { + path == std::path::Path::new("/opt/VoiceStudio.AppImage") + })); + } + + #[test] + fn exec_targets_parse_quoted_legacy_and_missing_lines() { + use super::entry_exec_target; + // Current writer: quoted. + assert_eq!( + entry_exec_target("[Desktop Entry]\nExec=\"/tmp/Voice Studio/app\"\n").as_deref(), + Some(std::path::Path::new("/tmp/Voice Studio/app")) + ); + // Pre-quoting entries from older builds still parse. + assert_eq!( + entry_exec_target("[Desktop Entry]\nExec=/home/u/target/debug/omnivoice-studio\n") + .as_deref(), + Some(std::path::Path::new( + "/home/u/target/debug/omnivoice-studio" + )) + ); + // No Exec at all resolves to NULL in GLib — treat as needing rewrite. + assert_eq!( + entry_exec_target("[Desktop Entry]\nType=Application\n"), + None + ); + assert!(super::entry_needs_rewrite("[Desktop Entry]\n", |_| true)); + // An action group's Exec is NOT the entry's Exec: GLib still resolves + // the entry to NULL without a main-group Exec, so accepting this would + // keep exactly the stale entry the rewrite exists to replace. + let action_only = + "[Desktop Entry]\nType=Application\n[Desktop Action new]\nExec=/bin/true\n"; + assert_eq!(entry_exec_target(action_only), None); + assert!(super::entry_needs_rewrite(action_only, |_| true)); + } + + #[test] + fn desktop_exec_paths_are_quoted_and_escaped() { + assert_eq!( + desktop_exec_value(Path::new("/tmp/Voice Studio/$build")), + "\"/tmp/Voice Studio/\\$build\"" + ); + } + + #[test] + fn uses_the_portals_effective_trigger_description() { + let mut properties = HashMap::new(); + properties.insert("trigger_description".into(), variant_string("Meta+Shift+V")); + assert_eq!( + trigger_description(vec![("voice-dictation".into(), properties)]).as_deref(), + Some("Meta+Shift+V") + ); + } + + #[test] + fn newer_rebinds_supersede_in_flight_registration() { + let state = PortalShortcutState::default(); + let startup = state.reserve(); + let changed = state.reserve(); + assert!(!state.is_current(startup)); + assert!(state.is_current(changed)); + } + + fn shortcut_signal(timestamp: T) -> zbus::Message + where + T: serde::Serialize + zbus::zvariant::Type, + { + let session = + OwnedObjectPath::try_from("/org/freedesktop/portal/desktop/session/1").unwrap(); + zbus::Message::signal(super::DESKTOP_PATH, GLOBAL_SHORTCUTS_INTERFACE, "Activated") + .unwrap() + .build(&(session, SHORTCUT_ID, timestamp, VariantMap::new())) + .unwrap() + } + + /// The portal spells the timestamp `t`; a `u32` field made zbus reject every + /// signal, which silently killed Wayland dictation. + #[test] + fn reads_portal_signals_with_a_64_bit_timestamp() { + let message = shortcut_signal(1_786_563_484_746_u64); + let (session, shortcut_id) = shortcut_signal_target(&message) + .expect("64-bit timestamps are the portal's declared spelling"); + assert_eq!( + session.as_str(), + "/org/freedesktop/portal/desktop/session/1" + ); + assert_eq!(shortcut_id, SHORTCUT_ID); + } + + #[test] + fn still_reads_a_32_bit_timestamp_from_a_nonconforming_portal() { + let message = shortcut_signal(42_u32); + let (_session, shortcut_id) = shortcut_signal_target(&message) + .expect("a 32-bit timestamp must not drop the key press"); + assert_eq!(shortcut_id, SHORTCUT_ID); + } + + #[test] + fn portal_response_wait_is_bounded() { + let (_sender, receiver) = mpsc::channel::>(); + let error = receive_with_timeout( + &receiver, + Duration::from_millis(1), + "portal shortcut request", + ) + .unwrap_err(); + assert!(error.contains("timed out")); + } +} + +fn display_accelerator(accelerator: &str) -> String { + accelerator + .split('+') + .map(|part| match part.to_ascii_lowercase().as_str() { + "cmdorctrl" | "commandorcontrol" | "ctrl" | "control" => "Ctrl", + "cmd" | "command" | "meta" | "super" => "Super", + "alt" | "option" => "Alt", + "shift" => "Shift", + _ => part, + }) + .collect::>() + .join("+") +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index f1a2ed88..dc0806dd 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -46,7 +46,7 @@ } ], "security": { - "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' ipc://localhost http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* blob: data:; media-src 'self' blob: data: http://localhost:* http://127.0.0.1:* asset: https://asset.localhost; img-src 'self' blob: data: asset: https://asset.localhost http://localhost:* http://127.0.0.1:* https://fonts.gstatic.com; font-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;", + "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' ipc://localhost http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* https://eu.i.posthog.com blob: data:; media-src 'self' blob: data: http://localhost:* http://127.0.0.1:* asset: https://asset.localhost; img-src 'self' blob: data: asset: https://asset.localhost http://localhost:* http://127.0.0.1:* https://fonts.gstatic.com; font-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;", "assetProtocol": { "enable": true, "scope": [ From 975799d4dd25ab21f97fa1665f49e3b3bdc13912 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:03:24 -0700 Subject: [PATCH 02/44] docs: note shared native desktop contracts --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e2dd83..1d6552a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Tauri and Electron now share native dictation, watch-folder, and Wayland shortcut contracts; focused paste stays ordered and first-run uv stays pinned at 0.12.13 (#2122) - Stopping a process on macOS no longer fails with "Operation not permitted" when it was already exiting (#2032) - A YouTube link blocked by its "not a bot" check now says how to attach signed-in cookies in Dub, instead of quoting yt-dlp's command-line flags (#2036, #2034) - An engine that fails to start now says whether it timed out, crashed (with its exit code and last output) or answered wrongly, instead of "did not signal ready: None" (#2037, #2026) From a0e334763921586d44e9bb23cf56fe187e11ad94 Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:13:12 +0530 Subject: [PATCH 03/44] fix(electron): refine macOS sidebar and titlebar controls --- docs/electron-macos-shell.md | 13 ++ .../src/components/app-shell/app-shell.tsx | 10 +- .../src/components/app-shell/status-bar.tsx | 158 +++++++++++------- .../app-shell/system-notifications.test.tsx | 27 +++ .../app-shell/system-notifications.tsx | 39 +++-- .../app-shell/workspace-sidebar.tsx | 105 ++++++++---- electron/src/renderer/src/styles/globals.css | 3 + electron/tests/sidebar-layout-smoke.mjs | 125 +++++++++++++- 8 files changed, 375 insertions(+), 105 deletions(-) create mode 100644 docs/electron-macos-shell.md diff --git a/docs/electron-macos-shell.md b/docs/electron-macos-shell.md new file mode 100644 index 00000000..656f3e31 --- /dev/null +++ b/docs/electron-macos-shell.md @@ -0,0 +1,13 @@ +# macOS desktop shell + +The expanded sidebar reserves space for the native traffic lights and app name. +The collapsed sidebar is 64 px wide, with its toggle below the traffic lights +and its right divider beginning below the 72 px header region. + +Notifications appear at the top right, with space reserved before the bell. +The notification menu opens downward and remains available while notification +data loads. Settings uses an icon in the macOS sidebar footer; Local device +sits beside it and opens the device and compute-target menu. The expanded +sidebar retains the Local device label. + +Windows and Linux retain their existing notification and device placement. diff --git a/electron/src/renderer/src/components/app-shell/app-shell.tsx b/electron/src/renderer/src/components/app-shell/app-shell.tsx index 8af65cac..a93d0934 100644 --- a/electron/src/renderer/src/components/app-shell/app-shell.tsx +++ b/electron/src/renderer/src/components/app-shell/app-shell.tsx @@ -3,15 +3,23 @@ import { CommandPalette } from '@/components/command-palette'; import { Outlet, useRouterState } from '@tanstack/react-router'; import { BackendGate } from '../backend-gate'; import { RepairAgentDock } from './repair-agent-dock'; +import { isMac } from '../bridge'; +import { cn } from '@/lib/utils'; export function AppShell() { const pathname = useRouterState({ select: (state) => state.location.pathname, }); const settings = pathname.startsWith('/settings'); + const macWorkspace = isMac() && !settings; const SettingsWorkspace = pathname === '/settings/openapi' ? 'div' : 'main'; return ( -
+
{settings ? ( <> diff --git a/electron/src/renderer/src/components/app-shell/status-bar.tsx b/electron/src/renderer/src/components/app-shell/status-bar.tsx index ffee9b54..14db5513 100644 --- a/electron/src/renderer/src/components/app-shell/status-bar.tsx +++ b/electron/src/renderer/src/components/app-shell/status-bar.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useRef, useState, type ReactNode } from 'react'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { useTranslationEngines } from '@/features/settings/translation-settings'; import { Link } from '@tanstack/react-router'; @@ -222,7 +222,15 @@ function EngineTip({ ); } -export function StatusBar({ compact = false }: { compact?: boolean }) { +export function StatusBar({ + compact = false, + inline = false, + footerLeading, +}: { + compact?: boolean; + inline?: boolean; + footerLeading?: ReactNode; +}) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const [deviceOpen, setDeviceOpen] = useState(false); @@ -661,72 +669,88 @@ export function StatusBar({ compact = false }: { compact?: boolean }) { : 'modelSettings.unavailable', }, ]; + const iconDevicePopover = ( + + + } + > + + {deviceContent} + + ); if (compact) { return ( -
- - - } - > - - {deviceContent} - +
+ {iconDevicePopover}
); } return (
-
- - + + + } + > + + {deviceContent} + + -
+ +
+ )}
)} + {footerLeading && ( +
+ {footerLeading} +
{iconDevicePopover}
+ +
+ )}
); diff --git a/electron/src/renderer/src/components/app-shell/system-notifications.test.tsx b/electron/src/renderer/src/components/app-shell/system-notifications.test.tsx index 33ba714b..378e7e8e 100644 --- a/electron/src/renderer/src/components/app-shell/system-notifications.test.tsx +++ b/electron/src/renderer/src/components/app-shell/system-notifications.test.tsx @@ -46,6 +46,13 @@ describe('SystemNotifications desktop updates', () => { beforeEach(() => { mocks.navigate.mockReset(); mocks.listener = undefined; + mocks.state = { + status: 'available', + currentVersion: '0.5.2', + availableVersion: '0.5.3', + channel: 'stable', + progress: 0, + } as UpdateState; }); afterEach(cleanup); @@ -62,4 +69,24 @@ describe('SystemNotifications desktop updates', () => { fireEvent.click(await screen.findByText('common.open')); await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ to: '/settings/updates' })); }); + + test('opens while notifications are still loading', async () => { + mocks.state = { + status: 'idle', + currentVersion: '0.5.2', + channel: 'stable', + progress: 0, + } as UpdateState; + render( + + + , + ); + + const trigger = await screen.findByRole('button', { name: 'preferences.loading' }); + expect(trigger).toBeEnabled(); + expect(trigger).toHaveClass('app-no-drag'); + fireEvent.click(trigger); + expect(await screen.findByText('preferences.loading')).toBeVisible(); + }); }); diff --git a/electron/src/renderer/src/components/app-shell/system-notifications.tsx b/electron/src/renderer/src/components/app-shell/system-notifications.tsx index d186452b..72de9958 100644 --- a/electron/src/renderer/src/components/app-shell/system-notifications.tsx +++ b/electron/src/renderer/src/components/app-shell/system-notifications.tsx @@ -83,9 +83,11 @@ function LevelIcon({ level }: { level: SystemNotification['level'] }) { export function SystemNotifications({ enabled, compact = false, + titlebar = false, }: { enabled: boolean; compact?: boolean; + titlebar?: boolean; }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -120,24 +122,20 @@ export function SystemNotifications({ return { id: `desktop-update-${update.availableVersion}`, level: 'info', - title: t( - update.status === 'downloaded' ? 'update.ready' : 'update.available', - { version: update.availableVersion }, - ), + title: t(update.status === 'downloaded' ? 'update.ready' : 'update.available', { + version: update.availableVersion, + }), message: t('update.safety'), action: { type: 'navigate', target: '/settings/updates', label: t('common.open') }, persistent: true, }; }, [t, update]); - const visible = useMemo( - () => { - const backend = (query.data?.notifications ?? []).filter( - (note) => note.level === 'error' || !dismissed.includes(note.id), - ); - return updateNotification ? [updateNotification, ...backend] : backend; - }, - [dismissed, query.data?.notifications, updateNotification], - ); + const visible = useMemo(() => { + const backend = (query.data?.notifications ?? []).filter( + (note) => note.level === 'error' || !dismissed.includes(note.id), + ); + return updateNotification ? [updateNotification, ...backend] : backend; + }, [dismissed, query.data?.notifications, updateNotification]); const dismiss = (id: string) => { const next = [...dismissed.filter((item) => item !== id), id].slice(-50); @@ -186,9 +184,11 @@ export function SystemNotifications({ + +
- -
+ {!mac && } +
- + {mac && } + {!mac && }
)} @@ -137,7 +169,7 @@ export function WorkspaceSidebar() {
- -
- - - {t('nav.settings')} - - -
+ + + + ) : undefined + } + /> + {!mac && ( +
+ + + {t('nav.settings')} + + +
+ )}
)} diff --git a/electron/src/renderer/src/styles/globals.css b/electron/src/renderer/src/styles/globals.css index 708f7178..f1028ef5 100644 --- a/electron/src/renderer/src/styles/globals.css +++ b/electron/src/renderer/src/styles/globals.css @@ -175,6 +175,9 @@ .native-controls-right { padding-right: 148px; } +.macos-notification-safe-area main .workspace-titlebar { + padding-right: 3.5rem; +} /* Keep the Lucide family visually consistent across controls and pane headings. */ @layer base { diff --git a/electron/tests/sidebar-layout-smoke.mjs b/electron/tests/sidebar-layout-smoke.mjs index 558207fc..6d6868ad 100644 --- a/electron/tests/sidebar-layout-smoke.mjs +++ b/electron/tests/sidebar-layout-smoke.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -const browser = await chromium.launch({ channel: 'msedge', headless: true }); +const browser = await chromium.launch({ + ...(process.env.PLAYWRIGHT_BUNDLED === '1' ? {} : { channel: 'msedge' }), + headless: true, +}); const page = await browser.newPage(); const out = mkdtempSync(join(tmpdir(), 'voicestudio-sidebar-')); const ui = process.env.VOICESTUDIO_UI_URL || 'http://localhost:3912'; @@ -79,8 +82,126 @@ try { await compactMain.waitFor(); } } + const macPage = await browser.newPage(); + try { + await macPage.addInitScript(() => { + localStorage.setItem('voicestudio.setup.complete.v1', '1'); + Object.defineProperty(window, 'voicestudio', { + value: { + app: { + version: 'test', + platform: 'darwin', + isDev: true, + onNavigate: () => () => {}, + onPersistenceFlush: () => () => {}, + }, + repair: { + list: async () => [], + getState: async () => ({ + status: 'idle', + output: '', + workspaceAvailable: false, + }), + onEvent: () => () => {}, + }, + }, + }); + }); + await macPage.goto(ui + '/#/clone'); + const macSidebar = macPage.locator('aside').first(); + const macNotifications = macPage.locator('[data-slot=macos-system-notifications]'); + const macNotificationBounds = await macNotifications.boundingBox(); + assert.ok( + macNotificationBounds && + macNotificationBounds.y < 20 && + macNotificationBounds.x + macNotificationBounds.width >= + (await macPage.evaluate(() => window.innerWidth)) - 20, + 'macOS notifications must sit in the top-right titlebar corner', + ); + const titlebarActions = await macPage.locator('main .workspace-titlebar button').all(); + const titlebarActionBounds = ( + await Promise.all(titlebarActions.map((action) => action.boundingBox())) + ).filter(Boolean); + assert.ok( + macNotificationBounds && + titlebarActionBounds.every( + (bounds) => bounds.x + bounds.width <= macNotificationBounds.x - 12, + ), + 'macOS titlebar actions must leave space before notifications', + ); + await macNotifications.getByRole('button').first().click(); + await macPage.locator('[data-slot=popover-content][data-open]').waitFor({ state: 'visible' }); + await macPage.keyboard.press('Escape'); + const brandLink = macSidebar.getByRole('link', { name: 'VoiceStudio', exact: true }); + const brandBounds = await brandLink.boundingBox(); + assert.ok(brandBounds && brandBounds.x >= 96, 'macOS brand must clear the traffic lights'); + assert.ok( + await brandLink + .locator('span') + .evaluate((element) => element.scrollWidth <= element.clientWidth), + 'macOS titlebar must show the complete VoiceStudio wordmark', + ); + const expandedSettings = macSidebar.getByRole('link', { name: 'Settings', exact: true }); + const expandedDevice = macSidebar.getByRole('button', { name: /Local device/ }); + const expandedSettingsBounds = await expandedSettings.boundingBox(); + const expandedDeviceBounds = await expandedDevice.boundingBox(); + assert.equal((await expandedSettings.innerText()).trim(), ''); + assert.ok( + expandedSettingsBounds && + expandedDeviceBounds && + expandedDeviceBounds.x > expandedSettingsBounds.x, + 'expanded macOS Local device must sit right of icon-only Settings', + ); + await expandedDevice.click(); + await macPage.locator('[data-slot=popover-content][data-open]').waitFor({ state: 'visible' }); + await macPage.keyboard.press('Escape'); + await macSidebar.getByRole('button', { name: 'Close', exact: true }).click(); + const compactMacSidebar = macPage.locator('[data-slot=compact-main-sidebar]'); + await compactMacSidebar.waitFor(); + assert.equal(Math.round((await compactMacSidebar.boundingBox()).width), 64); + const compactDividerBounds = await compactMacSidebar + .locator('[data-slot=compact-sidebar-divider]') + .boundingBox(); + assert.ok( + compactDividerBounds && compactDividerBounds.y >= 72, + 'macOS compact-sidebar divider must begin below the titlebar', + ); + const compactToggleBounds = await compactMacSidebar + .getByRole('button', { name: 'Toggle Sidebar', exact: true }) + .boundingBox(); + assert.ok( + compactToggleBounds && compactToggleBounds.y >= 32, + 'macOS compact-sidebar toggle must sit below the traffic lights', + ); + const compactSettingsBounds = await compactMacSidebar + .getByRole('link', { name: 'Settings', exact: true }) + .boundingBox(); + const compactDeviceBounds = await compactMacSidebar + .getByRole('button', { name: /Local device/ }) + .boundingBox(); + assert.ok( + compactSettingsBounds && + compactDeviceBounds && + compactDeviceBounds.x > compactSettingsBounds.x && + Math.abs( + compactDeviceBounds.y + + compactDeviceBounds.height / 2 - + (compactSettingsBounds.y + compactSettingsBounds.height / 2), + ) <= 1, + `macOS Local device must sit to the right of Settings: ${JSON.stringify({ compactSettingsBounds, compactDeviceBounds })}`, + ); + const workspaceTitleBounds = await macPage + .getByRole('heading', { name: 'Voice cloning' }) + .boundingBox(); + assert.ok( + workspaceTitleBounds && workspaceTitleBounds.x >= 88, + 'macOS workspace title must clear the traffic lights', + ); + } finally { + await macPage.close(); + } console.log( - 'Sidebar compact/expanded, 9 destinations, 6 engine links, visible models, non-duplicated Profiles, settings visibility and navigation passed. ' + + 'Sidebar compact/expanded, macOS titlebar clearance, 9 destinations, 6 engine links, visible models, non-duplicated Profiles, settings visibility and navigation passed. ' + out, ); } finally { From 4c3766c0c979e68faf7abede5cd4b1119955b48d Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:13:39 +0530 Subject: [PATCH 04/44] docs: note macOS shell improvements in changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e2dd83..9a67a62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- macOS desktop sidebar clears the traffic lights, uses a narrower collapsed rail, and places notifications and device controls with more space (#2126) - Stopping a process on macOS no longer fails with "Operation not permitted" when it was already exiting (#2032) - A YouTube link blocked by its "not a bot" check now says how to attach signed-in cookies in Dub, instead of quoting yt-dlp's command-line flags (#2036, #2034) - An engine that fails to start now says whether it timed out, crashed (with its exit code and last output) or answered wrongly, instead of "did not signal ready: None" (#2037, #2026) From 332d09e1a04983e2e8dee386a39af7b2572fe8cd Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:25:03 +0530 Subject: [PATCH 05/44] fix(electron): register notification hit region after titlebar drag regions --- docs/electron-macos-shell.md | 6 ++ .../src/components/app-shell/app-shell.tsx | 13 ++++ .../app-shell/workspace-sidebar.tsx | 8 --- electron/tests/fixtures/native-bell-host.cjs | 11 +++ electron/tests/fixtures/native-click.swift | 7 ++ electron/tests/native-bell-repro.mjs | 67 +++++++++++++++++++ 6 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 electron/tests/fixtures/native-bell-host.cjs create mode 100644 electron/tests/fixtures/native-click.swift create mode 100644 electron/tests/native-bell-repro.mjs diff --git a/docs/electron-macos-shell.md b/docs/electron-macos-shell.md index 656f3e31..2b754a3a 100644 --- a/docs/electron-macos-shell.md +++ b/docs/electron-macos-shell.md @@ -11,3 +11,9 @@ sits beside it and opens the device and compute-target menu. The expanded sidebar retains the Local device label. Windows and Linux retain their existing notification and device placement. + +The notification control follows workspace headers in document order so their +native drag regions cannot consume its mouse clicks. On macOS, run +`node tests/native-bell-repro.mjs` from `electron/` against the dev renderer +to verify a real system mouse click (requires Swift and Accessibility access). +Browser automation alone bypasses native titlebar hit testing. diff --git a/electron/src/renderer/src/components/app-shell/app-shell.tsx b/electron/src/renderer/src/components/app-shell/app-shell.tsx index a93d0934..1d1b676a 100644 --- a/electron/src/renderer/src/components/app-shell/app-shell.tsx +++ b/electron/src/renderer/src/components/app-shell/app-shell.tsx @@ -5,8 +5,11 @@ import { BackendGate } from '../backend-gate'; import { RepairAgentDock } from './repair-agent-dock'; import { isMac } from '../bridge'; import { cn } from '@/lib/utils'; +import { useBackendStatus } from '@/hooks/use-backend-status'; +import { SystemNotifications } from './system-notifications'; export function AppShell() { + const backend = useBackendStatus(); const pathname = useRouterState({ select: (state) => state.location.pathname, }); @@ -47,6 +50,16 @@ export function AppShell() { )}
+ {/* Native drag-region hit testing follows document order. Keep this + no-drag control after every workspace titlebar, outside BackendGate. */} + {macWorkspace && ( +
+ +
+ )}
); } diff --git a/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx b/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx index d2c33637..63f12bf4 100644 --- a/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx +++ b/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx @@ -85,14 +85,6 @@ export function WorkspaceSidebar() { }); return ( <> - {mac && ( -
- -
- )} {compact && (