Merge pull request #1490 from debpalash/fix/wayland-capture-shortcut
fix(dictation): support global shortcuts on Wayland
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+1
@@ -2973,6 +2973,7 @@ dependencies = [
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"zbus",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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(®istration);
|
||||
return Err("shortcut registration was superseded by a newer request".into());
|
||||
}
|
||||
let mut active = match self.active.lock() {
|
||||
Ok(active) => active,
|
||||
Err(_) => {
|
||||
let _ = close_session(®istration);
|
||||
return Err("portal shortcut lock poisoned".into());
|
||||
}
|
||||
};
|
||||
if !self.is_current(revision) {
|
||||
drop(active);
|
||||
let _ = close_session(®istration);
|
||||
return Err("shortcut registration was superseded by a newer request".into());
|
||||
}
|
||||
if let Err(error) = start_listener(app, registration.clone()) {
|
||||
let _ = close_session(®istration);
|
||||
return Err(error);
|
||||
}
|
||||
let previous = active.replace(registration);
|
||||
drop(active);
|
||||
if let Some(previous) = previous {
|
||||
if let Err(error) = close_session(&previous) {
|
||||
log::warn!("Could not close the previous Wayland shortcut session: {error}");
|
||||
}
|
||||
}
|
||||
Ok(display)
|
||||
}
|
||||
}
|
||||
|
||||
const DESKTOP_ID: &str = "com.debpalash.omnivoice-studio";
|
||||
|
||||
fn 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(
|
||||
®istration.connection,
|
||||
DESKTOP_DESTINATION,
|
||||
registration.session.as_str(),
|
||||
SESSION_INTERFACE,
|
||||
)
|
||||
.map_err(|error| format!("could not open the shortcut session: {error}"))?;
|
||||
session
|
||||
.call::<_, _, ()>("Close", &())
|
||||
.map_err(|error| format!("could not close the shortcut session: {error}"))
|
||||
}
|
||||
|
||||
fn listen(app: tauri::AppHandle, registration: PortalRegistration) -> Result<(), String> {
|
||||
let portal = Proxy::new(
|
||||
®istration.connection,
|
||||
DESKTOP_DESTINATION,
|
||||
DESKTOP_PATH,
|
||||
GLOBAL_SHORTCUTS_INTERFACE,
|
||||
)
|
||||
.map_err(|error| format!("could not open the global-shortcuts portal: {error}"))?;
|
||||
|
||||
log::info!("Wayland dictation shortcut registered through xdg-desktop-portal");
|
||||
let mut signals = portal
|
||||
.receive_all_signals()
|
||||
.map_err(|error| format!("could not listen for portal shortcuts: {error}"))?;
|
||||
for message in &mut signals {
|
||||
let header = message.header();
|
||||
let member = header
|
||||
.member()
|
||||
.map(|name| name.as_str().to_owned())
|
||||
.unwrap_or_default();
|
||||
if member != "Activated" && member != "Deactivated" {
|
||||
continue;
|
||||
}
|
||||
let (signal_session, shortcut_id, _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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": "نسخ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’accès au microphone, puis réessayez.",
|
||||
"empty_search_title": "Aucune transcription correspondante",
|
||||
"empty_search_desc": "Essayez un autre terme de recherche.",
|
||||
"copy": "Copier",
|
||||
|
||||
@@ -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": "प्रतिलिपि",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’accesso al microfono e riprova.",
|
||||
"empty_search_title": "Nessuna trascrizione corrispondente",
|
||||
"empty_search_desc": "Prova un termine di ricerca diverso.",
|
||||
"copy": "Copia",
|
||||
|
||||
@@ -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": "コピー",
|
||||
|
||||
@@ -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": "복사",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Копировать",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "คัดลอก",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Копіювати",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "复制",
|
||||
|
||||
@@ -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": "複製",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user