diff --git a/AGENTS.md b/AGENTS.md index 7614d61..5fc3595 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,10 @@ Repo-local guide for agents working in PickScribe — local dictation for Linux tokens over raw values. - When command discovery returns a resolved path, spawn that path instead of the bare command so desktop `PATH` fallbacks keep working. +- Test fakes must mirror the real implementation's error contract so tests do + not pin fake-only behavior. +- Any spawned recording or capture child must have an owner with `Drop` cleanup + so app exit cannot orphan it. ## Releasing diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 78f964e..7b31b98 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -6,6 +6,10 @@ then reset this file. ## User-facing changes +- macOS dictation is now enabled end to end, with native cue playback and + appearance-aware tray icons. The macOS release remains blocked only on + Developer ID signing and notarization, and the local whisper.cpp installer + now supports Homebrew installs and portable build tooling on macOS. - On macOS, PickScribe can now start and stop dictation with a configurable in-app global shortcut (Cmd+Shift+Space by default). Linux continues to use the existing desktop-environment keybinding path. @@ -106,6 +110,17 @@ then reset this file. - Whisper model auto-detection fix: `cargo test` and `cargo clippy --workspace --all-targets -- -D warnings`. Live-verified the installed `~/.local/share/whisper.cpp/models/ggml-base.en.bin` model is selected. +- pickscribe#66 PR 5 (macOS platform polish and support flip): `cargo test` + (115 + 10 + 7 passing, 1 ignored), `cargo test --manifest-path + src-tauri/Cargo.toml` (38 passing), `cargo clippy --workspace --all-targets + -- -D warnings`, `bun install --frozen-lockfile`, `bunx vitest run` (42 + passing), `bun run check`, and `bash -n scripts/install-whisper-cpp-local` + pass on macOS. Live checks played the generated start cue once through + `/usr/bin/afplay` at low volume and confirmed the `defaults read -g + AppleInterfaceStyle` result (`Dark`) matches the current System Events dark + appearance. `node tests/install-script-smoke.mjs` does not exercise the + whisper.cpp installer and remains blocked by the pre-existing + `scripts/install.sh` line 154 syntax error. - pickscribe#66 PR 2 (macOS audio capture via ffmpeg/avfoundation): `cargo test` (workspace root, 122 tests including new `recorder_args` and `platform` coverage), `cargo test --manifest-path src-tauri/Cargo.toml` (37 diff --git a/scripts/install-whisper-cpp-local b/scripts/install-whisper-cpp-local index 9b15838..58b9f16 100755 --- a/scripts/install-whisper-cpp-local +++ b/scripts/install-whisper-cpp-local @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +if command -v whisper-cli >/dev/null 2>&1; then + echo "whisper-cli is already available on PATH; nothing to install (Homebrew users can use: brew install whisper-cpp)." + exit 0 +fi + SRC="${PICKSCRIBE_WHISPER_CPP_SRC:-${WHISPER_CPP_SRC:-$HOME/.local/src/whisper.cpp}}" BUILD="$SRC/build" BIN_DIR="$HOME/.local/bin" @@ -17,12 +22,24 @@ else git -C "$SRC" pull --ff-only fi -cmake -S "$SRC" -B "$BUILD" -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DWHISPER_BUILD_TESTS=OFF \ +cmake_args=( + -S "$SRC" + -B "$BUILD" + -DCMAKE_BUILD_TYPE=Release + -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON +) +if command -v ninja >/dev/null 2>&1; then + cmake_args+=( -G Ninja ) +fi +cmake "${cmake_args[@]}" -cmake --build "$BUILD" --config Release --parallel "$(nproc)" +case "$(uname -s)" in + Darwin) core_count="$(sysctl -n hw.ncpu)" ;; + Linux) core_count="$(nproc)" ;; + *) core_count=1 ;; +esac +cmake --build "$BUILD" --config Release --parallel "$core_count" if [[ -x "$BUILD/bin/whisper-cli" ]]; then ln -sf "$BUILD/bin/whisper-cli" "$BIN_DIR/whisper-cli" diff --git a/src-tauri/src/file_job.rs b/src-tauri/src/file_job.rs index ae45a29..02e7d18 100644 --- a/src-tauri/src/file_job.rs +++ b/src-tauri/src/file_job.rs @@ -265,8 +265,7 @@ struct FileJobComplete { } fn create_temp_dir() -> Result { - let parent = recorder::state_dir(); - fs::create_dir_all(&parent).with_context(|| format!("creating {}", parent.display()))?; + let parent = recorder::prepare_state_dir()?; let stamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c1aa60..f0d0af2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,6 +110,12 @@ mod tests { ); } + #[test] + fn delivery_backend_checks_skip_unsupported_platforms() { + assert!(delivery_backend_checks("windows").is_empty()); + assert!(delivery_backend_checks("unknown").is_empty()); + } + #[test] fn strip_debug_image_paths_basenames_all_image_variants() { let apple_uuid = "2df005a8-67ab-4d33-98f2-52f9f6de4d15"; @@ -535,45 +541,49 @@ fn run_doctor_checks() -> Vec { /// via `pbcopy`/`osascript` (System Events) and needs Accessibility access; /// Linux delivers via wl-copy/xclip/xsel and ydotool. fn delivery_backend_checks(os: &str) -> Vec<(&'static str, bool, String)> { - if os == "macos" { - let trusted = macos::accessibility_trusted(); - vec![ - ("Clipboard", command_exists("pbcopy"), "pbcopy".into()), - ( - "Paste backend", - trusted, - if trusted { - "osascript (System Events) — Accessibility granted".into() - } else { - "Accessibility not granted — enable PickScribe in System Settings \u{2192} Privacy & Security \u{2192} Accessibility, then relaunch".into() - }, - ), - ] - } else { - let ydotool = command_exists("ydotool"); - let socket = std::env::var("YDOTOOL_SOCKET") - .ok() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "/tmp/.ydotool_socket".into()); - let socket_ok = std::path::Path::new(&socket).exists(); - vec![ - ( - "Paste backend", - ydotool && socket_ok, - if !ydotool { - "ydotool not installed".into() - } else if !socket_ok { - "ydotool installed, but ydotool.service socket not found".into() - } else { - "ydotool + ydotoold socket".into() - }, - ), - ( - "Clipboard", - command_exists("wl-copy") || command_exists("xclip") || command_exists("xsel"), - "wl-copy / xclip / xsel".into(), - ), - ] + match os { + "macos" => { + let trusted = macos::accessibility_trusted(); + vec![ + ("Clipboard", command_exists("pbcopy"), "pbcopy".into()), + ( + "Paste backend", + trusted, + if trusted { + "osascript (System Events) — Accessibility granted".into() + } else { + "Accessibility not granted — enable PickScribe in System Settings \u{2192} Privacy & Security \u{2192} Accessibility, then relaunch".into() + }, + ), + ] + } + "linux" => { + let ydotool = command_exists("ydotool"); + let socket = std::env::var("YDOTOOL_SOCKET") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "/tmp/.ydotool_socket".into()); + let socket_ok = std::path::Path::new(&socket).exists(); + vec![ + ( + "Paste backend", + ydotool && socket_ok, + if !ydotool { + "ydotool not installed".into() + } else if !socket_ok { + "ydotool installed, but ydotool.service socket not found".into() + } else { + "ydotool + ydotoold socket".into() + }, + ), + ( + "Clipboard", + command_exists("wl-copy") || command_exists("xclip") || command_exists("xsel"), + "wl-copy / xclip / xsel".into(), + ), + ] + } + _ => Vec::new(), } } diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index b1d9d10..47fe753 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -8,7 +8,7 @@ #[cfg(target_os = "macos")] #[link(name = "ApplicationServices", kind = "framework")] unsafe extern "C" { - fn AXIsProcessTrusted() -> bool; + fn AXIsProcessTrusted() -> u8; } /// Returns whether this process has been granted Accessibility permission @@ -16,7 +16,7 @@ unsafe extern "C" { /// `osascript`-driven paste/type delivery to work. #[cfg(target_os = "macos")] pub fn accessibility_trusted() -> bool { - unsafe { AXIsProcessTrusted() } + unsafe { AXIsProcessTrusted() != 0 } } #[cfg(not(target_os = "macos"))] diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 8f2cc37..eb1b6a0 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -50,9 +50,22 @@ pub(crate) fn refresh_panel_prefers_dark() -> bool { value } -/// Asks the XDG settings portal: 0 = no preference, 1 = dark, 2 = light. -/// Defaults to dark on any failure, matching the original icon set. +/// Probes the platform appearance setting. An unset macOS style means light; +/// other failures default to dark, matching the original icon set. fn probe_panel_prefers_dark() -> bool { + if cfg!(target_os = "macos") { + return match std::process::Command::new("defaults") + .args(["read", "-g", "AppleInterfaceStyle"]) + .output() + { + Ok(out) if out.status.success() => { + String::from_utf8_lossy(&out.stdout).contains("Dark") + } + Ok(_) => false, + Err(_) => true, + }; + } + let output = std::process::Command::new("gdbus") .args([ "call", diff --git a/src/engine/paste.rs b/src/engine/paste.rs index 36b12ef..c5b53bb 100644 --- a/src/engine/paste.rs +++ b/src/engine/paste.rs @@ -502,9 +502,14 @@ mod tests { backend: Option, chord: PasteChord, ) -> Result<()> { - if let Some(backend) = backend { - self.pasted = Some((backend, chord)); - } + let Some(backend) = + backend.filter(|backend| !matches!(backend, TypeBackend::Auto | TypeBackend::None)) + else { + return Err(anyhow!( + "no paste hotkey backend found; install ydotool for Wayland or xdotool for X11" + )); + }; + self.pasted = Some((backend, chord)); if self.insertion_fails { Err(anyhow!("paste failed")) } else { @@ -513,9 +518,14 @@ mod tests { } fn type_text(&mut self, backend: Option, text: &str) -> Result<()> { - if let Some(backend) = backend { - self.typed = Some((backend, text.into())); - } + let Some(backend) = + backend.filter(|backend| !matches!(backend, TypeBackend::Auto | TypeBackend::None)) + else { + return Err(anyhow!( + "no typing backend found; install ydotool for Wayland, wtype if your compositor supports it, or xdotool for X11" + )); + }; + self.typed = Some((backend, text.into())); if self.insertion_fails { Err(anyhow!("type failed")) } else { @@ -696,9 +706,12 @@ mod tests { let outcome = deliver_with(&mut runtime, &config, "hello"); - // Auto with no usable backend falls back to Type, which then finds - // no backend either, so insertion silently no-ops instead of erroring. - assert!(outcome.into_result().is_ok()); + assert!(outcome.clipboard_error.is_none()); + assert_eq!( + outcome.insertion_error.as_ref().unwrap().to_string(), + "no typing backend found; install ydotool for Wayland, wtype if your compositor supports it, or xdotool for X11" + ); + assert!(outcome.into_result().is_err()); assert!(runtime.pasted.is_none()); assert!(runtime.typed.is_none()); } diff --git a/src/engine/recorder.rs b/src/engine/recorder.rs index 344bdaf..8bac403 100644 --- a/src/engine/recorder.rs +++ b/src/engine/recorder.rs @@ -8,30 +8,64 @@ use anyhow::{Context, Result, bail}; use crate::config::SttConfig; pub struct Recording { - child: Child, + child: Option, pub audio_path: PathBuf, pub log_path: PathBuf, pub started: Instant, } pub fn state_dir() -> PathBuf { - if let Ok(dir) = std::env::var("PICKSCRIBE_STATE_DIR") + state_dir_from_env(|name| std::env::var(name).ok()) +} + +fn state_dir_from_env(mut var: impl FnMut(&str) -> Option) -> PathBuf { + if let Some(dir) = var("PICKSCRIBE_STATE_DIR") && !dir.is_empty() { return PathBuf::from(dir); } - if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") + if let Some(dir) = var("XDG_RUNTIME_DIR") + && !dir.is_empty() + { + return PathBuf::from(dir).join("pickscribe"); + } + if let Some(dir) = var("TMPDIR") && !dir.is_empty() { return PathBuf::from(dir).join("pickscribe"); } - let user = std::env::var("USER").unwrap_or_else(|_| "user".into()); + let user = var("USER").unwrap_or_else(|| "user".into()); PathBuf::from(format!("/tmp/pickscribe-{user}")) } -pub fn start(cfg: &SttConfig) -> Result { +pub fn prepare_state_dir() -> Result { let dir = state_dir(); - fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + prepare_state_dir_at(&dir)?; + Ok(dir) +} + +fn prepare_state_dir_at(dir: &Path) -> Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(dir) + .with_context(|| format!("creating {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("securing {}", dir.display()))?; + } + Ok(()) +} + +pub fn start(cfg: &SttConfig) -> Result { + let dir = prepare_state_dir()?; let stamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -63,7 +97,7 @@ pub fn start(cfg: &SttConfig) -> Result { .with_context(|| format!("starting recorder `{recorder}`"))?; Ok(Recording { - child, + child: Some(child), audio_path, log_path, started: Instant::now(), @@ -134,7 +168,7 @@ impl Recording { /// shortly after `start` — off the UI thread — instead of `start` /// sleeping on the command path. pub fn exit_error(&mut self) -> Option { - let status = self.child.try_wait().ok()??; + let status = self.child.as_mut()?.try_wait().ok()??; let log = fs::read_to_string(&self.log_path).unwrap_or_default(); Some(format!( "recorder exited immediately ({status}): {}", @@ -145,15 +179,19 @@ impl Recording { /// Stop the recorder gracefully (SIGINT so the WAV header is finalized), /// escalating to SIGTERM/SIGKILL if needed. pub fn stop(mut self) -> Result<(PathBuf, u64)> { - let pid = self.child.id(); let duration = self.duration_ms(); + let mut child = self + .child + .take() + .context("recorder process already stopped")?; + let pid = child.id(); signal(pid, "INT"); - if !wait_exit(&mut self.child, Duration::from_secs(5)) { + if !wait_exit(&mut child, Duration::from_secs(5)) { signal(pid, "TERM"); - if !wait_exit(&mut self.child, Duration::from_secs(2)) { - let _ = self.child.kill(); - let _ = self.child.wait(); + if !wait_exit(&mut child, Duration::from_secs(2)) { + let _ = child.kill(); + let _ = child.wait(); } } @@ -170,15 +208,45 @@ impl Recording { /// Stop and discard everything. pub fn cancel(mut self) { - let pid = self.child.id(); - signal(pid, "INT"); - if !wait_exit(&mut self.child, Duration::from_secs(2)) { - let _ = self.child.kill(); - let _ = self.child.wait(); + if let Some(mut child) = self.child.take() { + let pid = child.id(); + signal(pid, "INT"); + if !wait_exit(&mut child, Duration::from_secs(2)) { + let _ = child.kill(); + let _ = child.wait(); + } } let _ = fs::remove_file(&self.audio_path); let _ = fs::remove_file(&self.log_path); } + + #[cfg(test)] + fn from_child(child: Child) -> Self { + Self { + child: Some(child), + audio_path: PathBuf::new(), + log_path: PathBuf::new(), + started: Instant::now(), + } + } +} + +impl Drop for Recording { + fn drop(&mut self) { + let Some(mut child) = self.child.take() else { + return; + }; + match child.try_wait() { + Ok(Some(_)) => {} + Ok(None) | Err(_) => { + signal(child.id(), "INT"); + if !wait_exit(&mut child, Duration::from_secs(2)) { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + } } fn signal(pid: u32, sig: &str) { @@ -203,6 +271,68 @@ fn wait_exit(child: &mut Child, timeout: Duration) -> bool { mod tests { use super::*; + #[test] + fn state_dir_uses_tmpdir_before_the_shared_tmp_fallback() { + let dir = state_dir_from_env(|name| match name { + "TMPDIR" => Some("/private/var/folders/user/T".into()), + "USER" => Some("alice".into()), + _ => None, + }); + + assert_eq!(dir, PathBuf::from("/private/var/folders/user/T/pickscribe")); + } + + #[test] + fn state_dir_keeps_explicit_and_xdg_precedence() { + let explicit = state_dir_from_env(|name| match name { + "PICKSCRIBE_STATE_DIR" => Some("/custom/state".into()), + "XDG_RUNTIME_DIR" => Some("/run/user/1000".into()), + "TMPDIR" => Some("/private/tmp".into()), + _ => None, + }); + let xdg = state_dir_from_env(|name| match name { + "XDG_RUNTIME_DIR" => Some("/run/user/1000".into()), + "TMPDIR" => Some("/private/tmp".into()), + _ => None, + }); + + assert_eq!(explicit, PathBuf::from("/custom/state")); + assert_eq!(xdg, PathBuf::from("/run/user/1000/pickscribe")); + } + + #[cfg(unix)] + #[test] + fn prepared_state_dir_has_private_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("state"); + fs::create_dir(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + + prepare_state_dir_at(&dir).unwrap(); + + let mode = fs::metadata(dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + } + + #[cfg(unix)] + #[test] + fn dropping_recording_terminates_and_reaps_child() { + let child = Command::new("sleep").arg("30").spawn().unwrap(); + let pid = child.id(); + + drop(Recording::from_child(child)); + + let status = Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(!status.success(), "recording child {pid} is still alive"); + } + #[test] fn pw_record_args_are_unchanged() { let cfg = SttConfig { diff --git a/src/engine/sounds.rs b/src/engine/sounds.rs index 9a1d782..10895ec 100644 --- a/src/engine/sounds.rs +++ b/src/engine/sounds.rs @@ -78,7 +78,7 @@ fn write_wav(path: &PathBuf, segments: &[(f32, f32, f32)]) -> Result<()> { pub fn play(cue: Cue) { std::thread::spawn(move || { let Ok(path) = cue_path(&cue) else { return }; - for player in ["pw-play", "paplay", "aplay"] { + for player in ["pw-play", "paplay", "aplay", "afplay"] { if let Some(program) = find_command(player) { let _ = Command::new(program) .arg(&path) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 753f921..9a29298 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -49,7 +49,8 @@ config !== null && savedJson !== "" && JSON.stringify($state.snapshot(config)) !== savedJson ); const saveDisplay = $derived(settingsSaveDisplayState(dirty)); - const platformDisplay = settingsPlatformDisplayState(hostPlatform()); + const platform = hostPlatform(); + const platformDisplay = settingsPlatformDisplayState(platform); $effect(() => { onDirtyChange(dirty); @@ -470,7 +471,11 @@ placeholder="default microphone" bind:value={config.stt.audio_target} /> - PipeWire node name, leave empty for the default source. + + {platform === "macos" + ? "AVFoundation audio device index or name; empty uses the system default." + : "PipeWire node name, leave empty for the default source."} + @@ -606,8 +611,10 @@
diff --git a/src/platform.rs b/src/platform.rs index c835539..5e5cc24 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -61,24 +61,16 @@ fn support_for(os: &str) -> PlatformSupport { summary: "Linux release target: PipeWire capture, whisper.cpp, Linux clipboard/paste helpers, tray, floating window, and signed updater artifacts.".into(), blockers: Vec::new(), }, - "macos" => blocked( - "macos", - "macOS release is blocked until the native host path is implemented and signed.", - [ - ( - "Tray/window behavior validation", - "Menu bar, dock, main window, and floating window behavior are not validated.", - ), - ( - "Signing/notarization", - "Developer ID signing and notarization are not configured.", - ), - ( - "Native-host smoke tests", - "Deferred for this PR; required before a real macOS release.", - ), - ], - ), + "macos" => PlatformSupport { + os: "macos".into(), + release_status: ReleaseStatus::Blocked, + dictation_supported: true, + summary: "macOS dictation is supported; release is blocked pending Developer ID signing and notarization.".into(), + blockers: vec![PlatformBlocker { + name: "Signing/notarization".into(), + detail: "Developer ID signing and notarization are not configured.".into(), + }], + }, "windows" => blocked( "windows", "Windows release is blocked until the native host path is implemented and code-signed.", @@ -155,22 +147,18 @@ mod tests { } #[test] - fn macos_release_lists_required_native_work() { + fn macos_supports_dictation_with_signing_as_its_only_release_blocker() { let support = support_for("macos"); - let blocker_names = support - .blockers - .iter() - .map(|blocker| blocker.name.as_str()) - .collect::>(); assert_eq!(support.release_status, ReleaseStatus::Blocked); - assert!(!support.dictation_supported); - assert!(!blocker_names.contains(&"Native audio capture")); - assert!(!blocker_names.contains(&"Paste automation")); - assert!(!blocker_names.contains(&"Global shortcuts")); - assert!(blocker_names.contains(&"Tray/window behavior validation")); - assert!(blocker_names.contains(&"Signing/notarization")); - assert!(blocker_names.contains(&"Native-host smoke tests")); + assert!(support.dictation_supported); + assert_eq!(support.unsupported_dictation_message(), None); + assert_eq!(support.blockers.len(), 1); + assert_eq!(support.blockers[0].name, "Signing/notarization"); + assert_eq!( + support.blockers[0].detail, + "Developer ID signing and notarization are not configured." + ); } #[test]