From b4ac4693eec4f0976126fea19e5ccb3fc0d6bef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Thu, 23 Jul 2026 21:58:44 -0300 Subject: [PATCH] feat(shortcut): add macOS dictation hotkey --- Cargo.lock | 67 +++++++++++++++++++++++++++++ docs/releases/UNRELEASED.md | 9 ++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/lib.rs | 43 ++++++++++++++++-- src-tauri/src/shortcut.rs | 62 ++++++++++++++++++++++++++ src/config.rs | 45 +++++++++++++++++++ src/lib/api.ts | 3 ++ src/lib/settingsDisplay.test.ts | 23 +++++++++- src/lib/settingsDisplay.ts | 9 ++++ src/lib/settingsMerge.test.ts | 20 +++++++++ src/lib/views/Settings.svelte | 43 ++++++++++++------ src/platform.rs | 6 +-- 13 files changed, 312 insertions(+), 22 deletions(-) create mode 100644 src-tauri/src/shortcut.rs diff --git a/Cargo.lock b/Cargo.lock index 0fc1dc5..518f1f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1696,6 +1696,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1827,6 +1837,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -3404,6 +3432,7 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-sentry", @@ -5036,6 +5065,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -6792,6 +6836,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -6802,6 +6863,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index e7cb6be..fdc8534 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -6,6 +6,9 @@ then reset this file. ## User-facing changes +- 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. - Transcribe audio or video files by dropping them onto the app or browsing from the dashboard. PickScribe runs local whisper.cpp transcription with progress and cancellation, stores results in History, and exports TXT, SRT, @@ -127,6 +130,12 @@ then reset this file. machine — both are covered instead by unit tests against the `DeliveryRuntime` seam and the pure script-building/error-mapping functions. +- Issue #66 PR 4 (macOS in-app dictation shortcut): `cargo test` (119 tests), + `cargo test --manifest-path src-tauri/Cargo.toml` (38 tests), `cargo clippy + --workspace --all-targets -- -D warnings`, `bun install --frozen-lockfile`, + `bun run check`, `bunx vitest run` (42 tests), and `bun run build` on macOS. + Live global-shortcut registration was intentionally skipped to avoid changing + the active desktop session. - Issue #59 (macOS compile support and CI): `cargo check --manifest-path src-tauri/Cargo.toml`, `cargo test --manifest-path src-tauri/Cargo.toml` (37 tests), `bun install --frozen-lockfile && bun run check`, `bun run lint`, and diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b95d3f8..cd23a92 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ tauri-plugin-sentry = "0.5" serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" +tauri-plugin-global-shortcut = "2" [target."cfg(target_os = \"linux\")".dependencies] gtk = "0.18" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index af63e21..0464985 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,7 @@ "autostart:allow-is-enabled", "opener:default", "updater:default", - "process:default" + "process:default", + "global-shortcut:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f1efefc..8c1aa60 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod kwin; mod lifecycle; mod macos; mod settings; +mod shortcut; mod tray; use std::fs; @@ -251,7 +252,23 @@ fn get_platform_support() -> PlatformSupport { #[tauri::command] fn update_app_config(app: AppHandle, config: AppConfig) -> CommandResult { - settings::replace(&app, config) + let previous = AppConfig::load(); + let next_shortcut = config.shortcut.toggle.clone(); + shortcut::replace(&app, &previous.shortcut.toggle, &next_shortcut)?; + + match settings::replace(&app, config) { + Ok(config) => Ok(config), + Err(err) => { + if let Err(rollback_err) = + shortcut::replace(&app, &next_shortcut, &previous.shortcut.toggle) + { + return Err(format!( + "{err}; additionally failed to restore the previous global shortcut: {rollback_err}" + )); + } + Err(err) + } + } } #[tauri::command] @@ -865,7 +882,7 @@ pub fn run() { }; let engine = Arc::new(Engine::new().expect("failed to open PickScribe data directory")); - tauri::Builder::default() + let builder = tauri::Builder::default() .manage(engine) .plugin(sentry_plugin) .plugin(tauri_plugin_opener::init()) @@ -884,7 +901,24 @@ pub fn run() { } else { focus_main_window(app); } - })) + })); + let builder = if cfg!(target_os = "macos") { + builder.plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, _shortcut, event| { + if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed { + let engine = engine::engine(app); + engine.set_chord_override(None); + engine.toggle(app); + } + }) + .build(), + ) + } else { + builder + }; + + builder .invoke_handler(tauri::generate_handler![ get_state, toggle_dictation, @@ -912,6 +946,9 @@ pub fn run() { tray::setup(app)?; let cfg = AppConfig::load(); ensure_float_window(app.handle(), cfg.general.float_button); + if let Err(err) = shortcut::register_startup(app.handle(), &cfg.shortcut.toggle) { + eprintln!("{err}"); + } #[cfg(target_os = "linux")] if let Some(window) = app.get_webview_window("main") { fix_csd_titlebar_input(&window); diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs new file mode 100644 index 0000000..baa4ac1 --- /dev/null +++ b/src-tauri/src/shortcut.rs @@ -0,0 +1,62 @@ +use tauri::AppHandle; + +#[cfg(target_os = "macos")] +use tauri_plugin_global_shortcut::GlobalShortcutExt; + +#[cfg(target_os = "macos")] +pub(crate) fn register_startup(app: &AppHandle, shortcut: &str) -> Result<(), String> { + if shortcut.is_empty() { + return Ok(()); + } + app.global_shortcut() + .register(shortcut) + .map_err(|err| format!("failed to register global shortcut {shortcut:?}: {err}")) +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn register_startup(_app: &AppHandle, _shortcut: &str) -> Result<(), String> { + Ok(()) +} + +#[cfg(target_os = "macos")] +pub(crate) fn replace(app: &AppHandle, old: &str, new: &str) -> Result<(), String> { + if old == new { + return Ok(()); + } + + let shortcuts = app.global_shortcut(); + if !new.is_empty() { + shortcuts.register(new).map_err(|err| { + format!("failed to register global shortcut {new:?}; the previous shortcut is still active: {err}") + })?; + } + + if !old.is_empty() + && shortcuts.is_registered(old) + && let Err(err) = shortcuts.unregister(old) + { + if !new.is_empty() { + let _ = shortcuts.unregister(new); + } + return Err(format!( + "failed to replace global shortcut {old:?} with {new:?}; the previous shortcut is still active: {err}" + )); + } + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn replace(_app: &AppHandle, _old: &str, _new: &str) -> Result<(), String> { + Ok(()) +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use tauri_plugin_global_shortcut::Shortcut; + + #[test] + fn default_macos_shortcut_is_a_valid_accelerator() { + assert!("Cmd+Shift+Space".parse::().is_ok()); + } +} diff --git a/src/config.rs b/src/config.rs index 6640d0c..09a1b47 100644 --- a/src/config.rs +++ b/src/config.rs @@ -152,6 +152,24 @@ impl Default for PasteConfig { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ShortcutConfig { + pub toggle: String, +} + +impl Default for ShortcutConfig { + fn default() -> Self { + Self { + toggle: if cfg!(target_os = "macos") { + "Cmd+Shift+Space".into() + } else { + String::new() + }, + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct AppConfig { @@ -160,6 +178,7 @@ pub struct AppConfig { pub incremental: IncrementalConfig, pub cleanup: CleanupConfig, pub paste: PasteConfig, + pub shortcut: ShortcutConfig, } pub fn config_dir() -> PathBuf { @@ -375,6 +394,32 @@ mod tests { ); } + #[test] + fn shortcut_config_uses_platform_default() { + let cfg = AppConfig::default(); + let expected = if cfg!(target_os = "macos") { + "Cmd+Shift+Space" + } else { + "" + }; + + assert_eq!(cfg.shortcut.toggle, expected); + } + + #[test] + fn shortcut_config_deserializes_partial_toml() { + let cfg: AppConfig = toml::from_str( + r#" + [shortcut] + toggle = "Cmd+Option+D" + "#, + ) + .unwrap(); + + assert_eq!(cfg.shortcut.toggle, "Cmd+Option+D"); + assert_eq!(cfg.general.theme, "system"); + } + #[test] fn incremental_config_defaults_to_live_local_segments_only() { let cfg = AppConfig::default(); diff --git a/src/lib/api.ts b/src/lib/api.ts index d8e50be..a7f0798 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -120,6 +120,9 @@ export interface AppConfig { delay_ms: number; copy_to_clipboard: boolean; }; + shortcut: { + toggle: string; + }; } export type ReleaseStatus = "ships_now" | "blocked"; diff --git a/src/lib/settingsDisplay.test.ts b/src/lib/settingsDisplay.test.ts index b57b35b..2caabb2 100644 --- a/src/lib/settingsDisplay.test.ts +++ b/src/lib/settingsDisplay.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { settingsSaveDisplayState } from "./settingsDisplay"; +import { settingsPlatformDisplayState, settingsSaveDisplayState } from "./settingsDisplay"; describe("settingsSaveDisplayState", () => { it("shows a visible, disabled header Save action while clean and no overlay", () => { @@ -27,3 +27,24 @@ describe("settingsSaveDisplayState", () => { } }); }); + +describe("settingsPlatformDisplayState", () => { + it("shows the in-app shortcut only on macOS", () => { + expect(settingsPlatformDisplayState("macos")).toEqual({ + shortcutFieldVisible: true, + desktopKeybindingHelpVisible: false, + }); + expect(settingsPlatformDisplayState("linux")).toEqual({ + shortcutFieldVisible: false, + desktopKeybindingHelpVisible: true, + }); + expect(settingsPlatformDisplayState("windows")).toEqual({ + shortcutFieldVisible: false, + desktopKeybindingHelpVisible: false, + }); + expect(settingsPlatformDisplayState("web")).toEqual({ + shortcutFieldVisible: false, + desktopKeybindingHelpVisible: false, + }); + }); +}); diff --git a/src/lib/settingsDisplay.ts b/src/lib/settingsDisplay.ts index 26949cf..2eb7df1 100644 --- a/src/lib/settingsDisplay.ts +++ b/src/lib/settingsDisplay.ts @@ -1,3 +1,5 @@ +import type { HostPlatform } from "./platform"; + export type SettingsSaveDisplayState = { headerSaveHidden: boolean; headerSaveDisabled: boolean; @@ -16,3 +18,10 @@ export function settingsSaveDisplayState(dirty: boolean): SettingsSaveDisplaySta overlayVisible: dirty, }; } + +export function settingsPlatformDisplayState(platform: HostPlatform) { + return { + shortcutFieldVisible: platform === "macos", + desktopKeybindingHelpVisible: platform === "linux", + }; +} diff --git a/src/lib/settingsMerge.test.ts b/src/lib/settingsMerge.test.ts index 7bf757e..8c0989f 100644 --- a/src/lib/settingsMerge.test.ts +++ b/src/lib/settingsMerge.test.ts @@ -88,6 +88,26 @@ describe("mergeExternalSettings", () => { }); }); + it("preserves a local shortcut edit while merging unrelated external changes", () => { + const shortcutBaseline = { + general: { sounds: true }, + shortcut: { toggle: "Cmd+Shift+Space" }, + }; + const local = { + general: { sounds: true }, + shortcut: { toggle: "Cmd+Option+D" }, + }; + const external = { + general: { sounds: false }, + shortcut: { toggle: "Cmd+Shift+Space" }, + }; + + expect(mergeExternalSettings(shortcutBaseline, local, external)).toEqual({ + general: { sounds: false }, + shortcut: { toggle: "Cmd+Option+D" }, + }); + }); + it("ignores a save response after any newer config event", () => { expect(shouldApplySaveResponse(4, 4)).toBe(true); expect(shouldApplySaveResponse(4, 5)).toBe(false); diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index fd754aa..753f921 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -13,8 +13,9 @@ type AppConfig, type DoctorCheck, } from "../api"; + import { hostPlatform } from "../platform"; import { reconcileExternalSettings, shouldApplySaveResponse } from "../settingsMerge"; - import { settingsSaveDisplayState } from "../settingsDisplay"; + import { settingsPlatformDisplayState, settingsSaveDisplayState } from "../settingsDisplay"; import { setTheme, type ThemeSetting } from "../theme"; let { @@ -48,6 +49,7 @@ config !== null && savedJson !== "" && JSON.stringify($state.snapshot(config)) !== savedJson ); const saveDisplay = $derived(settingsSaveDisplayState(dirty)); + const platformDisplay = settingsPlatformDisplayState(hostPlatform()); $effect(() => { onDirtyChange(dirty); @@ -650,17 +652,34 @@ that scroller instead of the app viewport. See issue #45. --> -
-

