Merge pull request #1490 from debpalash/fix/wayland-capture-shortcut

fix(dictation): support global shortcuts on Wayland
This commit is contained in:
Palash Debnath
2026-08-11 16:23:16 +00:00
committed by GitHub
49 changed files with 2064 additions and 232 deletions
+1
View File
@@ -55,6 +55,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
- Dictation shortcuts now register and rebind through the desktop portal on Wayland, honor custom keys in focused app views, and show the effective platform keys. (#1490)
- Multi-language dubbing now translates, edits, generates, retains, and exports every selected language, and its language picker stays visible at viewport edges. (#1486)
- Dubbing's **From video** cast now uses available source-audio samples for every speaker and short line, including jobs without a pooled diarization clone. (#1484)
- Basic Dubbing translation remains available without an LLM; Cinematic and Autofit now degrade through the existing Fast translation path instead of blocking the quality choice. (#1481)
+21
View File
@@ -81,6 +81,27 @@ pkg-config --exists \
The first app launch downloads model weights on demand. Subsequent launches
reuse the Rust build, Python environment, and installed models.
## Wayland dictation shortcut
System-wide dictation on Wayland uses the standard
`org.freedesktop.portal.GlobalShortcuts` interface. Your desktop must run
`xdg-desktop-portal` and a portal backend that implements that interface. The
desktop owns the consent dialog and may let you replace VoiceStudio's preferred
key combination.
VoiceStudio binds the replacement before saving a changed shortcut. If consent
is declined or the portal is unavailable, Settings keeps the previous shortcut
and reports the registration failure. The configured shortcut still works while
the VoiceStudio window is focused.
If the global shortcut stops working, restart your desktop's portal service,
then save the shortcut again in **Settings → Hotkey** to reopen consent. Portal
packages and support vary by desktop; use the backend recommended by your
distribution rather than running multiple portal backends in the same session.
See the portal project's [service integration checks](https://flatpak.github.io/xdg-desktop-portal/docs/system-integration.html)
and the Arch Linux [backend compatibility table](https://wiki.archlinux.org/title/XDG_Desktop_Portal#List_of_backends_and_interfaces)
for concrete service and desktop-backend checks.
## Install (AppImage)
Download the latest AppImage from the
@@ -8,7 +8,7 @@ Dictating prompts to AI agents is the fastest-growing text-input workload (Claud
## Current state (verified in-repo)
Widget: pill webview + `tauri-plugin-global-shortcut` (`CmdOrCtrl+Shift+Space`, toggle/hold) + browser-mode keyboard fallback; `getUserMedia` → raw-PCM WS `/ws/transcribe`; paste via arboard+enigo with clipboard restore, macOS a11y fail-loud, Windows no-activate. Backend: 7 sherpa models (Parakeet TDT v3 default), streaming path (zipformer/paraformer) + chunked-offline path (0.8 s partial cadence, **RMS silence gate**), `text_polish` on finals, opt-in LLM refinement (Ollama/LM Studio, ≤4 s wall clock). Gaps: no real VAD, no dictionary/hotwords, no per-app awareness, no command grammar, no language picker, enigo-only Linux insertion, no dictation docs, picker understates model size ~4×.
Widget: pill webview + `tauri-plugin-global-shortcut` (`CmdOrCtrl+Shift+Space`, toggle/hold) on macOS, Windows and X11 + the GlobalShortcuts desktop portal on Wayland + browser-mode keyboard fallback; `getUserMedia` → raw-PCM WS `/ws/transcribe`; paste via arboard+enigo with clipboard restore, macOS a11y fail-loud, Windows no-activate. Backend: 7 sherpa models (Parakeet TDT v3 default), streaming path (zipformer/paraformer) + chunked-offline path (0.8 s partial cadence, **RMS silence gate**), `text_polish` on finals, opt-in LLM refinement (Ollama/LM Studio, ≤4 s wall clock). Gaps: no real VAD, no dictionary/hotwords, no per-app awareness, no command grammar, no language picker, enigo-only Linux insertion, no comprehensive dictation feature guide beyond the Linux installation note, picker understates model size ~4×.
## Program phases
+1
View File
@@ -2973,6 +2973,7 @@ dependencies = [
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
"zbus",
"zip 2.4.2",
]
+4
View File
@@ -91,6 +91,10 @@ libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0"
# Wayland compositors do not expose global keys through X11 grabs. Use the
# standard xdg-desktop-portal GlobalShortcuts interface there; zbus is already
# present transitively through Tauri's opener/single-instance plugins.
zbus = "5.16"
[dev-dependencies]
# Scoped-reset tests build real directory trees to prove the delete guard only
+126 -31
View File
@@ -7,9 +7,11 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::image::Image;
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
use crate::{AppFlags, TrayHandle, DictationShortcutState};
use crate::dictation_shortcut::{DictationShortcutManager, ShortcutInfo, update_tray_hint};
use crate::{AppFlags, TrayHandle};
use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING};
use crate::config::{load_config, save_config};
@@ -875,21 +877,25 @@ pub fn simulate_type(text: Option<String>, backspaces: Option<u32>) -> Result<()
#[tauri::command]
pub fn set_tray_recording(
app: tauri::AppHandle,
recording: bool,
tray_handle: tauri::State<'_, TrayHandle>,
flags: tauri::State<'_, AppFlags>,
shortcuts: tauri::State<'_, DictationShortcutManager>,
) -> Result<(), String> {
// Record the state BEFORE the icon swap: the tray's Start/Stop item reads
// this to decide which event to emit, and it must stay correct even if the
// icon fails to decode. (It used to read `widget.is_visible()`, which the
// permanently-hidden widget made meaningless.)
flags.dictating.store(recording, Ordering::SeqCst);
log::info!("Dictation recording state: {recording}");
let bytes = if recording { TRAY_ICON_RECORDING } else { TRAY_ICON_DEFAULT };
let img = Image::from_bytes(bytes).map_err(|e| format!("decode tray icon: {e}"))?;
let lock = tray_handle.tray.lock().map_err(|_| "tray lock poisoned")?;
if let Some(ref tray) = *lock {
tray.set_icon(Some(img)).map_err(|e| format!("set_icon: {e}"))?;
}
update_tray_hint(&app, &shortcuts.info().display, recording);
Ok(())
}
@@ -908,41 +914,130 @@ pub fn get_dictation_shortcut(app: tauri::AppHandle) -> String {
load_config(&app).dictation_shortcut
}
#[tauri::command]
pub fn get_effective_dictation_shortcut(
state: tauri::State<'_, DictationShortcutManager>,
) -> ShortcutInfo {
state.info()
}
#[tauri::command]
pub fn request_dictation_capture(app: tauri::AppHandle, action: String) -> Result<(), String> {
if action != "start" && action != "stop" && action != "toggle" {
return Err("capture action must be start, stop, or toggle".into());
}
crate::dispatch_dictation_capture(&app, &action);
Ok(())
}
#[tauri::command]
pub fn mark_dictation_capture_ready(app: tauri::AppHandle) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.ready = true;
if let Some(action) = capture.pending.take() {
drop(capture);
crate::dispatch_dictation_capture(&app, &action);
}
}
#[tauri::command]
pub fn set_dictation_shortcut(
app: tauri::AppHandle,
accelerator: String,
state: tauri::State<'_, DictationShortcutState>,
) -> Result<String, String> {
use std::str::FromStr;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
let parsed = Shortcut::from_str(&accelerator)
.map_err(|e| format!("Invalid shortcut '{accelerator}': {e}"))?;
let gs = app.global_shortcut();
let mut slot = state.current.lock().map_err(|_| "shortcut lock poisoned")?;
let prev = slot.take();
if let Some(ref p) = prev {
let _ = gs.unregister(p.clone());
}
if let Err(e) = gs.register(parsed.clone()) {
if let Some(p) = prev {
if gs.register(p.clone()).is_ok() {
*slot = Some(p);
}
}
return Err(format!("Failed to register '{accelerator}': {e}"));
}
*slot = Some(parsed);
drop(slot);
let mut cfg = load_config(&app);
cfg.dictation_shortcut = accelerator.clone();
save_config(&app, &cfg);
state: tauri::State<'_, DictationShortcutManager>,
) -> Result<ShortcutInfo, String> {
let info = state.serialize_update(|| {
let mut cfg = load_config(&app);
let previous = cfg.dictation_shortcut.clone();
let path = crate::config::config_path(&app)
.ok_or_else(|| "Could not locate the VoiceStudio config directory".to_string())?;
apply_shortcut_change(
&accelerator,
&previous,
|value| state.replace(&app, value),
|| {
cfg.dictation_shortcut = accelerator.clone();
crate::config::save_config_at(&path, &cfg)
},
)
})?;
log::info!("Dictation shortcut updated to {accelerator}");
Ok(accelerator)
Ok(info)
}
fn apply_shortcut_change<T, A, P>(
replacement: &str,
previous: &str,
mut activate: A,
persist: P,
) -> Result<T, String>
where
A: FnMut(&str) -> Result<T, String>,
P: FnOnce() -> Result<(), String>,
{
let active = activate(replacement)?;
if let Err(error) = persist() {
let rollback = activate(previous);
return Err(match rollback {
Ok(_) => format!("Could not save the shortcut: {error}"),
Err(rollback_error) => format!(
"Could not save the shortcut ({error}); restoring the previous shortcut also failed: {rollback_error}"
),
});
}
Ok(active)
}
#[cfg(test)]
mod shortcut_change_tests {
use super::apply_shortcut_change;
use std::cell::RefCell;
#[test]
fn activates_the_replacement_before_persisting_it() {
let events = RefCell::new(Vec::new());
let result = apply_shortcut_change(
"Ctrl+Alt+K",
"Ctrl+Shift+Space",
|value| {
events.borrow_mut().push(format!("activate:{value}"));
Ok(value.to_owned())
},
|| {
events.borrow_mut().push("persist".into());
Ok(())
},
)
.unwrap();
assert_eq!(result, "Ctrl+Alt+K");
assert_eq!(events.into_inner(), ["activate:Ctrl+Alt+K", "persist"]);
}
#[test]
fn restores_the_previous_binding_when_persistence_fails() {
let events = RefCell::new(Vec::new());
let error = apply_shortcut_change(
"Ctrl+Alt+K",
"Ctrl+Shift+Space",
|value| {
events.borrow_mut().push(format!("activate:{value}"));
Ok(())
},
|| Err("disk full".into()),
)
.unwrap_err();
assert!(error.contains("disk full"));
assert_eq!(
events.into_inner(),
["activate:Ctrl+Alt+K", "activate:Ctrl+Shift+Space"]
);
}
}
// ── Launch-mode persistence ───────────────────────────────────────────────
@@ -0,0 +1,303 @@
//! One registration seam for the native global-shortcut plugin and the
//! Wayland GlobalShortcuts portal.
use std::str::FromStr;
use std::sync::atomic::Ordering;
use std::sync::Mutex;
use serde::Serialize;
use tauri::{Emitter, Manager};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
use crate::TrayHandle;
#[derive(Clone, Debug, Serialize)]
pub struct ShortcutInfo {
pub accelerator: String,
pub display: String,
pub backend: &'static str,
}
pub struct DictationShortcutManager {
updates: Mutex<()>,
native: Mutex<Option<Shortcut>>,
effective: Mutex<ShortcutInfo>,
#[cfg(target_os = "linux")]
portal: crate::wayland_shortcut::PortalShortcutState,
}
impl DictationShortcutManager {
pub fn new(accelerator: &str) -> Self {
Self {
updates: Mutex::new(()),
native: Mutex::new(None),
effective: Mutex::new(ShortcutInfo {
accelerator: accelerator.to_owned(),
display: display_accelerator(accelerator),
backend: backend_name(),
}),
#[cfg(target_os = "linux")]
portal: crate::wayland_shortcut::PortalShortcutState::default(),
}
}
pub fn register_initial(app: tauri::AppHandle, accelerator: String) {
let accelerator = match Shortcut::from_str(&accelerator) {
Ok(_) => accelerator,
Err(error) => {
let fallback = crate::config::default_dictation_shortcut();
log::warn!(
"Saved shortcut '{accelerator}' is invalid ({error}); using '{fallback}'"
);
fallback
}
};
#[cfg(target_os = "linux")]
if crate::wayland_shortcut::is_wayland_session() {
let revision = app.state::<Self>().portal.reserve();
crate::wayland_shortcut::register_initial(app, accelerator, revision);
return;
}
let manager = app.state::<Self>();
match manager.replace_native(&app, &accelerator) {
Ok(()) => {
manager.publish(&app, accelerator, None, "native");
}
Err(error) => log::warn!("Failed to register global shortcut: {error}"),
}
}
pub fn replace(
&self,
app: &tauri::AppHandle,
accelerator: &str,
) -> Result<ShortcutInfo, String> {
Shortcut::from_str(accelerator)
.map_err(|error| format!("Invalid shortcut '{accelerator}': {error}"))?;
#[cfg(target_os = "linux")]
if crate::wayland_shortcut::is_wayland_session() {
let display = self.portal.replace(app.clone(), accelerator.to_owned())?;
return Ok(self.publish(app, accelerator.to_owned(), Some(display), "portal"));
}
self.replace_native(app, accelerator)?;
Ok(self.publish(app, accelerator.to_owned(), None, "native"))
}
pub fn info(&self) -> ShortcutInfo {
self.effective
.lock()
.map(|info| info.clone())
.unwrap_or_else(|_| ShortcutInfo {
accelerator: String::new(),
display: String::new(),
backend: backend_name(),
})
}
pub fn serialize_update<T>(
&self,
update: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
let _guard = self
.updates
.lock()
.map_err(|_| "shortcut update lock poisoned".to_string())?;
update()
}
#[cfg(target_os = "linux")]
pub(crate) fn register_portal_initial(
&self,
app: &tauri::AppHandle,
accelerator: String,
revision: u64,
) -> Result<(), String> {
Shortcut::from_str(&accelerator)
.map_err(|error| format!("Invalid shortcut '{accelerator}': {error}"))?;
let display = self
.portal
.replace_reserved(app.clone(), accelerator.clone(), revision)?;
self.publish(app, accelerator, Some(display), "portal");
Ok(())
}
fn replace_native(&self, app: &tauri::AppHandle, accelerator: &str) -> Result<(), String> {
let parsed = Shortcut::from_str(accelerator)
.map_err(|error| format!("Invalid shortcut '{accelerator}': {error}"))?;
let global = app.global_shortcut();
let mut slot = self
.native
.lock()
.map_err(|_| "shortcut lock poisoned".to_string())?;
if let Some(shortcut) = slot.as_ref() {
global.unregister(shortcut.clone()).map_err(|error| {
format!("Failed to unregister the previous shortcut: {error}")
})?;
}
let previous = slot.take();
if let Err(error) = global.register(parsed.clone()) {
if let Some(shortcut) = previous {
if global.register(shortcut.clone()).is_ok() {
*slot = Some(shortcut);
}
}
return Err(format!("Failed to register '{accelerator}': {error}"));
}
*slot = Some(parsed);
Ok(())
}
fn publish(
&self,
app: &tauri::AppHandle,
accelerator: String,
display: Option<String>,
backend: &'static str,
) -> ShortcutInfo {
let info = ShortcutInfo {
display: display.unwrap_or_else(|| display_accelerator(&accelerator)),
accelerator,
backend,
};
if let Ok(mut current) = self.effective.lock() {
*current = info.clone();
}
let recording = app
.try_state::<crate::AppFlags>()
.is_some_and(|flags| flags.dictating.load(Ordering::SeqCst));
update_tray_hint(app, &info.display, recording);
let _ = app.emit("dictation-shortcut-changed", &info);
log::info!(
"Dictation shortcut '{}' active through {}",
info.accelerator,
info.backend
);
info
}
}
pub fn update_tray_hint(app: &tauri::AppHandle, display: &str, recording: bool) {
let verb = if recording { "Stop" } else { "Start" };
if let Ok(slot) = app.state::<TrayHandle>().dictate.lock() {
if let Some(item) = slot.as_ref() {
if let Err(error) = item.set_text(format!("{verb} Dictation {display}")) {
log::warn!("Could not update the dictation tray hint: {error}");
}
}
}
}
pub fn display_accelerator(accelerator: &str) -> String {
#[cfg(target_os = "macos")]
{
return accelerator
.split('+')
.map(|part| match part.to_ascii_lowercase().as_str() {
"cmdorctrl" | "commandorcontrol" | "cmd" | "command" | "meta" | "super" => {
"".to_owned()
}
"ctrl" | "control" => "".to_owned(),
"alt" | "option" => "".to_owned(),
"shift" => "".to_owned(),
_ => part.to_owned(),
})
.collect::<String>();
}
#[cfg(not(target_os = "macos"))]
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::<Vec<_>>()
.join("+")
}
fn backend_name() -> &'static str {
// The focused-window bridge is available before the OS registration
// completes and remains the truthful fallback if that registration fails.
"focused"
}
#[cfg(test)]
mod tests {
use super::{display_accelerator, DictationShortcutManager};
use std::sync::{mpsc, Arc, Barrier, Mutex};
use std::time::Duration;
#[test]
fn formats_the_platform_shortcut_hint() {
#[cfg(target_os = "macos")]
assert_eq!(display_accelerator("CmdOrCtrl+Shift+Space"), "⌘⇧Space");
#[cfg(not(target_os = "macos"))]
assert_eq!(
display_accelerator("CmdOrCtrl+Shift+Space"),
"Ctrl+Shift+Space"
);
#[cfg(not(target_os = "macos"))]
assert_eq!(display_accelerator("Cmd+Option+K"), "Super+Alt+K");
}
#[test]
fn serializes_overlapping_update_and_rollback_flows() {
let manager = Arc::new(DictationShortcutManager::new("Ctrl+Shift+Space"));
let state = Arc::new(Mutex::new((
"Ctrl+Shift+Space".to_string(),
"Ctrl+Shift+Space".to_string(),
)));
let (first_entered_tx, first_entered_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let (second_entered_tx, second_entered_rx) = mpsc::channel();
let second_ready = Arc::new(Barrier::new(2));
let first_manager = Arc::clone(&manager);
let first_state = Arc::clone(&state);
let first = std::thread::spawn(move || {
first_manager
.serialize_update(|| {
first_state.lock().unwrap().1 = "Ctrl+Alt+A".into();
first_entered_tx.send(()).unwrap();
release_first_rx.recv().unwrap();
// Simulate a failed persistence and its runtime rollback.
first_state.lock().unwrap().1 = "Ctrl+Shift+Space".into();
Err::<(), _>("disk full".to_string())
})
.unwrap_err()
});
first_entered_rx.recv().unwrap();
let second_manager = Arc::clone(&manager);
let second_state = Arc::clone(&state);
let second_barrier = Arc::clone(&second_ready);
let second = std::thread::spawn(move || {
second_barrier.wait();
second_manager
.serialize_update(|| {
second_entered_tx.send(()).unwrap();
let mut state = second_state.lock().unwrap();
state.1 = "Ctrl+Alt+B".into();
state.0 = "Ctrl+Alt+B".into();
Ok(())
})
.unwrap();
});
second_ready.wait();
assert!(second_entered_rx
.recv_timeout(Duration::from_millis(50))
.is_err());
release_first_tx.send(()).unwrap();
assert_eq!(first.join().unwrap(), "disk full");
second.join().unwrap();
let state = state.lock().unwrap();
assert_eq!(state.0, "Ctrl+Alt+B");
assert_eq!(state.1, "Ctrl+Alt+B");
}
}
+80 -42
View File
@@ -13,11 +13,14 @@ pub mod bootstrap;
pub mod tools;
pub mod backend;
pub mod commands;
pub mod dictation_shortcut;
pub mod crash;
pub mod reset;
pub mod uninstall;
pub mod updater_channel;
pub mod blank_guard;
#[cfg(target_os = "linux")]
pub mod wayland_shortcut;
use std::process::Child;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -30,7 +33,8 @@ use tauri::tray::TrayIconBuilder;
use tauri_plugin_positioner::{Position, WindowExt};
use crate::bootstrap::{BootstrapStage, BootstrapState, set_stage};
use crate::config::{default_dictation_shortcut, load_config};
use crate::config::load_config;
use crate::dictation_shortcut::DictationShortcutManager;
// ── Port ──────────────────────────────────────────────────────────────────
@@ -58,14 +62,53 @@ pub struct AppFlags {
/// already reports every start and stop via `set_tray_recording` (it drives
/// the tray icon), so that same call keeps this in step.
pub dictating: AtomicBool,
pub capture: Mutex<CaptureDispatchState>,
}
pub struct CaptureDispatchState {
pub ready: bool,
pub pending: Option<String>,
}
pub struct TrayHandle {
pub tray: Mutex<Option<tauri::tray::TrayIcon>>,
pub dictate: Mutex<Option<tauri::menu::MenuItem<tauri::Wry>>>,
}
pub struct DictationShortcutState {
pub current: Mutex<Option<tauri_plugin_global_shortcut::Shortcut>>,
fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
match action {
"stop" => "tray-dictate-stop",
"toggle" if dictating => "tray-dictate-stop",
_ => "tray-dictate",
}
}
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
let flags = app.state::<AppFlags>();
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
if capture.ready {
let _ = app.emit(event, ());
} else {
capture.pending = Some(action.to_owned());
}
}
#[cfg(test)]
mod dictation_capture_tests {
use super::dictation_capture_event;
#[test]
fn toggle_starts_when_idle_and_stops_when_recording() {
assert_eq!(dictation_capture_event("toggle", false), "tray-dictate");
assert_eq!(
dictation_capture_event("toggle", true),
"tray-dictate-stop"
);
}
}
pub const TRAY_ICON_DEFAULT: &[u8] = include_bytes!("../icons/32x32.png");
@@ -437,7 +480,10 @@ pub fn run() {
commands::save_text_file,
commands::reveal_host_path,
commands::get_dictation_shortcut,
commands::get_effective_dictation_shortcut,
commands::set_dictation_shortcut,
commands::request_dictation_capture,
commands::mark_dictation_capture_ready,
commands::get_launch_as_widget,
commands::set_launch_as_widget,
commands::clear_webview_cache_and_relaunch,
@@ -537,20 +583,21 @@ pub fn run() {
app.manage(AppFlags {
quitting: AtomicBool::new(false),
dictating: AtomicBool::new(false),
capture: Mutex::new(CaptureDispatchState {
ready: false,
pending: None,
}),
});
app.manage(TrayHandle {
tray: Mutex::new(None),
dictate: Mutex::new(None),
});
app.manage(DictationShortcutState {
current: Mutex::new(None),
});
let startup_shortcut = load_config(app.handle()).dictation_shortcut;
app.manage(DictationShortcutManager::new(&startup_shortcut));
// ── Global dictation shortcut (hold-to-talk) ─────────────────
{
use std::str::FromStr;
use tauri_plugin_global_shortcut::{
GlobalShortcutExt, Shortcut, ShortcutState,
};
use tauri_plugin_global_shortcut::ShortcutState;
app.handle().plugin(
tauri_plugin_global_shortcut::Builder::new()
@@ -563,48 +610,23 @@ pub fn run() {
// host for the recorder, and dictation gives
// no on-screen pill. See the window builder
// above for why it must still exist.
let _ = app_handle.emit("tray-dictate", ());
dispatch_dictation_capture(app_handle, "start");
}
ShortcutState::Released => {
log::info!("Global shortcut released: dictation stop");
let _ = app_handle.emit("tray-dictate-stop", ());
dispatch_dictation_capture(app_handle, "stop");
}
}
})
.build(),
)?;
let cfg = load_config(app.handle());
let accel = cfg.dictation_shortcut.clone();
let parsed = Shortcut::from_str(&accel)
.or_else(|_| {
log::warn!(
"Saved shortcut '{accel}' unparseable — falling back to default"
);
Shortcut::from_str(&default_dictation_shortcut())
});
match parsed {
Ok(shortcut) => match app.global_shortcut().register(shortcut.clone()) {
Ok(()) => {
log::info!("Global shortcut '{accel}' registered");
if let Ok(mut slot) = app
.state::<DictationShortcutState>()
.current
.lock()
{
*slot = Some(shortcut);
}
}
Err(e) => log::warn!("Failed to register global shortcut: {e}"),
},
Err(e) => log::warn!("No usable dictation shortcut: {e}"),
}
}
// ── System tray ──────────────────────────────────────────────
let tray_menu = if pill_mode_tray {
// Pill mode: minimal tray with Open Studio + Dictate + Quit
let dictate_i = MenuItemBuilder::new("Start Dictation ⌘⇧Space")
let shortcut_hint = app.state::<DictationShortcutManager>().info().display;
let dictate_i = MenuItemBuilder::new(format!("Start Dictation {shortcut_hint}"))
.id("dictate")
.build(app)?;
let open_studio_i = MenuItemBuilder::new("Open VoiceStudio")
@@ -625,7 +647,8 @@ pub fn run() {
let show_i = MenuItemBuilder::new("Show VoiceStudio")
.id("show")
.build(app)?;
let dictate_i = MenuItemBuilder::new("Start Dictation ⌘⇧Space")
let shortcut_hint = app.state::<DictationShortcutManager>().info().display;
let dictate_i = MenuItemBuilder::new(format!("Start Dictation {shortcut_hint}"))
.id("dictate")
.build(app)?;
let switch_to_pill_i = MenuItemBuilder::new("Switch to Dictation Widget")
@@ -648,6 +671,21 @@ pub fn run() {
.build()?
};
if let Some(item) = tray_menu.get("dictate") {
if let Some(item) = item.as_menuitem() {
if let Ok(mut slot) = app.state::<TrayHandle>().dictate.lock() {
*slot = Some(item.clone());
}
}
}
// Publish the effective shortcut only after the tray item exists;
// Wayland portal registration completes asynchronously and may
// otherwise race past the first tray-label update.
DictationShortcutManager::register_initial(
app.handle().clone(),
startup_shortcut.clone(),
);
let tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
@@ -713,9 +751,9 @@ pub fn run() {
// current by the frontend's existing
// `set_tray_recording` call on every start and stop.
if app.state::<AppFlags>().dictating.load(Ordering::SeqCst) {
let _ = app.emit("tray-dictate-stop", ());
dispatch_dictation_capture(app, "stop");
} else {
let _ = app.emit("tray-dictate", ());
dispatch_dictation_capture(app, "start");
}
}
"settings" => {
+659
View File
@@ -0,0 +1,659 @@
//! 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 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<String, OwnedValue>;
#[derive(Clone)]
struct PortalRegistration {
connection: Connection,
session: OwnedObjectPath,
}
#[derive(Default)]
pub struct PortalShortcutState {
active: Mutex<Option<PortalRegistration>>,
revision: AtomicU64,
}
impl PortalShortcutState {
pub fn replace(&self, app: tauri::AppHandle, accelerator: String) -> Result<String, String> {
let revision = self.reserve();
self.replace_reserved(app, 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,
app: tauri::AppHandle,
accelerator: String,
revision: u64,
) -> Result<String, String> {
// 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(&registration);
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(&registration);
return Err("portal shortcut lock poisoned".into());
}
};
if !self.is_current(revision) {
drop(active);
let _ = close_session(&registration);
return Err("shortcut registration was superseded by a newer request".into());
}
if let Err(error) = start_listener(app, registration.clone()) {
let _ = close_session(&registration);
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 desktop_entry_exists() -> bool {
let filename = format!("{DESKTOP_ID}.desktop");
let user_entry = dirs_next::data_dir()
.map(|dir| dir.join("applications").join(&filename))
.is_some_and(|path| path.is_file());
if user_entry {
return true;
}
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()
})
})
}
fn desktop_exec_path() -> Result<std::path::PathBuf, String> {
// 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 desktop_entry_exists() {
return Ok(());
}
let applications = dirs_next::data_dir()
.ok_or("could not locate the user data directory")?
.join("applications");
std::fs::create_dir_all(&applications)
.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()?)
);
let path = applications.join(format!("{DESKTOP_ID}.desktop"));
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<String> {
let mut modifiers: Vec<String> = 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<String> {
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::<u8>()
.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<String> {
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<OwnedObjectPath, String> {
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<F>(connection: &Connection, token: &str, call: F) -> Result<VariantMap, String>
where
F: FnOnce() -> Result<OwnedObjectPath, zbus::Error>,
{
// 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<T>(
receiver: &mpsc::Receiver<Result<T, String>>,
timeout: Duration,
operation: &str,
) -> Result<T, String> {
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),
)
})?;
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(
&registration.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}"))
}
fn listen(app: tauri::AppHandle, registration: PortalRegistration) -> Result<(), String> {
let portal = Proxy::new(
&registration.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, _timestamp, _options): (
OwnedObjectPath,
String,
u32,
VariantMap,
) = match message.body().deserialize() {
Ok(body) => body,
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) {
let worker_app = app.clone();
if let Err(error) = std::thread::Builder::new()
.name("wayland-global-shortcut-setup".into())
.spawn(move || {
let manager = worker_app.state::<crate::dictation_shortcut::DictationShortcutManager>();
if let Err(error) = manager.register_portal_initial(&worker_app, accelerator, revision)
{
log::error!("Wayland dictation shortcut unavailable: {error}");
}
})
{
log::error!("Failed to start Wayland shortcut setup: {error}");
}
}
#[cfg(test)]
mod tests {
use super::{
desktop_exec_value, portal_trigger, receive_with_timeout, trigger_description,
variant_string, PortalShortcutState,
};
use std::collections::HashMap;
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
#[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 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));
}
#[test]
fn portal_response_wait_is_bounded() {
let (_sender, receiver) = mpsc::channel::<Result<(), String>>();
let error = receive_with_timeout(
&receiver,
Duration::from_millis(1),
"portal shortcut request",
)
.unwrap_err();
assert!(error.contains("timed out"));
}
}
+73 -20
View File
@@ -14,6 +14,7 @@ import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissin
import { createWaveform } from './captureWaveform';
import { emitDictationNotice } from '../utils/dictationNotice';
import { audioFormatForMimeType, startSupportedMediaRecorder } from '../utils/mediaRecorder';
import { BROWSER_DICTATION_REQUEST } from '../utils/dictationCapture';
// True inside the Tauri shell (desktop app / widget window); false in the
// browser webui / Docker, where the native commands don't exist. Gating on
@@ -333,6 +334,14 @@ export default function CaptureWidget({ onDismiss }) {
// identity, but the listener must not re-subscribe to follow them.
const startRecordingRef = useRef(null);
const stopRecordingRef = useRef(null);
// Hold mode can be released while microphone permission or getUserMedia is
// still pending. Preserve that release so the completed start cannot leave
// an orphaned recording behind.
const holdStartRef = useRef(null);
// Linux can deliver the same shortcut through both the native global-hotkey
// plugin and the focused main-window fallback. Collapse that pair into one
// logical action without slowing intentional toggle-mode presses.
const nativeEventAtRef = useRef({ start: 0, stop: 0 });
// Sherpa live-streaming session refs. `sherpaModeRef` flips on at start when a
// sherpa model is selected; `committedRef` accumulates per-utterance finals so
@@ -407,6 +416,41 @@ export default function CaptureWidget({ onDismiss }) {
pcmModeRef.current = false;
}, []);
// Browser buttons and focused-window shortcuts use the same request event;
// the recorder remains owned by this single component.
const browserRequestRef = useRef(null);
browserRequestRef.current = (action) => {
if (!enabledRef.current) return;
const current = stateRef.current;
if (action === 'stop') {
if (current === 'recording') stopRecordingRef.current?.();
else if (holdStartRef.current === 'starting') holdStartRef.current = 'released';
return;
}
if (current === 'setup') {
if (modeRef.current === 'hold') holdStartRef.current = 'starting';
checkAccessibility().then((ok) => {
if (ok) startRecordingRef.current?.(modeRef.current === 'hold');
else holdStartRef.current = null;
});
return;
}
const idle = current === 'idle' || current === 'done' || current === 'error';
if (action === 'toggle') {
if (idle) startRecordingRef.current?.();
else if (current === 'recording') stopRecordingRef.current?.();
} else if (idle) {
startRecordingRef.current?.(modeRef.current === 'hold');
}
};
useEffect(() => {
if (inTauri()) return;
const onRequest = (event) => browserRequestRef.current?.(event.detail?.action || 'start');
window.addEventListener(BROWSER_DICTATION_REQUEST, onRequest);
return () => window.removeEventListener(BROWSER_DICTATION_REQUEST, onRequest);
}, []);
// Hydrate dictation prefs (enabled / mode / model) from the backend once. The
// widget runs in its own Tauri webview (a separate JS context from the main
// window), so it loads the prefs itself rather than relying on the Settings
@@ -448,6 +492,9 @@ export default function CaptureWidget({ onDismiss }) {
try {
const { listen } = await import('@tauri-apps/api/event');
unlistenStart = await listen('tray-dictate', () => {
const now = Date.now();
if (now - nativeEventAtRef.current.start < 150) return;
nativeEventAtRef.current.start = now;
if (!enabledRef.current) {
// The hotkey is inert, but Rust has already shown the window.
// Put it back rather than leaving an empty capsule on screen.
@@ -458,8 +505,10 @@ export default function CaptureWidget({ onDismiss }) {
if (s === 'setup') {
// Re-probe on each press the user may have just granted access
// in System Settings; if so, flow straight into recording.
if (modeRef.current === 'hold') holdStartRef.current = 'starting';
checkAccessibility().then((ok) => {
if (ok) startRecordingRef.current?.();
if (ok) startRecordingRef.current?.(modeRef.current === 'hold');
else holdStartRef.current = null;
});
return;
}
@@ -470,15 +519,22 @@ export default function CaptureWidget({ onDismiss }) {
else if (s === 'recording') stopRecordingRef.current?.();
} else if (idle) {
// Hold mode: keydown start.
startRecordingRef.current?.();
startRecordingRef.current?.(true);
}
});
unlistenStop = await listen('tray-dictate-stop', () => {
const now = Date.now();
if (now - nativeEventAtRef.current.stop < 150) return;
nativeEventAtRef.current.stop = now;
// Only hold mode acts on release; toggle ignores it.
if (modeRef.current === 'hold' && stateRef.current === 'recording') {
stopRecordingRef.current?.();
} else if (modeRef.current === 'hold' && holdStartRef.current === 'starting') {
holdStartRef.current = 'released';
}
});
const { invoke } = await import('@tauri-apps/api/core');
await invoke('mark_dictation_capture_ready');
// Unmounted while the dynamic import was in flight drop the
// subscriptions we just created rather than leaking them.
if (cancelled) {
@@ -511,29 +567,14 @@ export default function CaptureWidget({ onDismiss }) {
const onKeyDown = (e) => {
if (!isCombo(e)) return;
e.preventDefault();
if (!enabledRef.current) return;
if (state === 'setup') {
checkAccessibility().then((ok) => {
if (ok) startRecording();
});
return;
}
const idle = state === 'idle' || state === 'done' || state === 'error';
if (modeRef.current === 'toggle') {
if (idle) startRecording();
else if (state === 'recording') stopRecording();
} else if (idle) {
// Hold mode: holding the combo records; auto-repeat keydowns are
// ignored because we only start from an idle state.
startRecording();
}
browserRequestRef.current?.(modeRef.current === 'toggle' ? 'toggle' : 'start');
};
const onKeyUp = (e) => {
// Hold mode stops as soon as Space (or a modifier) is released.
if (modeRef.current !== 'hold') return;
if (e.code !== 'Space' && e.key !== 'Meta' && e.key !== 'Control' && e.key !== 'Shift')
return;
if (state === 'recording') stopRecording();
browserRequestRef.current?.('stop');
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
@@ -770,6 +811,7 @@ export default function CaptureWidget({ onDismiss }) {
// raises the OS prompt; micError.js stays the reactive fallback), and
// outside Tauri checkMicrophone() is always 'unknown' unchanged.
if ((await checkMicrophone()) === 'denied') {
holdStartRef.current = null;
showMicDeniedGuide(t);
setTrayRecording(false);
setErrorInfo({
@@ -1156,7 +1198,15 @@ export default function CaptureWidget({ onDismiss }) {
setErrorInfo(null);
setDoneKind(null);
setDuration(0);
stateRef.current = 'recording';
if (holdStartRef.current === 'released') {
holdStartRef.current = null;
stopRecordingRef.current?.();
} else {
holdStartRef.current = null;
}
} catch (err) {
holdStartRef.current = null;
// Same guard as the success path above (#1175 review): the session may
// already have RESOLVED while setup was failing a connect-time WS
// error frame (e.g. the typed asr_model_missing preflight) or an
@@ -1261,7 +1311,10 @@ export default function CaptureWidget({ onDismiss }) {
// must run after every render so the once-attached tray listener above never
// calls into a stale closure.
useEffect(() => {
startRecordingRef.current = startRecording;
startRecordingRef.current = (trackHold = false) => {
if (trackHold && holdStartRef.current !== 'released') holdStartRef.current = 'starting';
return startRecording();
};
stopRecordingRef.current = stopRecording;
});
@@ -132,6 +132,7 @@ describe('CaptureWidget', () => {
mocks.holder.paste = async () => undefined;
mocks.holder.calls = [];
mocks.holder.onFrame = null;
mocks.state.dictationMode = 'toggle';
mocks.state.dictationModelId = 'sherpa-parakeet-tdt-v3';
FakeWebSocket.instances = [];
global.WebSocket = FakeWebSocket;
@@ -152,6 +153,32 @@ describe('CaptureWidget', () => {
const pasteCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_paste');
const typeCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_type');
it('honors a hold-mode release while microphone startup is pending', async () => {
mocks.state.dictationMode = 'hold';
let resolveMicrophone;
const getUserMedia = vi.fn(
() =>
new Promise((resolve) => {
resolveMicrophone = resolve;
}),
);
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia },
});
render(withI18n(<CaptureWidget />));
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
await waitFor(() => expect(getUserMedia).toHaveBeenCalledOnce());
fireEvent.keyUp(window, { code: 'Space' });
await act(async () => {
resolveMicrophone({ getTracks: () => [{ stop() {} }] });
});
expect(await screen.findByText(/Transcribing/)).toBeInTheDocument();
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
});
it('falls back to raw PCM when MediaRecorder cannot be constructed', async () => {
mocks.state.dictationModelId = null;
delete global.MediaRecorder;
@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
DEFAULT_SHORTCUT,
eventMatchesShortcut,
isShortcutRelease,
parseShortcut,
} from '../utils/dictationShortcut';
import { requestDictationCapture } from '../utils/dictationCapture';
/** Focused-window fallback for desktop environments whose global shortcut
* backend reports registration success but never delivers press events. */
export default function DesktopCaptureShortcutBridge() {
const acceleratorRef = useRef(DEFAULT_SHORTCUT);
const armedRef = useRef(null);
useEffect(() => {
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return;
let cancelled = false;
let unlisten;
const forward = (name) => {
try {
Promise.resolve(
requestDictationCapture(name === 'tray-dictate-stop' ? 'stop' : 'start'),
).catch((error) => console.warn(`${name} fallback emit failed:`, error));
} catch (error) {
console.warn(`${name} fallback emit failed:`, error);
}
};
const onKeyDown = (event) => {
if (event.repeat || !eventMatchesShortcut(event, acceleratorRef.current)) return;
event.preventDefault();
armedRef.current = parseShortcut(acceleratorRef.current);
forward('tray-dictate');
};
const onKeyUp = (event) => {
if (!armedRef.current || !isShortcutRelease(event, armedRef.current)) return;
event.preventDefault();
armedRef.current = null;
forward('tray-dictate-stop');
};
const onBlur = () => {
if (!armedRef.current) return;
armedRef.current = null;
forward('tray-dictate-stop');
};
(async () => {
try {
const subscription = await listen('dictation-shortcut-changed', ({ payload }) => {
if (payload?.accelerator) acceleratorRef.current = payload.accelerator;
});
if (cancelled) {
subscription();
return;
}
unlisten = subscription;
const current = await invoke('get_effective_dictation_shortcut');
if (!cancelled && current?.accelerator) acceleratorRef.current = current.accelerator;
if (cancelled) unlisten?.();
} catch (error) {
console.warn('dictation shortcut fallback setup failed:', error);
}
})();
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
return () => {
cancelled = true;
unlisten?.();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
};
}, []);
return null;
}
@@ -0,0 +1,84 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DesktopCaptureShortcutBridge from './DesktopCaptureShortcutBridge';
const { invoke, listen, handlers } = vi.hoisted(() => ({
invoke: vi.fn(),
listen: vi.fn(),
handlers: {},
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke }));
vi.mock('@tauri-apps/api/event', () => ({ listen }));
describe('DesktopCaptureShortcutBridge', () => {
beforeEach(() => {
window.__TAURI_INTERNALS__ = {};
invoke.mockReset().mockResolvedValue({
accelerator: 'Ctrl+Alt+K',
display: 'Ctrl+Alt+K',
backend: 'native',
});
listen.mockReset().mockImplementation(async (name, handler) => {
handlers[name] = handler;
return vi.fn();
});
});
afterEach(() => {
delete window.__TAURI_INTERNALS__;
});
it('forwards the configured shortcut and disarms when a modifier is released first', async () => {
render(<DesktopCaptureShortcutBridge />);
await waitFor(() => expect(invoke).toHaveBeenCalledWith('get_effective_dictation_shortcut'));
fireEvent.keyDown(window, { code: 'KeyK', ctrlKey: true, altKey: true });
fireEvent.keyUp(window, { code: 'ControlLeft', altKey: true });
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('request_dictation_capture', { action: 'start' });
expect(invoke).toHaveBeenCalledWith('request_dictation_capture', { action: 'stop' });
});
});
it('ignores auto-repeat and unrelated shortcuts', async () => {
render(<DesktopCaptureShortcutBridge />);
await waitFor(() => expect(invoke).toHaveBeenCalled());
fireEvent.keyDown(window, {
code: 'KeyK',
ctrlKey: true,
altKey: true,
repeat: true,
});
fireEvent.keyDown(window, { code: 'KeyK', ctrlKey: true });
await Promise.resolve();
expect(invoke).toHaveBeenCalledTimes(1);
});
it('uses a successfully rebound shortcut without remounting', async () => {
render(<DesktopCaptureShortcutBridge />);
await waitFor(() => expect(handlers['dictation-shortcut-changed']).toBeTypeOf('function'));
handlers['dictation-shortcut-changed']({
payload: { accelerator: 'Ctrl+Shift+Space', display: 'Ctrl+Shift+Space' },
});
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('request_dictation_capture', { action: 'start' }),
);
});
it('removes a subscription that resolves after unmount', async () => {
let resolveListen;
const unlisten = vi.fn();
listen.mockReturnValueOnce(
new Promise((resolve) => {
resolveListen = resolve;
}),
);
const view = render(<DesktopCaptureShortcutBridge />);
view.unmount();
resolveListen(unlisten);
await waitFor(() => expect(unlisten).toHaveBeenCalledOnce());
expect(invoke).not.toHaveBeenCalled();
});
});
+10 -22
View File
@@ -2,7 +2,7 @@
* DictationDemo guided walkthrough for the real-time dictation feature.
*
* What this surfaces:
* 1. Active hotkey display (read from the dictation_shortcut Tauri command).
* 1. Active hotkey display (including a portal-selected Wayland shortcut).
* 2. Three script cards short utterances the user can read aloud OR
* replay from a bundled WAV. The replay path posts the bundled audio
* to POST /transcribe and renders the recognized text below the card
@@ -25,6 +25,7 @@ import { Play, Pause, Keyboard, Mic, CheckCircle2, AlertTriangle } from 'lucide-
import { useTranslation } from 'react-i18next';
import { API, apiFetch } from '../api/client';
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
import { useEffectiveDictationShortcut } from '../hooks/useEffectiveDictationShortcut';
import { Button } from '../ui';
// Shared status-pill base; per-state color/bg/border appended below. The gruvbox
@@ -62,8 +63,9 @@ function isTauri() {
export default function DictationDemo({ embedded = false }) {
const { t } = useTranslation();
const [shortcut, setShortcut] = useState('');
const [hotkeyState, setHotkeyState] = useState('unknown'); // unknown | registered | verified
const desktop = isTauri();
const { info: shortcut } = useEffectiveDictationShortcut(desktop);
const [playingId, setPlayingId] = useState(null);
const [transcripts, setTranscripts] = useState({}); // {scriptId: {state, text, error}}
// null = probing, true/false once the demo assets are confirmed present.
@@ -89,26 +91,12 @@ export default function DictationDemo({ embedded = false }) {
};
}, []);
// Read the registered hotkey on mount.
useEffect(() => {
if (!isTauri()) return;
let cancelled = false;
(async () => {
try {
const { invoke } = await import('@tauri-apps/api/core');
const v = await invoke('get_dictation_shortcut');
if (!cancelled) {
setShortcut(v || '');
setHotkeyState(v ? 'registered' : 'unknown');
}
} catch {
if (!cancelled) setHotkeyState('unknown');
}
})();
return () => {
cancelled = true;
};
}, []);
if (!desktop) return;
setHotkeyState((current) =>
current === 'verified' ? current : shortcut.backend === 'focused' ? 'unknown' : 'registered',
);
}, [desktop, shortcut.backend]);
// Subscribe to dictation events: the moment the user presses their
// hotkey while this panel is mounted, flip to verified.
@@ -214,7 +202,7 @@ export default function DictationDemo({ embedded = false }) {
>
<Keyboard size={12} /> {t('demo.dictation_status_pending')}{' '}
<code className="font-mono text-[10px] px-[4px] py-[1px] bg-[rgba(0,0,0,0.3)] rounded-[3px]">
{shortcut}
{shortcut.display}
</code>
</span>
);
+13 -18
View File
@@ -3,6 +3,7 @@ import { Keyboard } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '../../ui';
import { useEffectiveDictationShortcut } from '../../hooks/useEffectiveDictationShortcut';
import { SettingsSection, SettingRow } from './primitives';
import { isTauri } from './native';
@@ -41,7 +42,6 @@ function isPureModifierEvent(e) {
export default function HotkeyTab() {
const { t } = useTranslation();
const [current, setCurrent] = useState('');
const [recording, setRecording] = useState(false);
const [pending, setPending] = useState('');
// True after a modifier-less press while recording drives the inline
@@ -49,20 +49,17 @@ export default function HotkeyTab() {
const [rejected, setRejected] = useState(false);
const [saving, setSaving] = useState(false);
const tauri = isTauri();
const { info: shortcut, error: shortcutError } = useEffectiveDictationShortcut(tauri);
const current = shortcut.accelerator;
// Load the saved shortcut on mount.
useEffect(() => {
if (!tauri) return;
(async () => {
try {
const { invoke } = await import('@tauri-apps/api/core');
const v = await invoke('get_dictation_shortcut');
setCurrent(v || '');
} catch (e) {
toast.error(t('settings.shortcut_load_failed', { message: e?.message || e }));
}
})();
}, [tauri]);
if (!shortcutError) return;
toast.error(
t('settings.shortcut_load_failed', {
message: shortcutError?.message || shortcutError,
}),
);
}, [shortcutError, t]);
// While recording, swallow keystrokes globally and convert the next real
// press into an accelerator string. Escape cancels; losing window focus
@@ -106,9 +103,8 @@ export default function HotkeyTab() {
try {
const { invoke } = await import('@tauri-apps/api/core');
const saved = await invoke('set_dictation_shortcut', { accelerator: pending });
setCurrent(saved);
setPending('');
toast.success(t('settings.shortcut_set', { shortcut: saved }));
toast.success(t('settings.shortcut_set', { shortcut: saved.display || saved.accelerator }));
} catch (e) {
// Common cause: the OS or another app already owns the combo. Surface
// the raw error so the user can pick something else.
@@ -122,10 +118,9 @@ export default function HotkeyTab() {
setSaving(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
const saved = await invoke('set_dictation_shortcut', {
await invoke('set_dictation_shortcut', {
accelerator: 'CmdOrCtrl+Shift+Space',
});
setCurrent(saved);
setPending('');
toast.success(t('settings.shortcut_reset'));
} catch (e) {
@@ -143,7 +138,7 @@ export default function HotkeyTab() {
</p>
)}
<SettingRow title={t('capture.active_shortcut')} control={current || '—'} mono />
<SettingRow title={t('capture.active_shortcut')} control={shortcut.display || '—'} mono />
<SettingRow
title={recording ? t('capture.press_key') : t('capture.new_shortcut')}
hint={<Trans i18nKey="capture.desc_detail" components={{ 1: <code />, 2: <code /> }} />}
@@ -1,20 +1,36 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import HotkeyTab from './HotkeyTab';
const shortcutEvents = vi.hoisted(() => ({}));
// Recording is only armed in the desktop shell; pretend we are in it and
// stub the two shortcut IPC commands.
vi.mock('./native', () => ({ isTauri: () => true }));
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(async (cmd) => (cmd === 'get_dictation_shortcut' ? 'CmdOrCtrl+Shift+Space' : '')),
invoke: vi.fn(async (cmd) =>
cmd === 'get_effective_dictation_shortcut'
? {
accelerator: 'CmdOrCtrl+Shift+Space',
display: 'Ctrl+Shift+Space',
backend: 'native',
}
: '',
),
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async (name, handler) => {
shortcutEvents[name] = handler;
return vi.fn();
}),
}));
async function startRecording() {
render(<HotkeyTab />);
// Wait for the mount-time shortcut load so state updates stay inside act().
await screen.findByText('CmdOrCtrl+Shift+Space');
await screen.findByText('Ctrl+Shift+Space');
fireEvent.click(screen.getByRole('button', { name: 'Record shortcut' }));
expect(screen.getByText(/listening/)).toBeInTheDocument();
}
@@ -67,4 +83,19 @@ describe('HotkeyTab — recording feedback and cancel affordances', () => {
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' });
expect(screen.queryByText(/listening/)).toBeNull();
});
it('shows the portal effective shortcut when registration finishes', async () => {
render(<HotkeyTab />);
await screen.findByText('Ctrl+Shift+Space');
act(() => {
shortcutEvents['dictation-shortcut-changed']({
payload: {
accelerator: 'CmdOrCtrl+Shift+Space',
display: 'Meta+Shift+V',
backend: 'portal',
},
});
});
expect(await screen.findByText('Meta+Shift+V')).toBeInTheDocument();
});
});
@@ -4,8 +4,7 @@
* The home of the live-dictation controls (the "Voice" card in the screenshot):
*
* 1. Enable Voice Dictation master toggle. Subtitle shows the REAL
* registered dictation shortcut (read from the `get_dictation_shortcut`
* Tauri command, same source as HotkeyTab); changing the shortcut still
* effective dictation shortcut (same live source as HotkeyTab); changing it still
* lives in HotkeyTab below.
* 2. Dictation Mode Toggle / Hold segmented control. Toggle = press once to
* start, again to stop. Hold = dictate while the key is held.
@@ -35,6 +34,7 @@ import { useInstallModel, useDeleteModel } from '../../api/hooks';
import { setupDownloadStreamUrl } from '../../api/setup';
import { isTauri as _isTauri } from '../../utils/media';
import { Badge, Progress, Segmented } from '../../ui';
import { useEffectiveDictationShortcut } from '../../hooks/useEffectiveDictationShortcut';
import { SettingsSection, SettingRow, SettingsToggle } from './primitives';
/** Native confirm dialog in Tauri, window.confirm in the web UI. Mirrors the
@@ -58,10 +58,6 @@ function fmtSize(sizeGb) {
return `${sizeGb.toFixed(sizeGb < 10 ? 1 : 0)} GB`;
}
/** The default shortcut label HotkeyTab resets to used when not in Tauri or
* the read fails, so the subtitle never shows a bare placeholder. */
const DEFAULT_SHORTCUT = 'CmdOrCtrl+Shift+Space';
export default function VoicePanel() {
const { t } = useTranslation();
@@ -78,7 +74,7 @@ export default function VoicePanel() {
const [engineReason, setEngineReason] = useState(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [shortcut, setShortcut] = useState('');
const { info: shortcut } = useEffectiveDictationShortcut(_isTauri);
// Per-repo download runtime, keyed by repo_id, driven by the shared SSE
// progress stream (same events ModelStoreTab consumes):
@@ -96,27 +92,6 @@ export default function VoicePanel() {
loadPrefs();
}, [loadPrefs]);
// Read the registered dictation shortcut the same way HotkeyTab does.
useEffect(() => {
let cancelled = false;
(async () => {
if (!_isTauri) {
setShortcut(DEFAULT_SHORTCUT);
return;
}
try {
const { invoke } = await import('@tauri-apps/api/core');
const v = await invoke('get_dictation_shortcut');
if (!cancelled) setShortcut(v || DEFAULT_SHORTCUT);
} catch {
if (!cancelled) setShortcut(DEFAULT_SHORTCUT);
}
})();
return () => {
cancelled = true;
};
}, []);
const loadModels = React.useCallback(async () => {
try {
const data = await apiJson('/dictation/models');
@@ -264,7 +239,7 @@ export default function VoicePanel() {
// back to the backend `languages` string when a key is missing.
const modelDesc = (m) => t(`voicePanel.model_desc.${m.id}`, { defaultValue: m.languages || '' });
const shortcutLabel = shortcut || DEFAULT_SHORTCUT;
const shortcutLabel = shortcut.display;
return (
<SettingsSection
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { DEFAULT_SHORTCUT, formatShortcut } from '../utils/dictationShortcut';
function fallbackInfo() {
return {
accelerator: DEFAULT_SHORTCUT,
display: formatShortcut(DEFAULT_SHORTCUT),
backend: 'focused',
};
}
/** Live view of the shortcut the OS actually registered. */
export function useEffectiveDictationShortcut(enabled = undefined) {
const desktop =
enabled ??
(typeof window !== 'undefined' &&
Object.prototype.hasOwnProperty.call(window, '__TAURI_INTERNALS__'));
const [state, setState] = useState(() => ({ info: fallbackInfo(), error: null }));
useEffect(() => {
if (!desktop) {
setState({ info: fallbackInfo(), error: null });
return;
}
let cancelled = false;
let unlisten;
const show = (info) => {
if (!cancelled && info?.accelerator) setState({ info, error: null });
};
(async () => {
try {
const [{ invoke }, { listen }] = await Promise.all([
import('@tauri-apps/api/core'),
import('@tauri-apps/api/event'),
]);
unlisten = await listen('dictation-shortcut-changed', ({ payload }) => show(payload));
show(await invoke('get_effective_dictation_shortcut'));
if (cancelled) unlisten?.();
} catch (error) {
if (!cancelled) setState((current) => ({ ...current, error }));
}
})();
return () => {
cancelled = true;
unlisten?.();
};
}, [desktop]);
return state;
}
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "تصدير الكل",
"clear_title": "مسح الكل",
"empty_title": "لا يوجد نسخ بعد",
"empty_desc": "استخدم زر الالتقاط (⌘+⇧+Space) لتسجيل الصوت ونسخه.",
"empty_desc": "اختر بدء الإملاء أو اضغط {{shortcut}} لتسجيل الصوت ونسخه.",
"capture": "بدء الإملاء",
"capture_failed": "تعذر بدء الإملاء. تحقق من إذن الميكروفون، ثم حاول مرة أخرى.",
"empty_search_title": "لا توجد نسخ مطابقة",
"empty_search_desc": "حاول استخدام مصطلح بحث مختلف.",
"copy": "نسخ",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Alles exportieren",
"clear_title": "Alles löschen",
"empty_title": "Noch keine Transkriptionen",
"empty_desc": "Verwenden Sie die Aufnahmetaste (⌘+⇧+Leertaste), um Audio aufzunehmen und zu transkribieren.",
"empty_desc": "Wählen Sie „Diktat starten“ oder drücken Sie {{shortcut}}, um Audio aufzunehmen und zu transkribieren.",
"capture": "Diktat starten",
"capture_failed": "Das Diktat konnte nicht gestartet werden. Prüfe den Mikrofonzugriff und versuche es erneut.",
"empty_search_title": "Keine passenden Transkriptionen",
"empty_search_desc": "Versuchen Sie es mit einem anderen Suchbegriff.",
"copy": "Kopieren",
+3 -1
View File
@@ -1535,7 +1535,9 @@
"export_title": "Export all",
"clear_title": "Clear all",
"empty_title": "No transcriptions yet",
"empty_desc": "Use the capture button (⌘+⇧+Space) to record and transcribe audio.",
"empty_desc": "Select Start dictation or press {{shortcut}} to record and transcribe audio.",
"capture": "Start dictation",
"capture_failed": "Could not start dictation. Check microphone access, then try again.",
"empty_search_title": "No matching transcriptions",
"empty_search_desc": "Try a different search term.",
"copy": "Copy",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Exportar todo",
"clear_title": "Borrar todo",
"empty_title": "Aún no hay transcripciones",
"empty_desc": "Utilice el botón de captura (⌘+⇧+Espacio) para grabar y transcribir audio.",
"empty_desc": "Seleccione Iniciar dictado o pulse {{shortcut}} para grabar y transcribir audio.",
"capture": "Iniciar dictado",
"capture_failed": "No se pudo iniciar el dictado. Comprueba el acceso al micrófono y vuelve a intentarlo.",
"empty_search_title": "No hay transcripciones coincidentes",
"empty_search_desc": "Pruebe con un término de búsqueda diferente.",
"copy": "Copiar",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Exporter tout",
"clear_title": "Tout effacer",
"empty_title": "Aucune transcription pour l'instant",
"empty_desc": "Utilisez le bouton de capture (⌘+⇧+Espace) pour enregistrer et transcrire l'audio.",
"empty_desc": "Sélectionnez Démarrer la dictée ou appuyez sur {{shortcut}} pour enregistrer et transcrire l'audio.",
"capture": "Démarrer la dictée",
"capture_failed": "Impossible de démarrer la dictée. Vérifiez laccès au microphone, puis réessayez.",
"empty_search_title": "Aucune transcription correspondante",
"empty_search_desc": "Essayez un autre terme de recherche.",
"copy": "Copier",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "सभी निर्यात करें",
"clear_title": "सब साफ़ करें",
"empty_title": "अभी तक कोई प्रतिलेखन नहीं",
"empty_desc": "ऑडियो रिकॉर्ड और ट्रांसक्राइब करने के लिए कैप्चर बटन (⌘+⇧+Space) का उपयोग करें।",
"empty_desc": "ऑडियो रिकॉर्ड और ट्रांसक्राइब करने के लिए डिक्टेशन शुरू करें चुनें या {{shortcut}} दबाएँ।",
"capture": "डिक्टेशन शुरू करें",
"capture_failed": "डिक्टेशन शुरू नहीं हो सका। माइक्रोफ़ोन की अनुमति जाँचें, फिर दोबारा कोशिश करें।",
"empty_search_title": "कोई मेल खाता प्रतिलेखन नहीं",
"empty_search_desc": "कोई भिन्न खोज शब्द आज़माएँ.",
"copy": "प्रतिलिपि",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Ekspor semua",
"clear_title": "Hapus semuanya",
"empty_title": "Belum ada transkripsi",
"empty_desc": "Gunakan tombol ambil (⌘+⇧+Spasi) untuk merekam dan menyalin audio.",
"empty_desc": "Pilih Mulai dikte atau tekan {{shortcut}} untuk merekam dan menyalin audio.",
"capture": "Mulai dikte",
"capture_failed": "Dikte tidak dapat dimulai. Periksa akses mikrofon, lalu coba lagi.",
"empty_search_title": "Tidak ada transkripsi yang cocok",
"empty_search_desc": "Coba istilah pencarian lain.",
"copy": "Salin",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Esporta tutto",
"clear_title": "Cancella tutto",
"empty_title": "Nessuna trascrizione ancora",
"empty_desc": "Utilizza il pulsante di acquisizione (⌘+⇧+Spazio) per registrare e trascrivere l'audio.",
"empty_desc": "Seleziona Avvia dettatura o premi {{shortcut}} per registrare e trascrivere l'audio.",
"capture": "Avvia dettatura",
"capture_failed": "Impossibile avviare la dettatura. Verifica laccesso al microfono e riprova.",
"empty_search_title": "Nessuna trascrizione corrispondente",
"empty_search_desc": "Prova un termine di ricerca diverso.",
"copy": "Copia",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "すべてエクスポート",
"clear_title": "すべてクリア",
"empty_title": "転写はまだありません",
"empty_desc": "キャプチャ ボタン (⌘+⇧+Space) を使用して音声を録音し文字に起こします。",
"empty_desc": "「音声入力を開始」を選択するか {{shortcut}} を押して音声を録音し文字に起こします。",
"capture": "音声入力を開始",
"capture_failed": "音声入力を開始できませんでした。マイクへのアクセスを確認して、もう一度お試しください。",
"empty_search_title": "一致する文字起こしはありません",
"empty_search_desc": "別の検索語を試してください。",
"copy": "コピー",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "모두 내보내기",
"clear_title": "모두 지우기",
"empty_title": "아직 스크립트가 없습니다.",
"empty_desc": "오디오를 녹음하고 텍스트로 변환하려면 캡처 버튼(⌘+⇧+Space)을 사용하세요.",
"empty_desc": "받아쓰기 시작을 선택하거나 {{shortcut}}을 눌러 오디오를 녹음하고 텍스트로 변환하세요.",
"capture": "받아쓰기 시작",
"capture_failed": "받아쓰기를 시작할 수 없습니다. 마이크 접근 권한을 확인한 후 다시 시도하세요.",
"empty_search_title": "일치하는 스크립트가 없습니다.",
"empty_search_desc": "다른 검색어를 사용해 보세요.",
"copy": "복사",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Alles exporteren",
"clear_title": "Alles wissen",
"empty_title": "Nog geen transcripties",
"empty_desc": "Gebruik de opnameknop (⌘+⇧+Spatie) om audio op te nemen en te transcriberen.",
"empty_desc": "Selecteer Dicteren starten of druk op {{shortcut}} om audio op te nemen en te transcriberen.",
"capture": "Dicteren starten",
"capture_failed": "Dicteren kon niet worden gestart. Controleer de microfoontoegang en probeer het opnieuw.",
"empty_search_title": "Geen overeenkomende transcripties",
"empty_search_desc": "Probeer een andere zoekterm.",
"copy": "Kopieer",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Eksportuj wszystko",
"clear_title": "Wyczyść wszystko",
"empty_title": "Nie ma jeszcze transkrypcji",
"empty_desc": "Użyj przycisku przechwytywania (⌘+⇧+spacja), aby nagrać i transkrybować dźwięk.",
"empty_desc": "Wybierz Rozpocznij dyktowanie lub naciśnij {{shortcut}}, aby nagrać i transkrybować dźwięk.",
"capture": "Rozpocznij dyktowanie",
"capture_failed": "Nie udało się rozpocząć dyktowania. Sprawdź dostęp do mikrofonu i spróbuj ponownie.",
"empty_search_title": "Brak pasujących transkrypcji",
"empty_search_desc": "Wypróbuj inne wyszukiwane hasło.",
"copy": "Kopiuj",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Exportar tudo",
"clear_title": "Limpar tudo",
"empty_title": "Ainda não há transcrições",
"empty_desc": "Use o botão de captura (⌘+⇧+Espaço) para gravar e transcrever áudio.",
"empty_desc": "Selecione Iniciar ditado ou pressione {{shortcut}} para gravar e transcrever áudio.",
"capture": "Iniciar ditado",
"capture_failed": "Não foi possível iniciar o ditado. Verifique o acesso ao microfone e tente novamente.",
"empty_search_title": "Nenhuma transcrição correspondente",
"empty_search_desc": "Experimente um termo de pesquisa diferente.",
"copy": "Copiar",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Экспортировать все",
"clear_title": "Очистить все",
"empty_title": "Транскрипций пока нет",
"empty_desc": "Используйте кнопку захвата (⌘+⇧+Пробел) для записи и расшифровки звука.",
"empty_desc": "Выберите «Начать диктовку» или нажмите {{shortcut}}, чтобы записать и расшифровать звук.",
"capture": "Начать диктовку",
"capture_failed": "Не удалось начать диктовку. Проверьте доступ к микрофону и повторите попытку.",
"empty_search_title": "Нет подходящих транскрипций",
"empty_search_desc": "Попробуйте другой поисковый запрос.",
"copy": "Копировать",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Exportera allt",
"clear_title": "Rensa alla",
"empty_title": "Inga transkriptioner än",
"empty_desc": "Använd inspelningsknappen (⌘+⇧+Mellanslag) för att spela in och transkribera ljud.",
"empty_desc": "Välj Starta diktering eller tryck på {{shortcut}} för att spela in och transkribera ljud.",
"capture": "Starta diktering",
"capture_failed": "Det gick inte att starta dikteringen. Kontrollera mikrofonåtkomsten och försök igen.",
"empty_search_title": "Inga matchande transkriptioner",
"empty_search_desc": "Prova en annan sökterm.",
"copy": "Kopiera",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "ส่งออกทั้งหมด",
"clear_title": "เคลียร์ทั้งหมด",
"empty_title": "ยังไม่มีการถอดเสียงเป็นคำ",
"empty_desc": "ใช้ปุ่มจับภาพ (⌘+⇧+Space) เพื่อบันทึกและถอดเสียง",
"empty_desc": "เลือก เริ่มการป้อนตามคำบอก หรือกด {{shortcut}} เพื่อบันทึกและถอดเสียง",
"capture": "เริ่มการป้อนตามคำบอก",
"capture_failed": "ไม่สามารถเริ่มการป้อนตามคำบอกได้ โปรดตรวจสอบสิทธิ์เข้าถึงไมโครโฟนแล้วลองอีกครั้ง",
"empty_search_title": "ไม่มีการถอดเสียงที่ตรงกัน",
"empty_search_desc": "ลองใช้คำค้นหาอื่น",
"copy": "คัดลอก",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Tümünü dışa aktar",
"clear_title": "Tümünü temizle",
"empty_title": "Henüz transkripsiyon yok",
"empty_desc": "Sesi kaydetmek ve metne dönüştürmek için yakalama düğmesini (⌘+⇧+Boşluk) kullanın.",
"empty_desc": "Sesi kaydetmek ve metne dönüştürmek için Dikteyi başlat'ı seçin veya {{shortcut}} tuşlarına basın.",
"capture": "Dikteyi başlat",
"capture_failed": "Dikte başlatılamadı. Mikrofon erişimini kontrol edip tekrar deneyin.",
"empty_search_title": "Eşleşen transkripsiyon yok",
"empty_search_desc": "Farklı bir arama terimi deneyin.",
"copy": "Kopyala",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Експортувати все",
"clear_title": "Очистити все",
"empty_title": "Транскрипцій ще немає",
"empty_desc": "Використовуйте кнопку захоплення (⌘+⇧+пробіл), щоб записати та транскрибувати аудіо.",
"empty_desc": "Виберіть «Почати диктування» або натисніть {{shortcut}}, щоб записати й транскрибувати аудіо.",
"capture": "Почати диктування",
"capture_failed": "Не вдалося почати диктування. Перевірте доступ до мікрофона та повторіть спробу.",
"empty_search_title": "Немає відповідних транскрипцій",
"empty_search_desc": "Спробуйте інший термін пошуку.",
"copy": "Копіювати",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "Xuất tất cả",
"clear_title": "Xóa tất cả",
"empty_title": "No transcriptions yet",
"empty_desc": "Sử dụng nút chụp (⌘+⇧+Space) để ghi và chép lại âm thanh.",
"empty_desc": "Chọn Bắt đầu đọc chính tả hoặc nhấn {{shortcut}} để ghi và chép lại âm thanh.",
"capture": "Bắt đầu đọc chính tả",
"capture_failed": "Không thể bắt đầu đọc chính tả. Hãy kiểm tra quyền truy cập micrô rồi thử lại.",
"empty_search_title": "Không có bản chép lời phù hợp",
"empty_search_desc": "Hãy thử một cụm từ tìm kiếm khác.",
"copy": "Sao chép",
+3 -1
View File
@@ -1185,7 +1185,9 @@
"export_title": "导出全部",
"clear_title": "清空全部",
"empty_title": "暂无转录记录",
"empty_desc": "使用捕获快捷键(⌘+⇧+Space录制和转录音频。",
"empty_desc": "选择“开始听写”或按 {{shortcut}} 录制和转录音频。",
"capture": "开始听写",
"capture_failed": "无法开始听写。请检查麦克风访问权限,然后重试。",
"empty_search_title": "未找到匹配的转录",
"empty_search_desc": "试试其他搜索词。",
"copy": "复制",
+3 -1
View File
@@ -1226,7 +1226,9 @@
"export_title": "全部導出",
"clear_title": "全部清除",
"empty_title": "還沒有轉錄",
"empty_desc": "使用捕獲按鈕(⌘+⇧+空格)錄製和轉錄音訊。",
"empty_desc": "選擇「開始聽寫」或按 {{shortcut}} 錄製和轉錄音訊。",
"capture": "開始聽寫",
"capture_failed": "無法開始聽寫。請檢查麥克風存取權限,然後再試一次。",
"empty_search_title": "沒有匹配的轉錄",
"empty_search_desc": "嘗試不同的搜尋字詞。",
"copy": "複製",
+1 -1
View File
@@ -3327,7 +3327,7 @@ html[data-ui-scale-engine='native'] .app-bootstrap-scale {
/* ── Widget-mode body (standalone Tauri window) ─────────────────────── */
body:has(.capture-pill) {
html[data-window='widget'] body:has(.capture-pill) {
background: transparent !important;
margin: 0;
padding: 8px;
+20 -31
View File
@@ -18,6 +18,8 @@ import './index.css';
import App from './App.jsx';
import ErrorBoundary from './components/ErrorBoundary';
import RemoteAuthGate from './components/RemoteAuthGate';
import DesktopCaptureShortcutBridge from './components/DesktopCaptureShortcutBridge';
import CaptureWidget from './components/CaptureWidget.jsx';
import { installConsoleCapture } from './utils/consoleBuffer.js';
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers.js';
@@ -36,9 +38,6 @@ const queryClient = new QueryClient({
},
});
import { Suspense, lazy } from 'react';
const CaptureWidget = lazy(() => import('./components/CaptureWidget.jsx'));
// Detect which Tauri window we're rendering in.
// Tauri 2's WebviewUrl::App(PathBuf) variant doesn't support query strings
// declaring `"url": "/?window=widget"` in tauri.conf.json silently failed to
@@ -69,6 +68,7 @@ async function detectIsWidget() {
export async function bootstrapApp() {
const isWidget = await detectIsWidget();
const isDesktopShell = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
// The widget window is `transparent: true` (tauri.conf.json), but it loads
// the SAME index.html as the main window so `body { background-color:
@@ -80,7 +80,8 @@ export async function bootstrapApp() {
// there is no window in which an unstyled frame can be seen.
if (isWidget) document.documentElement.dataset.window = 'widget';
createRoot(document.getElementById('root')).render(
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
{/* Root error boundary the missing layer between App's own render and
the shell's blank_guard (frontend/src-tauri/src/blank_guard.rs). App
@@ -101,38 +102,26 @@ export async function bootstrapApp() {
unaffected (the gate only shows on an ov:auth-required event). */}
<RemoteAuthGate>
{isWidget ? (
<Suspense
fallback={
<div
style={{
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(18, 18, 22, 0.88)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '100px',
color: 'rgba(255, 255, 255, 0.9)',
fontFamily: '"Inter Variable", "Inter", -apple-system, sans-serif',
fontSize: 13,
userSelect: 'none',
}}
>
Loading dictation
</div>
}
>
<CaptureWidget />
</Suspense>
<CaptureWidget />
) : (
<App />
<>
<App />
{isDesktopShell && <DesktopCaptureShortcutBridge />}
{/* The desktop shell owns a separate global-hotkey widget
window. Browser/Docker builds do not, so mount the same
capture engine here to provide the documented focused-page
Ctrl+Shift+Space fallback. */}
{!isDesktopShell && (
<div className="capture-pill-host">
<CaptureWidget />
</div>
)}
</>
)}
</RemoteAuthGate>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>,
);
return root;
}
+29 -5
View File
@@ -13,6 +13,8 @@ import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from
import { Button } from '../ui';
import { toast } from 'react-hot-toast';
import { toMillis } from '../utils/relativeTime';
import { useEffectiveDictationShortcut } from '../hooks/useEffectiveDictationShortcut';
import { requestDictationCapture } from '../utils/dictationCapture';
import {
loadTranscriptions,
TRANSCRIPTIONS_KEY,
@@ -46,6 +48,18 @@ export default function TranscriptionsPage() {
const [transcriptions, setTranscriptions] = useState(loadTranscriptions);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
const { info: shortcut } = useEffectiveDictationShortcut();
const emptyDescription = t('transcriptions.empty_desc', { shortcut: shortcut.display });
const normalizedSearch = search.trim();
const startCapture = useCallback(async () => {
try {
await requestDictationCapture('start');
} catch (error) {
console.warn('Could not start dictation:', error);
toast.error(t('transcriptions.capture_failed'));
}
}, [t]);
// Listen for new transcriptions added from CaptureButton
useEffect(() => {
@@ -57,12 +71,12 @@ export default function TranscriptionsPage() {
}, []);
const filtered = useMemo(() => {
if (!search.trim()) return transcriptions;
const q = search.toLowerCase();
if (!normalizedSearch) return transcriptions;
const q = normalizedSearch.toLowerCase();
return transcriptions.filter(
(t) => t.text.toLowerCase().includes(q) || (t.language || '').toLowerCase().includes(q),
);
}, [transcriptions, search]);
}, [transcriptions, normalizedSearch]);
const selected = useMemo(
() => transcriptions.find((t) => t.id === selectedId),
@@ -145,6 +159,9 @@ export default function TranscriptionsPage() {
</span>
</div>
<div className="txn-header__right flex items-center gap-[6px]">
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
</Button>
<div className="txn-search relative flex items-center">
<Search
size={13}
@@ -189,11 +206,18 @@ export default function TranscriptionsPage() {
<div className="txn-empty flex h-full flex-col items-center justify-center gap-[8px] px-[20px] py-[40px] text-center text-fg-muted">
<Mic size={32} className="txn-empty__icon opacity-30" />
<p className="txn-empty__title m-0 text-[var(--text-sm)] font-medium text-fg">
{search ? t('transcriptions.empty_search_title') : t('transcriptions.empty_title')}
{normalizedSearch
? t('transcriptions.empty_search_title')
: t('transcriptions.empty_title')}
</p>
<p className="txn-empty__desc m-0 max-w-[280px] text-[var(--text-xs)] leading-[1.6] text-fg-muted">
{search ? t('transcriptions.empty_search_desc') : t('transcriptions.empty_desc')}
{normalizedSearch ? t('transcriptions.empty_search_desc') : emptyDescription}
</p>
{!normalizedSearch && (
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
</Button>
)}
</div>
) : (
filtered.map((t) => (
@@ -0,0 +1,68 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestDictationCapture, toast } = vi.hoisted(() => ({
requestDictationCapture: vi.fn(),
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('../utils/dictationCapture', () => ({ requestDictationCapture }));
vi.mock('../hooks/useEffectiveDictationShortcut', () => ({
useEffectiveDictationShortcut: () => ({
info: {
accelerator: 'Super+Shift+V',
display: 'Super+Shift+V',
backend: 'portal',
},
}),
}));
vi.mock('react-hot-toast', () => ({ toast }));
import TranscriptionsPage, { addTranscription } from './Transcriptions';
describe('Transcriptions capture entry point', () => {
beforeEach(() => {
localStorage.clear();
requestDictationCapture.mockReset().mockResolvedValue(undefined);
toast.error.mockReset();
});
it('shows the effective shortcut and starts the shared recorder from the empty state', async () => {
render(<TranscriptionsPage />);
expect(screen.getByText(/Super\+Shift\+V/)).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Start dictation' }).at(-1));
await waitFor(() => expect(requestDictationCapture).toHaveBeenCalledWith('start'));
});
it('reports a capture-controller failure', async () => {
requestDictationCapture.mockRejectedValueOnce(new Error('event channel unavailable'));
render(<TranscriptionsPage />);
fireEvent.click(screen.getAllByRole('button', { name: 'Start dictation' }).at(-1));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
'Could not start dictation. Check microphone access, then try again.',
),
);
});
it('keeps the capture action available for whitespace-only searches', () => {
render(<TranscriptionsPage />);
fireEvent.change(screen.getByRole('textbox', { name: 'Search transcriptions…' }), {
target: { value: ' ' },
});
expect(screen.getAllByRole('button', { name: 'Start dictation' })).toHaveLength(2);
expect(screen.getByText('No transcriptions yet')).toBeInTheDocument();
});
it('shows a successful transcript emitted by the shared recorder', async () => {
render(<TranscriptionsPage />);
act(() => {
addTranscription({ text: 'The shared capture path works.', language: 'en' });
});
expect(await screen.findByText('The shared capture path works.')).toBeInTheDocument();
});
});
@@ -10,13 +10,15 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const { toastMock } = vi.hoisted(() => ({
const { toastMock, eventHandlers, captureState } = vi.hoisted(() => ({
toastMock: Object.assign(vi.fn(), {
error: vi.fn(),
success: vi.fn(),
dismiss: vi.fn(),
loading: vi.fn(),
}),
eventHandlers: {},
captureState: { pending: null },
}));
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
@@ -25,7 +27,10 @@ vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args) => invokeMock(...args),
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async () => () => {}),
listen: vi.fn(async (name, handler) => {
eventHandlers[name] = handler;
return () => delete eventHandlers[name];
}),
}));
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ hide: async () => {} }),
@@ -57,12 +62,24 @@ vi.mock('../store', () => {
});
import CaptureWidget from '../components/CaptureWidget';
import { requestDictationCapture } from '../utils/dictationCapture';
/** Route the invoke mock per command. */
function stubInvoke({ mic = 'granted' } = {}) {
invokeMock.mockImplementation(async (cmd) => {
invokeMock.mockImplementation(async (cmd, payload) => {
if (cmd === 'check_microphone') return mic;
if (cmd === 'check_accessibility') return true;
if (cmd === 'request_dictation_capture') {
const event = payload?.action === 'stop' ? 'tray-dictate-stop' : 'tray-dictate';
if (eventHandlers[event]) return eventHandlers[event]();
captureState.pending = event;
return undefined;
}
if (cmd === 'mark_dictation_capture_ready' && captureState.pending) {
const pending = captureState.pending;
captureState.pending = null;
return eventHandlers[pending]?.();
}
return undefined;
});
}
@@ -83,12 +100,13 @@ const notFound = () => {
return e;
};
/** Fire the in-page dictation shortcut (Ctrl+Shift+Space). */
/** Request capture through the same controller used by the page and shortcut. */
function pressShortcut() {
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
void requestDictationCapture('start');
}
beforeEach(() => {
captureState.pending = null;
invokeMock.mockReset();
toastMock.mockClear();
toastMock.error.mockClear();
@@ -126,6 +144,20 @@ describe('CaptureWidget — mic permission pre-flight (Tauri)', () => {
});
});
it('the shared desktop capture request surfaces microphone denial', async () => {
stubInvoke({ mic: 'denied' });
const gum = installGum(async () => {
throw notFound();
});
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
eventHandlers['tray-dictate']();
expect(await screen.findByText(/Mic access denied/)).toBeInTheDocument();
expect(gum).not.toHaveBeenCalled();
expect(toastMock.error).toHaveBeenCalled();
});
it.each(['granted', 'prompt', 'unknown'])(
'"%s" proceeds to getUserMedia as before (reactive micError stays the fallback)',
async (mic) => {
@@ -149,6 +181,16 @@ describe('CaptureWidget — mic permission pre-flight (Tauri)', () => {
});
describe('CaptureWidget — plain browser (no Tauri)', () => {
it('starts through the shared capture request', async () => {
const gum = installGum(async () => {
throw notFound();
});
render(<CaptureWidget />);
await requestDictationCapture('start');
await waitFor(() => expect(gum).toHaveBeenCalled());
});
it('behaviour unchanged: no permission probe, straight to getUserMedia', async () => {
const gum = installGum(async () => {
throw notFound();
@@ -16,10 +16,14 @@
* is transparent, so html, body and #root all have to be covered.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen } from '@testing-library/react';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('../App.jsx', () => ({ default: () => null }));
vi.mock('../components/CaptureWidget.jsx', () => ({
default: () => <span data-testid="capture-widget-mounted" />,
}));
const setLabel = (label) =>
vi.doMock('@tauri-apps/api/window', () => ({
@@ -27,17 +31,23 @@ const setLabel = (label) =>
}));
describe('the widget window marks itself on <html>', () => {
let root;
beforeEach(() => {
vi.resetModules();
const root = document.createElement('div');
root.id = 'root';
document.body.appendChild(root);
delete document.documentElement.dataset.window;
delete window.__TAURI_INTERNALS__;
});
afterEach(() => {
root?.unmount();
root = undefined;
document.getElementById('root')?.remove();
delete document.documentElement.dataset.window;
delete window.__TAURI_INTERNALS__;
vi.doUnmock('@tauri-apps/api/window');
vi.restoreAllMocks();
});
@@ -45,25 +55,40 @@ describe('the widget window marks itself on <html>', () => {
it('sets data-window="widget" when rendering in the widget window', async () => {
setLabel('widget');
const { bootstrapApp } = await import('../main-app.jsx');
await bootstrapApp();
root = await bootstrapApp();
expect(document.documentElement.dataset.window).toBe('widget');
});
it('leaves the main window unmarked, so it keeps the opaque chrome', async () => {
setLabel('main');
const { bootstrapApp } = await import('../main-app.jsx');
await bootstrapApp();
root = await bootstrapApp();
expect(document.documentElement.dataset.window).toBeUndefined();
});
it('mounts the in-page capture listener in browser mode', async () => {
setLabel('main');
const { bootstrapApp } = await import('../main-app.jsx');
root = await bootstrapApp();
expect(await screen.findByTestId('capture-widget-mounted')).toBeInTheDocument();
});
it('leaves capture to the separate widget in a desktop main window', async () => {
window.__TAURI_INTERNALS__ = {};
setLabel('main');
const { bootstrapApp } = await import('../main-app.jsx');
root = await bootstrapApp();
expect(screen.queryByTestId('capture-widget-mounted')).not.toBeInTheDocument();
});
});
describe('index.css honours the marker', () => {
const css = fs.readFileSync(path.join(import.meta.dirname, '..', 'index.css'), 'utf8');
const marker = "html[data-window='widget']";
it('clears the background on html, body AND #root', () => {
// Any one of the three left opaque defeats the window transparency, so
// the selector list is the contract not just "the file mentions it".
const marker = "html[data-window='widget']";
const start = css.indexOf(`${marker},`);
expect(start).toBeGreaterThan(-1);
const rule = css.slice(start, css.indexOf('}', start));
@@ -75,5 +100,7 @@ describe('index.css honours the marker', () => {
it('does not disturb the main window background', () => {
expect(css).toContain('background-color: var(--chrome-bg)');
expect(css).toContain(`${marker} body:has(.capture-pill)`);
expect(css).not.toContain('\nbody:has(.capture-pill)');
});
});
+16
View File
@@ -0,0 +1,16 @@
import { invoke } from '@tauri-apps/api/core';
export const BROWSER_DICTATION_REQUEST = 'voicestudio:dictation-request';
export async function requestDictationCapture(action = 'start') {
if (typeof window === 'undefined') return;
if ('__TAURI_INTERNALS__' in window) {
await invoke('request_dictation_capture', { action });
return;
}
window.dispatchEvent(
new CustomEvent(BROWSER_DICTATION_REQUEST, {
detail: { action },
}),
);
}
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock('@tauri-apps/api/core', () => ({ invoke }));
import { BROWSER_DICTATION_REQUEST, requestDictationCapture } from './dictationCapture';
afterEach(() => {
delete window.__TAURI_INTERNALS__;
invoke.mockReset();
});
describe('dictation capture controller', () => {
it('routes desktop requests through the queued Tauri command', async () => {
window.__TAURI_INTERNALS__ = {};
invoke.mockResolvedValue(undefined);
await requestDictationCapture('start');
await requestDictationCapture('stop');
expect(invoke).toHaveBeenNthCalledWith(1, 'request_dictation_capture', { action: 'start' });
expect(invoke).toHaveBeenNthCalledWith(2, 'request_dictation_capture', { action: 'stop' });
});
it('routes browser requests to the mounted recorder', async () => {
const handler = vi.fn();
window.addEventListener(BROWSER_DICTATION_REQUEST, handler);
await requestDictationCapture('start');
window.removeEventListener(BROWSER_DICTATION_REQUEST, handler);
expect(handler).toHaveBeenCalledOnce();
expect(handler.mock.calls[0][0].detail).toEqual({ action: 'start' });
});
});
+130
View File
@@ -0,0 +1,130 @@
import { detectPlatform } from './micError';
const DEFAULT_SHORTCUT = 'CmdOrCtrl+Shift+Space';
function keyCode(part) {
if (/^[a-z]$/i.test(part)) return `Key${part.toUpperCase()}`;
if (/^[0-9]$/.test(part)) return `Digit${part}`;
const aliases = {
Return: 'Enter',
Esc: 'Escape',
PageUp: 'PageUp',
PageDown: 'PageDown',
};
return aliases[part] || part;
}
export function parseShortcut(accelerator = DEFAULT_SHORTCUT, platform = detectPlatform()) {
const parsed = {
accelerator,
alt: false,
ctrl: false,
meta: false,
shift: false,
code: '',
modifierCodes: new Set(),
};
for (const rawPart of accelerator
.split('+')
.map((part) => part.trim())
.filter(Boolean)) {
switch (rawPart.toLowerCase()) {
case 'cmdorctrl':
case 'commandorcontrol':
if (platform === 'mac') {
parsed.meta = true;
parsed.modifierCodes.add('Meta');
} else {
parsed.ctrl = true;
parsed.modifierCodes.add('Control');
}
break;
case 'cmd':
case 'command':
case 'meta':
case 'super':
parsed.meta = true;
parsed.modifierCodes.add('Meta');
break;
case 'ctrl':
case 'control':
parsed.ctrl = true;
parsed.modifierCodes.add('Control');
break;
case 'alt':
case 'option':
parsed.alt = true;
parsed.modifierCodes.add('Alt');
break;
case 'shift':
parsed.shift = true;
parsed.modifierCodes.add('Shift');
break;
default:
if (parsed.code) return null;
parsed.code = keyCode(rawPart);
}
}
return parsed.code ? parsed : null;
}
export function eventMatchesShortcut(event, accelerator, platform = detectPlatform()) {
const shortcut = parseShortcut(accelerator, platform);
return Boolean(
shortcut &&
event.code === shortcut.code &&
Boolean(event.altKey) === shortcut.alt &&
Boolean(event.ctrlKey) === shortcut.ctrl &&
Boolean(event.metaKey) === shortcut.meta &&
Boolean(event.shiftKey) === shortcut.shift,
);
}
export function isShortcutRelease(event, shortcut) {
if (!shortcut) return false;
if (event.code === shortcut.code) return true;
for (const modifier of shortcut.modifierCodes) {
if (
event.code === modifier ||
event.code === `${modifier}Left` ||
event.code === `${modifier}Right`
)
return true;
}
return false;
}
export function formatShortcut(accelerator, platform = detectPlatform()) {
const parts = accelerator
.split('+')
.map((part) => part.trim())
.filter(Boolean);
if (platform !== 'mac') {
return parts
.map((part) => {
if (/^(cmdorctrl|commandorcontrol|ctrl|control)$/i.test(part)) return 'Ctrl';
if (/^(cmd|command|meta|super)$/i.test(part)) return 'Super';
if (/^(alt|option)$/i.test(part)) return 'Alt';
if (/^shift$/i.test(part)) return 'Shift';
return part;
})
.join('+');
}
const glyphs = {
cmdorctrl: '⌘',
commandorcontrol: '⌘',
cmd: '⌘',
command: '⌘',
meta: '⌘',
super: '⌘',
ctrl: '⌃',
control: '⌃',
alt: '⌥',
option: '⌥',
shift: '⇧',
};
return parts.map((part) => glyphs[part.toLowerCase()] || part).join('');
}
export { DEFAULT_SHORTCUT };
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
eventMatchesShortcut,
formatShortcut,
isShortcutRelease,
parseShortcut,
} from './dictationShortcut';
describe('dictation shortcut parsing', () => {
it('resolves CmdOrCtrl to the current platform', () => {
expect(parseShortcut('CmdOrCtrl+Shift+Space', 'mac')).toMatchObject({
code: 'Space',
meta: true,
ctrl: false,
shift: true,
});
expect(parseShortcut('CmdOrCtrl+Shift+Space', 'windows')).toMatchObject({
code: 'Space',
meta: false,
ctrl: true,
shift: true,
});
});
it('matches the configured chord exactly', () => {
expect(
eventMatchesShortcut(
{ code: 'KeyK', ctrlKey: true, altKey: true, shiftKey: false, metaKey: false },
'Ctrl+Alt+K',
'linux',
),
).toBe(true);
expect(
eventMatchesShortcut(
{ code: 'KeyK', ctrlKey: true, altKey: true, shiftKey: true, metaKey: false },
'Ctrl+Alt+K',
'linux',
),
).toBe(false);
});
it('disarms hold-to-talk when a chord modifier is released first', () => {
const shortcut = parseShortcut('Ctrl+Shift+Space', 'linux');
expect(isShortcutRelease({ code: 'ControlLeft' }, shortcut)).toBe(true);
expect(isShortcutRelease({ code: 'KeyA' }, shortcut)).toBe(false);
});
it('formats platform-native hints', () => {
expect(formatShortcut('CmdOrCtrl+Shift+Space', 'mac')).toBe('⌘⇧Space');
expect(formatShortcut('CmdOrCtrl+Shift+Space', 'windows')).toBe('Ctrl+Shift+Space');
expect(formatShortcut('Cmd+Option+K', 'linux')).toBe('Super+Alt+K');
});
});