Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions scripts/install-whisper-cpp-local
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down
3 changes: 1 addition & 2 deletions src-tauri/src/file_job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,7 @@ struct FileJobComplete {
}

fn create_temp_dir() -> Result<PathBuf> {
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())
Expand Down
88 changes: 49 additions & 39 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -535,45 +541,49 @@ fn run_doctor_checks() -> Vec<DoctorCheck> {
/// 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(),
}
}

Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
#[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
/// (System Settings -> Privacy & Security -> Accessibility). Required for
/// `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"))]
Expand Down
17 changes: 15 additions & 2 deletions src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 22 additions & 9 deletions src/engine/paste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,9 +502,14 @@ mod tests {
backend: Option<TypeBackend>,
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 {
Expand All @@ -513,9 +518,14 @@ mod tests {
}

fn type_text(&mut self, backend: Option<TypeBackend>, 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 {
Expand Down Expand Up @@ -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());
}
Expand Down
Loading