Global hotkey

-

- Bind a key in KDE System Settings → Shortcuts → Custom Shortcuts to: -

- pickscribe-app --toggle -

- Press once to start recording, again to stop. A remapped Caps Lock (to F13) makes a great - dedicated dictation key. The CLI pickscribe binary keeps working too. -

-
+ {#if platformDisplay.shortcutFieldVisible} +
+

Global shortcut

+
+ + + Use a shortcut such as Cmd+Shift+Space. Leave empty to turn it off. +
+
+ {:else if platformDisplay.desktopKeybindingHelpVisible} +
+

Global hotkey

+

+ Bind a key in KDE System Settings → Shortcuts → Custom Shortcuts to: +

+ pickscribe-app --toggle +

+ Press once to start recording, again to stop. A remapped Caps Lock (to F13) makes a great + dedicated dictation key. The CLI pickscribe binary keeps working too. +

+
+ {/if} {/if} diff --git a/src/platform.rs b/src/platform.rs index 2e37baa..c835539 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -65,10 +65,6 @@ fn support_for(os: &str) -> PlatformSupport { "macos", "macOS release is blocked until the native host path is implemented and signed.", [ - ( - "Global shortcuts", - "Native shortcut registration and permission flow are missing.", - ), ( "Tray/window behavior validation", "Menu bar, dock, main window, and floating window behavior are not validated.", @@ -171,7 +167,7 @@ mod tests { 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(&"Global shortcuts")); assert!(blocker_names.contains(&"Tray/window behavior validation")); assert!(blocker_names.contains(&"Signing/notarization")); assert!(blocker_names.contains(&"Native-host smoke tests"));