feat(capture): dictation, personalities, 0.5.0

Ships the Capture release end to end. Global-hotkey dictation with
synthetic paste into the focused app on macOS and Windows, an on-screen
pill across recording / transcribing / refining, customizable push-to-
talk and toggle chords, and an accessibility-permission prompt scoped to
Settings → Captures with inline re-check feedback.

Voice profiles gain optional personalities that power compose / rewrite /
respond actions via a local Qwen3 LLM — shared with refinement, so there
is one local LLM in the app, not two.

Refinement hardened with deterministic Whisper-loop collapse before the
LLM sees the transcript, per-capture flag snapshots for re-runs, and a
ten-transcript evaluation harness across every bundled refinement size.

Version bump 0.4.5 → 0.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-22 18:49:16 -07:00
co-authored by Claude Opus 4.7
parent ed2eec591a
commit 87c582ad54
84 changed files with 11043 additions and 512 deletions
+42
View File
@@ -0,0 +1,42 @@
//! Platform permission gate for the auto-paste pipeline.
//!
//! On macOS, posting synthetic keyboard events and reading focused-UI state
//! via the AX API both require the host process to be listed under System
//! Settings → Privacy & Security → Accessibility. Without that trust,
//! `CGEventPost` silently drops events and `AXUIElementCopyAttributeValue`
//! returns an error. We surface a boolean check up front so the paste
//! pipeline can short-circuit with a clear "grant permission" message
//! instead of running through the full save → write → post → restore dance
//! with nothing to show for it.
//!
//! Windows has no equivalent user-facing permission — `SendInput` and
//! UIAutomation work for any non-elevated target out of the box. (UAC /
//! UIPI still blocks sending input *into* an elevated target window from a
//! non-elevated process, but that's per-target, not a global switch, and
//! there's no Settings pane to send users to.) So the Windows branch just
//! returns `true`.
#[cfg(target_os = "macos")]
mod ffi {
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
/// Returns true when the current process is listed in Accessibility.
/// No prompt side-effect.
pub fn AXIsProcessTrusted() -> bool;
}
}
#[cfg(target_os = "macos")]
pub fn is_trusted() -> bool {
unsafe { ffi::AXIsProcessTrusted() }
}
#[cfg(target_os = "windows")]
pub fn is_trusted() -> bool {
true
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn is_trusted() -> bool {
false
}
+718
View File
@@ -0,0 +1,718 @@
//! Snapshot / write / restore helpers around the system clipboard.
//!
//! Used by the auto-paste flow: before synthesising the paste accelerator
//! into a foreign app we need to (1) remember what the user had on the
//! clipboard, (2) stage our transcribed text, (3) paste, (4) put the
//! original contents back. Missing step 4 turns every dictation into a
//! silent clipboard-stomp.
//!
//! On **macOS** the snapshot walks `NSPasteboard.pasteboardItems` and
//! copies every `(UTI, data)` pair into an owned `Vec<u8>`, so restore
//! rebuilds the full multi-type payload — not just the plain-text
//! fallback. Images, styled text, file-reference lists all survive the
//! round-trip.
//!
//! On **Windows** the snapshot walks `EnumClipboardFormats` and copies the
//! HGLOBAL payload for every advertised format. GDI-handle formats (DIB
//! bitmap, metafile, enhanced metafile, palette), owner-display variants,
//! and the private-/GDI-object format ranges are skipped — those can't be
//! round-tripped across processes without synthesising the underlying
//! kernel/GDI objects, which isn't worth the complexity for a dictation
//! clipboard guard. CF_UNICODETEXT, CF_HDROP, CF_DIB (bitmap data in
//! memory, not a handle), CF_DIBV5, and every registered format (HTML
//! Format, Rich Text Format, FileGroupDescriptor, etc.) all survive.
//!
//! On **macOS** every entry point manages its own `NSAutoreleasePool`
//! because the Tauri command runtime threads don't have one by default —
//! without it, every autoreleased `NSString` / `NSData` we touch would
//! leak for the life of the process. On Windows, HGLOBAL ownership
//! transfers to the clipboard on `SetClipboardData` success, so we only
//! free handles we allocated but didn't hand off.
#[cfg(target_os = "macos")]
use objc::runtime::Object;
#[cfg(target_os = "macos")]
use objc::{class, msg_send, sel, sel_impl};
/// One full-fidelity snapshot of the general pasteboard. Hold on to the value
/// until the paste has landed, then pass it to [`restore_clipboard`].
#[derive(Debug, Clone)]
pub struct ClipboardSnapshot {
/// Outer vec: pasteboard items. Inner: `(uti, raw bytes)` per type. We
/// store the raw UTI string and the raw `NSData` payload so we can rebuild
/// the item with `setData:forType:` without interpreting the contents.
items: Vec<Vec<(String, Vec<u8>)>>,
/// `NSPasteboard.changeCount` at the moment of capture. Incremented by AppKit
/// on every mutation from any process, so a caller can decide whether a
/// restore is still safe (change_count == expected) or whether someone
/// else wrote to the clipboard in the interim and we should back off.
change_count: i64,
}
impl ClipboardSnapshot {
pub fn change_count(&self) -> i64 {
self.change_count
}
pub fn item_count(&self) -> usize {
self.items.len()
}
}
#[cfg(target_os = "macos")]
type Id = *mut Object;
/// RAII wrapper so the pool drains even on early return / `?` propagation.
#[cfg(target_os = "macos")]
struct AutoreleasePool {
pool: Id,
}
#[cfg(target_os = "macos")]
impl AutoreleasePool {
unsafe fn new() -> Self {
let pool: Id = msg_send![class!(NSAutoreleasePool), alloc];
let pool: Id = msg_send![pool, init];
Self { pool }
}
}
#[cfg(target_os = "macos")]
impl Drop for AutoreleasePool {
fn drop(&mut self) {
unsafe {
let _: () = msg_send![self.pool, drain];
}
}
}
/// Build an autoreleased `NSString` from a Rust `&str` without scanning for
/// interior nulls (which is what `initWithUTF8String:` would require).
#[cfg(target_os = "macos")]
unsafe fn ns_string(s: &str) -> Id {
// NSUTF8StringEncoding = 4.
let obj: Id = msg_send![class!(NSString), alloc];
let obj: Id = msg_send![
obj,
initWithBytes: s.as_ptr()
length: s.len()
encoding: 4u64
];
let _: () = msg_send![obj, autorelease];
obj
}
#[cfg(target_os = "macos")]
unsafe fn ns_string_to_rust(s: Id) -> Option<String> {
if s.is_null() {
return None;
}
let bytes: *const i8 = msg_send![s, UTF8String];
if bytes.is_null() {
return None;
}
std::ffi::CStr::from_ptr(bytes)
.to_str()
.ok()
.map(|x| x.to_owned())
}
#[cfg(target_os = "macos")]
unsafe fn general_pasteboard() -> Result<Id, String> {
let pb: Id = msg_send![class!(NSPasteboard), generalPasteboard];
if pb.is_null() {
return Err("NSPasteboard generalPasteboard returned nil".into());
}
Ok(pb)
}
/// Read the pasteboard's current change count without snapshotting contents.
///
/// AppKit increments this every time any process writes to the general
/// pasteboard, so it's a cheap way to detect "did someone clobber my staged
/// text before the paste landed?".
#[cfg(target_os = "macos")]
pub fn current_change_count() -> Result<i64, String> {
unsafe {
let _pool = AutoreleasePool::new();
let pb = general_pasteboard()?;
let c: i64 = msg_send![pb, changeCount];
Ok(c)
}
}
/// Capture every item on the general pasteboard into an owned snapshot.
#[cfg(target_os = "macos")]
pub fn save_clipboard() -> Result<ClipboardSnapshot, String> {
unsafe {
let _pool = AutoreleasePool::new();
let pb = general_pasteboard()?;
let change_count: i64 = msg_send![pb, changeCount];
let items: Id = msg_send![pb, pasteboardItems];
if items.is_null() {
return Ok(ClipboardSnapshot {
items: Vec::new(),
change_count,
});
}
let count: usize = msg_send![items, count];
let mut saved: Vec<Vec<(String, Vec<u8>)>> = Vec::with_capacity(count);
for i in 0..count {
let item: Id = msg_send![items, objectAtIndex: i];
if item.is_null() {
continue;
}
let types: Id = msg_send![item, types];
if types.is_null() {
continue;
}
let type_count: usize = msg_send![types, count];
let mut pairs: Vec<(String, Vec<u8>)> = Vec::with_capacity(type_count);
for j in 0..type_count {
let t: Id = msg_send![types, objectAtIndex: j];
let Some(type_str) = ns_string_to_rust(t) else {
continue;
};
let data: Id = msg_send![item, dataForType: t];
if data.is_null() {
// Type advertised but no concrete data (lazy provider).
// Skipping is safer than trying to force it to materialise.
continue;
}
let length: usize = msg_send![data, length];
let bytes_ptr: *const u8 = msg_send![data, bytes];
let bytes = if bytes_ptr.is_null() || length == 0 {
Vec::new()
} else {
std::slice::from_raw_parts(bytes_ptr, length).to_vec()
};
pairs.push((type_str, bytes));
}
saved.push(pairs);
}
Ok(ClipboardSnapshot {
items: saved,
change_count,
})
}
}
/// Replace the pasteboard contents with a single plain-text string. Returns
/// the post-write change count so a later restore can verify nothing else
/// touched the clipboard in between.
#[cfg(target_os = "macos")]
pub fn write_text(text: &str) -> Result<i64, String> {
unsafe {
let _pool = AutoreleasePool::new();
let pb = general_pasteboard()?;
let _new_count: i64 = msg_send![pb, clearContents];
let ns_text = ns_string(text);
// `public.utf8-plain-text` is the raw UTI behind `NSPasteboardTypeString`
// and works for every text-aware paste target we care about.
let ns_type = ns_string("public.utf8-plain-text");
let ok: bool = msg_send![pb, setString: ns_text forType: ns_type];
if !ok {
return Err("NSPasteboard setString:forType: returned NO".into());
}
let after: i64 = msg_send![pb, changeCount];
Ok(after)
}
}
/// Rebuild the pasteboard from a snapshot, replacing whatever is on it now.
///
/// Does not consult the change count — callers that want safe restore should
/// compare [`current_change_count`] against the value returned by
/// [`write_text`] first.
#[cfg(target_os = "macos")]
pub fn restore_clipboard(snapshot: &ClipboardSnapshot) -> Result<(), String> {
unsafe {
let _pool = AutoreleasePool::new();
let pb = general_pasteboard()?;
let _: i64 = msg_send![pb, clearContents];
if snapshot.items.is_empty() {
return Ok(());
}
let array: Id = msg_send![class!(NSMutableArray), array];
for pairs in &snapshot.items {
let item: Id = msg_send![class!(NSPasteboardItem), alloc];
let item: Id = msg_send![item, init];
let _: () = msg_send![item, autorelease];
for (uti, bytes) in pairs {
let ns_type = ns_string(uti);
let data: Id = msg_send![
class!(NSData),
dataWithBytes: bytes.as_ptr()
length: bytes.len()
];
let _ok: bool = msg_send![item, setData: data forType: ns_type];
}
let _: () = msg_send![array, addObject: item];
}
let ok: bool = msg_send![pb, writeObjects: array];
if !ok {
return Err("NSPasteboard writeObjects: returned NO".into());
}
Ok(())
}
}
#[cfg(target_os = "windows")]
mod win {
//! Windows clipboard implementation.
//!
//! The snapshot is structured so it mirrors the macOS `Vec<Vec<_>>`
//! shape: a single outer "item" holding one `(format-name, bytes)`
//! pair per enumerated format. Windows has no notion of multiple
//! pasteboard items, so there's always exactly one or zero outer
//! entries — enough to keep `item_count()` meaningful without
//! fan-out.
//!
//! Format IDs are serialised as strings so the snapshot type can stay
//! platform-neutral. Predefined formats use their canonical
//! identifier (`"CF_UNICODETEXT"`, `"CF_HDROP"`, `"CF_DIB"`, …);
//! registered formats use their string name from
//! `GetClipboardFormatNameW` (`"HTML Format"`, `"Rich Text
//! Format"`, …). Restore reverses the mapping with a lookup table
//! for the predefined IDs and `RegisterClipboardFormatW` for the
//! rest.
//!
//! Skipped format classes:
//! - CF_BITMAP (2), CF_METAFILEPICT (3), CF_PALETTE (9),
//! CF_ENHMETAFILE (14) — HGLOBAL's actually an HBITMAP /
//! HENHMETAFILE, not raw memory. Rebuilding them across processes
//! is possible but not worth it for clipboard stashing.
//! - CF_OWNERDISPLAY (0x80) and the CF_DSPxxx variants (0x810x8E) —
//! the owner draws these on demand. No data to snapshot.
//! - CF_PRIVATEFIRST..CF_PRIVATELAST (0x2000x2FF) — app-private,
//! meaningless to restore from a different process.
//! - CF_GDIOBJFIRST..CF_GDIOBJLAST (0x3000x3FF) — GDI handles.
//!
//! Text formats that Windows auto-synthesises (CF_TEXT, CF_OEMTEXT,
//! CF_LOCALE) are also skipped during save: `SetClipboardData` on
//! CF_UNICODETEXT regenerates them lazily on restore.
use std::thread;
use std::time::Duration;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{GlobalFree, HANDLE, HGLOBAL, HWND};
use windows::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, EnumClipboardFormats, GetClipboardData,
GetClipboardFormatNameW, GetClipboardSequenceNumber, OpenClipboard,
RegisterClipboardFormatW, SetClipboardData,
};
use windows::Win32::System::Memory::{
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GLOBAL_ALLOC_FLAGS,
};
// `windows` 0.62 doesn't re-export every predefined clipboard format
// under a stable feature flag, so the values are pinned inline.
// These numbers are ABI-stable back to Windows 3.1 — verified against
// winuser.h.
pub const CF_TEXT: u32 = 1;
pub const CF_BITMAP: u32 = 2;
pub const CF_METAFILEPICT: u32 = 3;
pub const CF_SYLK: u32 = 4;
pub const CF_DIF: u32 = 5;
pub const CF_TIFF: u32 = 6;
pub const CF_OEMTEXT: u32 = 7;
pub const CF_DIB: u32 = 8;
pub const CF_PALETTE: u32 = 9;
pub const CF_PENDATA: u32 = 10;
pub const CF_RIFF: u32 = 11;
pub const CF_WAVE: u32 = 12;
pub const CF_UNICODETEXT: u32 = 13;
pub const CF_ENHMETAFILE: u32 = 14;
pub const CF_HDROP: u32 = 15;
pub const CF_LOCALE: u32 = 16;
pub const CF_DIBV5: u32 = 17;
pub const CF_OWNERDISPLAY: u32 = 0x0080;
pub const CF_DSPTEXT: u32 = 0x0081;
pub const CF_DSPBITMAP: u32 = 0x0082;
pub const CF_DSPMETAFILEPICT: u32 = 0x0083;
pub const CF_DSPENHMETAFILE: u32 = 0x008E;
pub const CF_PRIVATEFIRST: u32 = 0x0200;
pub const CF_PRIVATELAST: u32 = 0x02FF;
pub const CF_GDIOBJFIRST: u32 = 0x0300;
pub const CF_GDIOBJLAST: u32 = 0x03FF;
/// `GlobalAlloc` movable-memory flag — `GMEM_MOVEABLE` (0x0002).
/// Required for HGLOBAL handles destined for `SetClipboardData`; fixed
/// allocations are rejected.
const GMEM_MOVEABLE: GLOBAL_ALLOC_FLAGS = GLOBAL_ALLOC_FLAGS(0x0002);
/// Map a predefined clipboard format ID to its canonical identifier
/// string. Registered formats (IDs >= 0xC000) aren't handled here —
/// the caller resolves those via `GetClipboardFormatNameW`.
pub fn predefined_name(id: u32) -> Option<&'static str> {
Some(match id {
CF_TEXT => "CF_TEXT",
CF_BITMAP => "CF_BITMAP",
CF_METAFILEPICT => "CF_METAFILEPICT",
CF_SYLK => "CF_SYLK",
CF_DIF => "CF_DIF",
CF_TIFF => "CF_TIFF",
CF_OEMTEXT => "CF_OEMTEXT",
CF_DIB => "CF_DIB",
CF_PALETTE => "CF_PALETTE",
CF_PENDATA => "CF_PENDATA",
CF_RIFF => "CF_RIFF",
CF_WAVE => "CF_WAVE",
CF_UNICODETEXT => "CF_UNICODETEXT",
CF_ENHMETAFILE => "CF_ENHMETAFILE",
CF_HDROP => "CF_HDROP",
CF_LOCALE => "CF_LOCALE",
CF_DIBV5 => "CF_DIBV5",
CF_OWNERDISPLAY => "CF_OWNERDISPLAY",
CF_DSPTEXT => "CF_DSPTEXT",
CF_DSPBITMAP => "CF_DSPBITMAP",
CF_DSPMETAFILEPICT => "CF_DSPMETAFILEPICT",
CF_DSPENHMETAFILE => "CF_DSPENHMETAFILE",
_ => return None,
})
}
/// Reverse of [`predefined_name`].
pub fn predefined_id(name: &str) -> Option<u32> {
Some(match name {
"CF_TEXT" => CF_TEXT,
"CF_BITMAP" => CF_BITMAP,
"CF_METAFILEPICT" => CF_METAFILEPICT,
"CF_SYLK" => CF_SYLK,
"CF_DIF" => CF_DIF,
"CF_TIFF" => CF_TIFF,
"CF_OEMTEXT" => CF_OEMTEXT,
"CF_DIB" => CF_DIB,
"CF_PALETTE" => CF_PALETTE,
"CF_PENDATA" => CF_PENDATA,
"CF_RIFF" => CF_RIFF,
"CF_WAVE" => CF_WAVE,
"CF_UNICODETEXT" => CF_UNICODETEXT,
"CF_ENHMETAFILE" => CF_ENHMETAFILE,
"CF_HDROP" => CF_HDROP,
"CF_LOCALE" => CF_LOCALE,
"CF_DIBV5" => CF_DIBV5,
"CF_OWNERDISPLAY" => CF_OWNERDISPLAY,
"CF_DSPTEXT" => CF_DSPTEXT,
"CF_DSPBITMAP" => CF_DSPBITMAP,
"CF_DSPMETAFILEPICT" => CF_DSPMETAFILEPICT,
"CF_DSPENHMETAFILE" => CF_DSPENHMETAFILE,
_ => return None,
})
}
/// Returns true for predefined formats whose payload is a GDI handle
/// or owner-display sentinel rather than plain memory — callers must
/// skip these during snapshot because GlobalSize/GlobalLock wouldn't
/// return usable bytes.
pub fn is_skipped_format(id: u32) -> bool {
matches!(
id,
CF_BITMAP
| CF_METAFILEPICT
| CF_PALETTE
| CF_ENHMETAFILE
| CF_OWNERDISPLAY
| CF_DSPTEXT
| CF_DSPBITMAP
| CF_DSPMETAFILEPICT
| CF_DSPENHMETAFILE
) || (CF_PRIVATEFIRST..=CF_PRIVATELAST).contains(&id)
|| (CF_GDIOBJFIRST..=CF_GDIOBJLAST).contains(&id)
}
/// Auto-synthesised formats that Windows regenerates from
/// CF_UNICODETEXT on demand. Safe to skip during save; restore
/// lets `SetClipboardData(CF_UNICODETEXT)` re-derive them.
pub fn is_auto_synthesised(id: u32) -> bool {
matches!(id, CF_TEXT | CF_OEMTEXT | CF_LOCALE)
}
/// RAII wrapper around `OpenClipboard` / `CloseClipboard`.
///
/// The clipboard is a global exclusive resource — only one process at
/// a time holds the handle. `OpenClipboard` fails with
/// ERROR_ACCESS_DENIED when another process is mid-paste; the retry
/// loop here absorbs the common transient case without bubbling a
/// user-visible error.
pub struct ClipboardGuard;
impl ClipboardGuard {
pub fn open() -> Result<Self, String> {
const MAX_ATTEMPTS: usize = 10;
const RETRY_DELAY: Duration = Duration::from_millis(10);
let mut last_err: Option<windows::core::Error> = None;
for _ in 0..MAX_ATTEMPTS {
let result = unsafe { OpenClipboard(Some(HWND(std::ptr::null_mut()))) };
match result {
Ok(()) => return Ok(Self),
Err(e) => {
last_err = Some(e);
thread::sleep(RETRY_DELAY);
}
}
}
Err(format!(
"OpenClipboard failed after {} retries ({:?}). Another process likely holds the clipboard open.",
MAX_ATTEMPTS, last_err
))
}
}
impl Drop for ClipboardGuard {
fn drop(&mut self) {
unsafe {
let _ = CloseClipboard();
}
}
}
/// Read the full payload for `format` from the currently open
/// clipboard into an owned `Vec<u8>`. Returns `Ok(None)` when the
/// clipboard advertises the format but provides no concrete data
/// (delay-rendered format that's never been realised).
pub fn read_format_bytes(format: u32) -> Result<Option<Vec<u8>>, String> {
unsafe {
let handle = GetClipboardData(format)
.map_err(|e| format!("GetClipboardData({format}) failed: {e}"))?;
if handle.is_invalid() {
return Ok(None);
}
let hglobal = HGLOBAL(handle.0);
let size = GlobalSize(hglobal);
if size == 0 {
return Ok(Some(Vec::new()));
}
let ptr = GlobalLock(hglobal);
if ptr.is_null() {
return Err(format!(
"GlobalLock returned null for format {format} (size {size})"
));
}
let bytes = std::slice::from_raw_parts(ptr as *const u8, size).to_vec();
let _ = GlobalUnlock(hglobal);
Ok(Some(bytes))
}
}
/// Look up the name for a registered format ID (>= 0xC000). Returns
/// `None` for unnamed predefined IDs — the caller should have used
/// [`predefined_name`] first.
pub fn registered_name(id: u32) -> Option<String> {
let mut buf = [0u16; 256];
let len = unsafe { GetClipboardFormatNameW(id, &mut buf) };
if len <= 0 {
return None;
}
String::from_utf16(&buf[..len as usize]).ok()
}
/// Allocate a movable HGLOBAL, copy `bytes` in, return the handle
/// ready for `SetClipboardData`. On success ownership transfers to
/// the clipboard; on failure the caller must `GlobalFree`.
pub fn allocate_global(bytes: &[u8]) -> Result<HGLOBAL, String> {
if bytes.is_empty() {
// `GlobalAlloc(_, 0)` returns NULL, which `SetClipboardData`
// would then reject as an invalid handle. Pad to one byte so
// the format still round-trips (the receiving app already
// has to handle zero-content payloads via GlobalSize).
return allocate_global(&[0u8]);
}
unsafe {
let hglobal = GlobalAlloc(GMEM_MOVEABLE, bytes.len())
.map_err(|e| format!("GlobalAlloc({}) failed: {e}", bytes.len()))?;
let ptr = GlobalLock(hglobal);
if ptr.is_null() {
let _ = GlobalFree(Some(hglobal));
return Err("GlobalLock returned null after GlobalAlloc".into());
}
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr as *mut u8, bytes.len());
let _ = GlobalUnlock(hglobal);
Ok(hglobal)
}
}
/// Push one format's payload onto the currently open clipboard.
/// On `SetClipboardData` success the HGLOBAL becomes the clipboard's
/// responsibility — do not free. On failure, free it ourselves.
pub fn put_format(format: u32, bytes: &[u8]) -> Result<(), String> {
let hglobal = allocate_global(bytes)?;
let handle = HANDLE(hglobal.0);
unsafe {
match SetClipboardData(format, Some(handle)) {
Ok(_) => Ok(()),
Err(e) => {
let _ = GlobalFree(Some(hglobal));
Err(format!("SetClipboardData({format}) failed: {e}"))
}
}
}
}
/// UTF-16 encode `s` with a trailing null code unit and push it as
/// CF_UNICODETEXT.
pub fn put_unicode_text(s: &str) -> Result<(), String> {
let mut utf16: Vec<u16> = s.encode_utf16().collect();
utf16.push(0);
let bytes: &[u8] = unsafe {
std::slice::from_raw_parts(
utf16.as_ptr() as *const u8,
utf16.len() * std::mem::size_of::<u16>(),
)
};
put_format(CF_UNICODETEXT, bytes)
}
/// Walk every format currently on the clipboard. `EnumClipboardFormats(0)`
/// returns the first; each subsequent call with the previous format
/// returns the next, until it returns 0 (or an error).
pub fn enumerate_formats() -> Vec<u32> {
let mut out = Vec::new();
let mut current = 0u32;
loop {
let next = unsafe { EnumClipboardFormats(current) };
if next == 0 {
break;
}
out.push(next);
current = next;
}
out
}
/// Resolve a snapshot's format name back to the u32 format ID.
/// Registered names (anything not predefined) go through
/// `RegisterClipboardFormatW`, which is idempotent — the same name
/// yields the same ID within a Windows session.
pub fn resolve_format_id(name: &str) -> Result<u32, String> {
if let Some(id) = predefined_id(name) {
return Ok(id);
}
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
let id = unsafe { RegisterClipboardFormatW(PCWSTR(wide.as_ptr())) };
if id == 0 {
return Err(format!("RegisterClipboardFormatW failed for {name:?}"));
}
Ok(id)
}
pub fn sequence_number() -> u32 {
unsafe { GetClipboardSequenceNumber() }
}
pub fn empty() -> Result<(), String> {
unsafe { EmptyClipboard().map_err(|e| format!("EmptyClipboard failed: {e}")) }
}
}
#[cfg(target_os = "windows")]
pub fn current_change_count() -> Result<i64, String> {
Ok(win::sequence_number() as i64)
}
#[cfg(target_os = "windows")]
pub fn save_clipboard() -> Result<ClipboardSnapshot, String> {
let change_count = win::sequence_number() as i64;
let _guard = win::ClipboardGuard::open()?;
let formats = win::enumerate_formats();
let mut pairs: Vec<(String, Vec<u8>)> = Vec::with_capacity(formats.len());
for id in formats {
if win::is_skipped_format(id) || win::is_auto_synthesised(id) {
continue;
}
let name = match win::predefined_name(id) {
Some(n) => n.to_string(),
None => match win::registered_name(id) {
Some(n) => n,
None => continue,
},
};
match win::read_format_bytes(id) {
Ok(Some(bytes)) => pairs.push((name, bytes)),
Ok(None) => {}
Err(_) => {
// Single-format read failure (delay-render that never
// materialises, ACL-restricted format, etc.) shouldn't
// abort the whole snapshot — drop this format and keep
// going so the user's other clipboard contents still
// survive the round-trip.
continue;
}
}
}
let items = if pairs.is_empty() {
Vec::new()
} else {
vec![pairs]
};
Ok(ClipboardSnapshot {
items,
change_count,
})
}
#[cfg(target_os = "windows")]
pub fn write_text(text: &str) -> Result<i64, String> {
let _guard = win::ClipboardGuard::open()?;
win::empty()?;
win::put_unicode_text(text)?;
// `GetClipboardSequenceNumber` reflects the post-write value as soon
// as `SetClipboardData` returns.
Ok(win::sequence_number() as i64)
}
#[cfg(target_os = "windows")]
pub fn restore_clipboard(snapshot: &ClipboardSnapshot) -> Result<(), String> {
let _guard = win::ClipboardGuard::open()?;
win::empty()?;
for pairs in &snapshot.items {
for (name, bytes) in pairs {
let id = match win::resolve_format_id(name) {
Ok(id) => id,
Err(_) => continue,
};
// Per-format failures here also don't abort the whole
// restore — better to get the user's text content back even
// if a weird custom format can't be rehydrated.
let _ = win::put_format(id, bytes);
}
}
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn current_change_count() -> Result<i64, String> {
Err("clipboard snapshot is not yet implemented on this platform".into())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn save_clipboard() -> Result<ClipboardSnapshot, String> {
Err("clipboard snapshot is not yet implemented on this platform".into())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn write_text(_text: &str) -> Result<i64, String> {
Err("clipboard snapshot is not yet implemented on this platform".into())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn restore_clipboard(_snapshot: &ClipboardSnapshot) -> Result<(), String> {
Err("clipboard snapshot is not yet implemented on this platform".into())
}
+480
View File
@@ -0,0 +1,480 @@
//! Captures the focused-UI snapshot at chord-start so auto-paste can land
//! in the user's original text field even after focus drifts during
//! transcription / refinement.
//!
//! We don't try to re-focus a specific sub-element on restore — many apps
//! expose complex focus hierarchies that don't respond consistently to
//! programmatic focus pokes. Bringing the owning *window* to the
//! foreground is enough: the window's own focus manager restores its
//! last-focused field, which is what every well-behaved paste-buffer tool
//! does and what users expect.
//!
//! - **macOS** — `AXUIElementCopyAttributeValue(kAXFocusedUIElement)` +
//! `AXUIElementGetPid` + `NSRunningApplication.activateWithOptions:`.
//! - **Windows** — `GetForegroundWindow` + `GetWindowThreadProcessId` for
//! the top-level HWND and PID; UIAutomation's `IUIAutomation::GetFocusedElement`
//! for best-effort control-class (skipped silently if COM isn't usable).
//! Activation walks top-level windows for the saved PID and calls
//! `SetForegroundWindow`, bracketed by the `AttachThreadInput` dance
//! so Windows' foreground-lock rules don't silently swallow the
//! activation into a taskbar flash.
//!
//! PID + bundle id + role are all captured for diagnostics — the bundle
//! id lets step 6 (internal direct injection) detect "focus was inside
//! Voicebox itself" and short-circuit the synthetic-paste path. On
//! Windows, `bundle_id` holds the lowercased exe basename (`"voicebox.exe"`)
//! since there's no equivalent of macOS' reverse-DNS bundle identifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FocusSnapshot {
pub pid: i32,
pub bundle_id: Option<String>,
pub role: Option<String>,
}
#[cfg(target_os = "macos")]
use core_foundation_sys::base::{kCFAllocatorDefault, CFRelease};
#[cfg(target_os = "macos")]
use core_foundation_sys::string::{
kCFStringEncodingUTF8, CFStringCreateWithCString, CFStringGetCString, CFStringGetLength,
CFStringRef,
};
#[cfg(target_os = "macos")]
use objc::runtime::Object;
#[cfg(target_os = "macos")]
use objc::{class, msg_send, sel, sel_impl};
#[cfg(target_os = "macos")]
type Id = *mut Object;
#[cfg(target_os = "macos")]
mod ffi {
use core_foundation_sys::base::CFTypeRef;
use core_foundation_sys::string::CFStringRef;
pub type AXError = i32;
pub const AX_ERROR_SUCCESS: AXError = 0;
pub type AXUIElementRef = *const std::ffi::c_void;
pub type Pid = i32;
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
pub fn AXUIElementCreateSystemWide() -> AXUIElementRef;
pub fn AXUIElementCopyAttributeValue(
element: AXUIElementRef,
attribute: CFStringRef,
value: *mut CFTypeRef,
) -> AXError;
pub fn AXUIElementGetPid(element: AXUIElementRef, pid: *mut Pid) -> AXError;
}
// AX attribute keys are exposed as C macros that expand to CFSTR(...)
// literals, not as linkable symbols — build the CFStrings at runtime
// instead (see `cf_string_const` in focus_capture.rs).
}
#[cfg(target_os = "macos")]
struct AutoreleasePool {
pool: Id,
}
#[cfg(target_os = "macos")]
impl AutoreleasePool {
unsafe fn new() -> Self {
let pool: Id = msg_send![class!(NSAutoreleasePool), alloc];
let pool: Id = msg_send![pool, init];
Self { pool }
}
}
#[cfg(target_os = "macos")]
impl Drop for AutoreleasePool {
fn drop(&mut self) {
unsafe {
let _: () = msg_send![self.pool, drain];
}
}
}
#[cfg(target_os = "macos")]
unsafe fn ns_string_to_rust(s: Id) -> Option<String> {
if s.is_null() {
return None;
}
let bytes: *const i8 = msg_send![s, UTF8String];
if bytes.is_null() {
return None;
}
std::ffi::CStr::from_ptr(bytes)
.to_str()
.ok()
.map(|x| x.to_owned())
}
/// Build a `+1` retained CFString from an ASCII constant. Caller owns the
/// returned reference and must `CFRelease` it. Used for AX attribute keys
/// (`"AXFocusedUIElement"`, `"AXRole"`) because those aren't exported as
/// linker symbols — Apple ships them as `CFSTR(...)` macros.
#[cfg(target_os = "macos")]
unsafe fn cf_string_const(s: &str) -> Option<CFStringRef> {
let cstr = std::ffi::CString::new(s).ok()?;
let result = CFStringCreateWithCString(kCFAllocatorDefault, cstr.as_ptr(), kCFStringEncodingUTF8);
if result.is_null() {
None
} else {
Some(result)
}
}
#[cfg(target_os = "macos")]
unsafe fn cfstring_to_rust(s: CFStringRef) -> Option<String> {
if s.is_null() {
return None;
}
let len = CFStringGetLength(s);
if len == 0 {
return Some(String::new());
}
// CFStringGetLength is in UTF-16 code units; UTF-8 can need up to 4
// bytes per unit plus the trailing NUL.
let max_bytes = (len * 4 + 1) as usize;
let mut buf = vec![0u8; max_bytes];
let ok = CFStringGetCString(
s,
buf.as_mut_ptr() as *mut i8,
max_bytes as isize,
kCFStringEncodingUTF8,
);
if ok == 0 {
return None;
}
let cstr = std::ffi::CStr::from_ptr(buf.as_ptr() as *const i8);
cstr.to_str().ok().map(|x| x.to_owned())
}
#[cfg(target_os = "macos")]
unsafe fn bundle_id_for_pid(pid: i32) -> Option<String> {
let _pool = AutoreleasePool::new();
let app: Id = msg_send![
class!(NSRunningApplication),
runningApplicationWithProcessIdentifier: pid
];
if app.is_null() {
return None;
}
let bundle: Id = msg_send![app, bundleIdentifier];
ns_string_to_rust(bundle)
}
/// Read the system-wide focused UI element's PID, bundle id, and AX role.
///
/// Returns an error when no element is focused (e.g. Dock has focus) or
/// when Accessibility permission is missing — `AXUIElementCopyAttributeValue`
/// returns `-25204 kAXErrorAPIDisabled` in that case.
#[cfg(target_os = "macos")]
pub fn capture_focus() -> Result<FocusSnapshot, String> {
use ffi::*;
unsafe {
let system_wide = AXUIElementCreateSystemWide();
if system_wide.is_null() {
return Err("AXUIElementCreateSystemWide returned null".into());
}
let _sys_guard = scopeguard::guard(system_wide, |e| {
CFRelease(e as *const std::ffi::c_void)
});
let focused_attr = cf_string_const("AXFocusedUIElement")
.ok_or("Failed to build AXFocusedUIElement CFString")?;
let _focused_attr_guard =
scopeguard::guard(focused_attr, |s| CFRelease(s as *const std::ffi::c_void));
let mut focused: *const std::ffi::c_void = std::ptr::null();
let err = AXUIElementCopyAttributeValue(
system_wide,
focused_attr,
&mut focused as *mut _,
);
if err != AX_ERROR_SUCCESS || focused.is_null() {
return Err(format!(
"No focused element (AXError {}). Verify Accessibility permission is granted and a focused text field exists.",
err
));
}
let _focus_guard = scopeguard::guard(focused, |e| CFRelease(e));
let focused_elem = focused as AXUIElementRef;
let mut pid: Pid = 0;
let err = AXUIElementGetPid(focused_elem, &mut pid);
if err != AX_ERROR_SUCCESS {
return Err(format!("AXUIElementGetPid failed (AXError {})", err));
}
let role = {
let role_attr = cf_string_const("AXRole");
match role_attr {
Some(role_attr) => {
let _role_attr_guard = scopeguard::guard(role_attr, |s| {
CFRelease(s as *const std::ffi::c_void)
});
let mut role_value: *const std::ffi::c_void = std::ptr::null();
let err = AXUIElementCopyAttributeValue(
focused_elem,
role_attr,
&mut role_value as *mut _,
);
if err == AX_ERROR_SUCCESS && !role_value.is_null() {
let _role_guard = scopeguard::guard(role_value, |e| CFRelease(e));
cfstring_to_rust(role_value as CFStringRef)
} else {
None
}
}
None => None,
}
};
let bundle_id = bundle_id_for_pid(pid);
Ok(FocusSnapshot {
pid,
bundle_id,
role,
})
}
}
/// Bring the app owning `pid` to the foreground, re-activating its
/// last-focused window. Paired with [`capture_focus`] at chord-start so a
/// post-transcription synthetic ⌘V lands where the user started, not
/// wherever focus drifted to during the transcribe / refine window.
#[cfg(target_os = "macos")]
pub fn activate_pid(pid: i32) -> Result<(), String> {
unsafe {
let _pool = AutoreleasePool::new();
let app: Id = msg_send![
class!(NSRunningApplication),
runningApplicationWithProcessIdentifier: pid
];
if app.is_null() {
return Err(format!("No running application for PID {}", pid));
}
// NSApplicationActivateIgnoringOtherApps = 1 << 1 = 2.
//
// macOS 14 deprecated this in favour of `activate()` but kept it
// functional when the caller has Accessibility permission — which
// we require for the paste event anyway.
let _: bool = msg_send![app, activateWithOptions: 2u64];
Ok(())
}
}
#[cfg(target_os = "windows")]
mod win {
use std::path::Path;
use windows::core::{IUnknown, BSTR, PWSTR};
use windows::Win32::Foundation::{CloseHandle, BOOL, HWND, LPARAM};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED,
};
use windows::Win32::System::Threading::{
AttachThreadInput, GetCurrentThreadId, OpenProcess, QueryFullProcessImageNameW,
PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION,
};
use windows::Win32::UI::Accessibility::{CUIAutomation, IUIAutomation, IUIAutomationElement};
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetForegroundWindow, GetWindow, GetWindowThreadProcessId, IsWindowVisible,
SetForegroundWindow, GW_OWNER,
};
/// Read the PID that owns `hwnd`. Returns 0 on failure.
pub unsafe fn hwnd_pid(hwnd: HWND) -> u32 {
let mut pid: u32 = 0;
let _ = GetWindowThreadProcessId(hwnd, Some(&mut pid as *mut _));
pid
}
/// Query a PID's executable path and return its lowercased basename
/// (e.g. `"voicebox.exe"`). This is the Windows analogue of macOS'
/// `bundleIdentifier`, just less globally unique — two apps with the
/// same exe name can collide, but that's rare enough to accept for
/// the self-paste short-circuit.
pub fn exe_basename(pid: u32) -> Option<String> {
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
let mut buf = [0u16; 1024];
let mut size = buf.len() as u32;
let ok = QueryFullProcessImageNameW(
handle,
PROCESS_NAME_FORMAT(0),
PWSTR(buf.as_mut_ptr()),
&mut size,
);
let _ = CloseHandle(handle);
if ok.is_err() || size == 0 {
return None;
}
let full = String::from_utf16(&buf[..size as usize]).ok()?;
let basename = Path::new(&full)
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase())?;
Some(basename)
}
}
/// Best-effort `UIAutomation::GetFocusedElement().CurrentClassName()`.
/// Returns `None` when COM init, CoCreateInstance, or any UIA call
/// fails — role info is nice-to-have, not load-bearing for paste.
pub fn focused_control_class() -> Option<String> {
unsafe {
// MTA per-thread init. Ignore HRESULT: S_OK / S_FALSE /
// RPC_E_CHANGED_MODE are all benign for our uses here, and
// we deliberately never call CoUninitialize (the Tauri
// runtime thread lives for the life of the process, so
// leaving COM init in place is fine).
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
let automation: IUIAutomation =
CoCreateInstance(&CUIAutomation, None::<&IUnknown>, CLSCTX_INPROC_SERVER).ok()?;
let element: IUIAutomationElement = automation.GetFocusedElement().ok()?;
// UIAutomationElement's CurrentClassName allocates a BSTR
// the caller has to drop. `BSTR` in `windows` crate is a
// Drop-wrapped owned string, so just returning `.to_string()`
// is safe.
let class: BSTR = element.CurrentClassName().ok()?;
let s = class.to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
}
/// Find a visible top-level window owned by `pid`. Returns the first
/// match via `EnumWindows`. Top-level ≡ no owner window.
pub fn find_top_level_window(pid: u32) -> Option<HWND> {
struct Ctx {
target_pid: u32,
found: Option<HWND>,
}
let mut ctx = Ctx {
target_pid: pid,
found: None,
};
unsafe extern "system" fn callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let ctx = &mut *(lparam.0 as *mut Ctx);
if hwnd_pid(hwnd) != ctx.target_pid {
return BOOL(1);
}
// Skip tool windows / invisible shells. `GetWindow(GW_OWNER)`
// is non-null for modal dialogs and other secondary windows;
// we want the real app frame, which has no owner.
if !IsWindowVisible(hwnd).as_bool() {
return BOOL(1);
}
if !GetWindow(hwnd, GW_OWNER).unwrap_or(HWND(std::ptr::null_mut())).is_invalid() {
return BOOL(1);
}
ctx.found = Some(hwnd);
BOOL(0)
}
unsafe {
let _ = EnumWindows(
Some(callback),
LPARAM(&mut ctx as *mut _ as isize),
);
}
ctx.found
}
/// Bring `hwnd` to the foreground reliably.
///
/// Plain `SetForegroundWindow` loses to Windows' foreground-lock
/// rules — when our process isn't already foreground it can't hand
/// focus to another app. The documented workaround is to attach the
/// current thread's input queue to the current foreground window's
/// thread for the duration of the call, which temporarily lets us
/// share that thread's "last user activity" stamp.
pub fn activate_hwnd(hwnd: HWND) -> Result<(), String> {
unsafe {
let fg = GetForegroundWindow();
if fg == hwnd {
return Ok(());
}
let our_thread = GetCurrentThreadId();
let fg_thread = if fg.is_invalid() {
0
} else {
let mut _pid: u32 = 0;
GetWindowThreadProcessId(fg, Some(&mut _pid as *mut _))
};
let attached = fg_thread != 0
&& fg_thread != our_thread
&& AttachThreadInput(our_thread, fg_thread, true).as_bool();
let ok = SetForegroundWindow(hwnd).as_bool();
if attached {
let _ = AttachThreadInput(our_thread, fg_thread, false);
}
if !ok {
return Err(format!(
"SetForegroundWindow failed for HWND {:?} — Windows foreground-lock may have denied the activation.",
hwnd.0
));
}
Ok(())
}
}
}
#[cfg(target_os = "windows")]
pub fn capture_focus() -> Result<FocusSnapshot, String> {
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_invalid() {
return Err(
"GetForegroundWindow returned null — the desktop has no focused window (secure attention sequence, lock screen, or no user session)."
.into(),
);
}
let pid = win::hwnd_pid(hwnd);
if pid == 0 {
return Err("GetWindowThreadProcessId returned PID 0 for the foreground window".into());
}
let bundle_id = win::exe_basename(pid);
let role = win::focused_control_class();
Ok(FocusSnapshot {
pid: pid as i32,
bundle_id,
role,
})
}
}
#[cfg(target_os = "windows")]
pub fn activate_pid(pid: i32) -> Result<(), String> {
if pid <= 0 {
return Err(format!("Cannot activate invalid PID {pid}"));
}
let hwnd = win::find_top_level_window(pid as u32)
.ok_or_else(|| format!("No visible top-level window for PID {pid}"))?;
win::activate_hwnd(hwnd)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn capture_focus() -> Result<FocusSnapshot, String> {
Err("focus capture is not yet implemented on this platform".into())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn activate_pid(_pid: i32) -> Result<(), String> {
Err("app activation is not yet implemented on this platform".into())
}
+383
View File
@@ -0,0 +1,383 @@
//! Global keyboard tap + chord dispatcher.
//!
//! Spawns a dedicated thread running `rdev::listen` (which internally owns a
//! CGEventTap on macOS / `SetWindowsHookEx` on Windows / `XRecord` on Linux).
//! Feeds raw key events into a private `Chord` state machine and translates
//! its effects into Tauri events + window show/hide calls.
//!
//! Left- and right-hand modifier variants are deliberately kept distinct.
//! Defaults bind to right-hand Cmd + right-hand Option so that the usual
//! left-hand shortcuts — Cmd+Option+I to open devtools, Cmd+Option+Esc for
//! force-quit, etc. — continue to work untouched.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::thread;
use rdev::{listen, EventType, Key};
use tauri::{AppHandle, Emitter, Manager};
use crate::focus_capture;
use crate::DICTATE_WINDOW_LABEL;
// ========================================================================
// Chord state machine
// ========================================================================
/// Semantic action a chord can be bound to. `PushToTalk` = hold chord to
/// record, release to stop. `ToggleToTalk` = press chord to start recording,
/// press again to stop.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChordAction {
PushToTalk,
ToggleToTalk,
}
/// Output of the chord state machine after consuming an input event. Hosts
/// translate these into UI / recorder calls.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Effect {
StartRecording(ChordAction),
StopRecording(ChordAction),
/// Emitted when a push-to-talk chord is "upgraded" into the toggle chord
/// mid-hold — hosts may want to discard the captured audio and restart
/// so the transition moment isn't in the recording.
RestartRecording(ChordAction),
}
#[derive(Debug, Clone)]
enum KeyEvent {
Down(Key),
Up(Key),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Match {
None,
Partial,
Hit(ChordAction),
}
pub type Bindings = HashMap<ChordAction, HashSet<Key>>;
/// Private state machine that turns key-down / key-up events into
/// `Effect`s. Owns no I/O — just the "which keys are held" and
/// "which action is currently driving a recording" bookkeeping.
struct Chord {
bindings: Bindings,
pressed_keys: HashSet<Key>,
active_recording_action: Option<ChordAction>,
}
impl Chord {
fn new(bindings: Bindings) -> Self {
Self {
bindings,
pressed_keys: HashSet::new(),
active_recording_action: None,
}
}
fn update_bindings(&mut self, bindings: Bindings) {
self.bindings = bindings;
}
fn handle(&mut self, event: KeyEvent) -> Vec<Effect> {
let changed = match event {
KeyEvent::Down(k) => self.pressed_keys.insert(k),
KeyEvent::Up(k) => self.pressed_keys.remove(&k),
};
if !changed {
return Vec::new();
}
self.step()
}
#[allow(dead_code)] // Used by the chord picker UI in Pass 2 to suspend matching during capture.
fn reset(&mut self) {
self.pressed_keys.clear();
self.active_recording_action = None;
}
fn step(&mut self) -> Vec<Effect> {
match self.active_recording_action {
Some(ChordAction::PushToTalk) => {
if self.classify() == Match::Hit(ChordAction::ToggleToTalk) {
self.active_recording_action = Some(ChordAction::ToggleToTalk);
return vec![Effect::RestartRecording(ChordAction::ToggleToTalk)];
}
let still_held = self
.bindings
.get(&ChordAction::PushToTalk)
.map(|chord| chord.is_subset(&self.pressed_keys))
.unwrap_or(false);
if !still_held {
self.active_recording_action = None;
return vec![Effect::StopRecording(ChordAction::PushToTalk)];
}
Vec::new()
}
Some(ChordAction::ToggleToTalk) => {
if self.classify() == Match::Hit(ChordAction::ToggleToTalk) {
self.active_recording_action = None;
return vec![Effect::StopRecording(ChordAction::ToggleToTalk)];
}
Vec::new()
}
None => match self.classify() {
Match::Hit(action) => {
self.active_recording_action = Some(action);
vec![Effect::StartRecording(action)]
}
Match::None | Match::Partial => Vec::new(),
},
}
}
fn classify(&self) -> Match {
if self.pressed_keys.is_empty() {
return Match::None;
}
// Exact match wins even if the pressed set is also a prefix of another
// binding.
for (action, chord) in &self.bindings {
if self.pressed_keys == *chord {
return Match::Hit(*action);
}
}
let is_prefix = self
.bindings
.values()
.any(|c| self.pressed_keys.is_subset(c) && self.pressed_keys != *c);
if is_prefix {
Match::Partial
} else {
Match::None
}
}
}
// ========================================================================
// Monitor
// ========================================================================
/// Hardcoded Pass 1 defaults. Two right-hand modifiers so the usual left-hand
/// shortcuts pass through unaffected. Replaced in Pass 2 by reading from the
/// server-side `capture_settings` table via a Tauri command the frontend
/// invokes whenever `useCaptureSettings` resolves.
///
/// - **macOS:** `MetaRight + AltGr` — right Command + right Option. (rdev
/// labels right-Option as `AltGr` for Linux-convention symmetry; on macOS
/// it's the physical right-option key.)
/// - **Windows / Linux:** `ControlRight + ShiftRight` — right Ctrl + right
/// Shift. Deliberately avoids `AltGr`: on international Windows layouts
/// the OS synthesises `AltGr` as `Ctrl+Alt`, so any `AltGr`-involving
/// default would fire on every `@`, `€`, `\` keypress on German / French
/// / Spanish keyboards.
pub fn default_bindings() -> Bindings {
#[cfg(target_os = "macos")]
let (m1, m2) = (Key::MetaRight, Key::AltGr);
#[cfg(not(target_os = "macos"))]
let (m1, m2) = (Key::ControlRight, Key::ShiftRight);
let mut b = Bindings::new();
b.insert(ChordAction::PushToTalk, {
let mut s = HashSet::new();
s.insert(m1);
s.insert(m2);
s
});
b.insert(ChordAction::ToggleToTalk, {
let mut s = HashSet::new();
s.insert(m1);
s.insert(m2);
s.insert(Key::Space);
s
});
b
}
pub struct HotkeyMonitor {
chord: Arc<Mutex<Chord>>,
}
impl HotkeyMonitor {
pub fn spawn(app: AppHandle, bindings: Bindings) -> Self {
let chord = Arc::new(Mutex::new(Chord::new(bindings)));
let chord_for_thread = chord.clone();
let app_for_thread = app.clone();
thread::spawn(move || {
// Without this call, rdev's convert() calls TSMGetInputSourceProperty
// on this background thread, which trips a main-queue assertion on
// macOS 14+ and traps the whole process (see Narsil/rdev#165 / #147).
#[cfg(target_os = "macos")]
rdev::set_is_main_thread(false);
let result = listen(move |event| {
let input = match event.event_type {
EventType::KeyPress(k) => KeyEvent::Down(k),
EventType::KeyRelease(k) => KeyEvent::Up(k),
_ => return,
};
let effects = match chord_for_thread.lock() {
Ok(mut chord) => chord.handle(input),
Err(_) => return,
};
for effect in effects {
apply_effect(&app_for_thread, effect);
}
});
if let Err(err) = result {
eprintln!(
"HotkeyMonitor: rdev::listen failed ({:?}). Global chord detection is disabled. On macOS, grant Input Monitoring in System Settings → Privacy & Security → Input Monitoring and relaunch.",
err
);
}
});
Self { chord }
}
pub fn update_bindings(&self, bindings: Bindings) {
if let Ok(mut chord) = self.chord.lock() {
chord.update_bindings(bindings);
}
}
}
fn apply_effect(app: &AppHandle, effect: Effect) {
match effect {
Effect::StartRecording(_) => {
// Snapshot focus BEFORE we touch the window — any AppKit
// reshuffle triggered by set_position / show could in principle
// steal key focus and poison the reading. In practice those
// calls leave keyWindow alone, but capturing first is free.
let focus = focus_capture::capture_focus().ok();
if let Some(window) = app.get_webview_window(DICTATE_WINDOW_LABEL) {
// The previous hide-cycle parked the window off-screen and
// made it click-through — undo both before showing, so the
// pill lands at top-center and the user can actually click
// the error pill / stop button.
//
// `current_monitor()` returns None when the window is off
// any display (our hide handler parks it at -10_000, -10_000
// precisely so it never intercepts clicks), so fall back to
// the primary monitor for the reposition.
let monitor = window
.current_monitor()
.ok()
.flatten()
.or_else(|| window.primary_monitor().ok().flatten());
if let Some(monitor) = monitor {
let monitor_pos = monitor.position();
let monitor_size = monitor.size();
if let Ok(win_size) = window.outer_size() {
let x = monitor_pos.x
+ (monitor_size.width as i32 - win_size.width as i32) / 2;
let y = monitor_pos.y + (monitor_size.height as f64 * 0.04) as i32;
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
}
}
let _ = window.set_ignore_cursor_events(false);
// Deliberately no set_focus() — taking key focus would yank
// it out of whatever app the user was typing in, which is
// the opposite of what a dictation overlay should do.
let _ = window.show();
let payload = serde_json::json!({ "focus": focus });
let _ = window.emit("dictate:start", payload);
}
}
Effect::StopRecording(_) => {
if let Some(window) = app.get_webview_window(DICTATE_WINDOW_LABEL) {
let _ = window.emit("dictate:stop", ());
}
}
Effect::RestartRecording(_) => {
if let Some(window) = app.get_webview_window(DICTATE_WINDOW_LABEL) {
let _ = window.emit("dictate:restart", ());
}
}
}
}
// ========================================================================
// Tests
// ========================================================================
#[cfg(test)]
mod tests {
use super::*;
fn keys(keys: &[Key]) -> HashSet<Key> {
keys.iter().copied().collect()
}
fn test_bindings() -> Bindings {
let mut b = Bindings::new();
b.insert(ChordAction::PushToTalk, keys(&[Key::MetaLeft, Key::Alt]));
b.insert(
ChordAction::ToggleToTalk,
keys(&[Key::MetaLeft, Key::Alt, Key::Space]),
);
b
}
#[test]
fn push_to_talk_starts_on_exact_hold_and_stops_on_release() {
let mut c = Chord::new(test_bindings());
assert_eq!(c.handle(KeyEvent::Down(Key::MetaLeft)), vec![]);
assert_eq!(
c.handle(KeyEvent::Down(Key::Alt)),
vec![Effect::StartRecording(ChordAction::PushToTalk)],
);
assert_eq!(
c.handle(KeyEvent::Up(Key::Alt)),
vec![Effect::StopRecording(ChordAction::PushToTalk)],
);
}
#[test]
fn toggle_starts_on_exact_and_stops_on_second_exact() {
let mut c = Chord::new(test_bindings());
c.handle(KeyEvent::Down(Key::MetaLeft));
c.handle(KeyEvent::Down(Key::Alt));
// At this point PTT is active.
assert_eq!(c.active_recording_action, Some(ChordAction::PushToTalk));
assert_eq!(
c.handle(KeyEvent::Down(Key::Space)),
vec![Effect::RestartRecording(ChordAction::ToggleToTalk)],
);
// Releasing cmd/opt must not stop toggle recording.
assert_eq!(c.handle(KeyEvent::Up(Key::MetaLeft)), vec![]);
assert_eq!(c.handle(KeyEvent::Up(Key::Alt)), vec![]);
assert_eq!(c.handle(KeyEvent::Up(Key::Space)), vec![]);
// Second press of toggle chord stops it.
c.handle(KeyEvent::Down(Key::MetaLeft));
c.handle(KeyEvent::Down(Key::Alt));
assert_eq!(
c.handle(KeyEvent::Down(Key::Space)),
vec![Effect::StopRecording(ChordAction::ToggleToTalk)],
);
}
#[test]
fn toggle_from_idle_starts_immediately_on_full_chord() {
let mut c = Chord::new(test_bindings());
c.handle(KeyEvent::Down(Key::MetaLeft));
c.handle(KeyEvent::Down(Key::Alt));
// Drop MetaLeft before Space — we're not in the exact toggle match
// yet, just prefix. No start for toggle.
assert_eq!(c.active_recording_action, Some(ChordAction::PushToTalk));
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Stable string ↔ `rdev::Key` mapping for chord persistence.
//!
//! The frontend captures keypresses through the browser keyboard API (which
//! exposes `event.code` like `"MetaRight"`, `"AltRight"`, `"Space"`, `"KeyA"`)
//! and stores chords in capture_settings as JSON arrays of canonical names.
//! On the way back the same names need to round-trip into `rdev::Key`
//! variants the chord engine actually matches against.
//!
//! Names follow the rdev variant identifiers exactly (`"MetaRight"`,
//! `"AltGr"`, `"KeyA"`, …) with one alias: the browser reports right-Option
//! as `"AltRight"` while rdev calls it `"AltGr"`. Both map to the same key.
use rdev::Key;
/// Resolve a canonical key name to its `rdev::Key`. Returns `None` for
/// names that don't have a corresponding variant — the command surface
/// rejects those so we never silently drop keys from a chord.
pub fn key_from_str(name: &str) -> Option<Key> {
Some(match name {
// Modifiers — left/right distinction matters for chord defaults.
"Alt" | "AltLeft" => Key::Alt,
"AltGr" | "AltRight" => Key::AltGr,
"ControlLeft" => Key::ControlLeft,
"ControlRight" => Key::ControlRight,
"MetaLeft" => Key::MetaLeft,
"MetaRight" => Key::MetaRight,
"ShiftLeft" => Key::ShiftLeft,
"ShiftRight" => Key::ShiftRight,
"CapsLock" => Key::CapsLock,
"Function" => Key::Function,
// Whitespace / navigation
"Space" => Key::Space,
"Tab" => Key::Tab,
"Return" | "Enter" => Key::Return,
"Backspace" => Key::Backspace,
"Delete" => Key::Delete,
"Escape" => Key::Escape,
"Insert" => Key::Insert,
"Home" => Key::Home,
"End" => Key::End,
"PageUp" => Key::PageUp,
"PageDown" => Key::PageDown,
"ArrowUp" | "UpArrow" => Key::UpArrow,
"ArrowDown" | "DownArrow" => Key::DownArrow,
"ArrowLeft" | "LeftArrow" => Key::LeftArrow,
"ArrowRight" | "RightArrow" => Key::RightArrow,
// Function row
"F1" => Key::F1, "F2" => Key::F2, "F3" => Key::F3, "F4" => Key::F4,
"F5" => Key::F5, "F6" => Key::F6, "F7" => Key::F7, "F8" => Key::F8,
"F9" => Key::F9, "F10" => Key::F10, "F11" => Key::F11, "F12" => Key::F12,
// Digits
"Digit0" | "Num0" => Key::Num0,
"Digit1" | "Num1" => Key::Num1,
"Digit2" | "Num2" => Key::Num2,
"Digit3" | "Num3" => Key::Num3,
"Digit4" | "Num4" => Key::Num4,
"Digit5" | "Num5" => Key::Num5,
"Digit6" | "Num6" => Key::Num6,
"Digit7" | "Num7" => Key::Num7,
"Digit8" | "Num8" => Key::Num8,
"Digit9" | "Num9" => Key::Num9,
// Letters — browser uses "KeyA" style which already matches rdev.
"KeyA" => Key::KeyA, "KeyB" => Key::KeyB, "KeyC" => Key::KeyC,
"KeyD" => Key::KeyD, "KeyE" => Key::KeyE, "KeyF" => Key::KeyF,
"KeyG" => Key::KeyG, "KeyH" => Key::KeyH, "KeyI" => Key::KeyI,
"KeyJ" => Key::KeyJ, "KeyK" => Key::KeyK, "KeyL" => Key::KeyL,
"KeyM" => Key::KeyM, "KeyN" => Key::KeyN, "KeyO" => Key::KeyO,
"KeyP" => Key::KeyP, "KeyQ" => Key::KeyQ, "KeyR" => Key::KeyR,
"KeyS" => Key::KeyS, "KeyT" => Key::KeyT, "KeyU" => Key::KeyU,
"KeyV" => Key::KeyV, "KeyW" => Key::KeyW, "KeyX" => Key::KeyX,
"KeyY" => Key::KeyY, "KeyZ" => Key::KeyZ,
// Punctuation / symbols
"Backquote" | "BackQuote" => Key::BackQuote,
"Minus" => Key::Minus,
"Equal" => Key::Equal,
"BracketLeft" | "LeftBracket" => Key::LeftBracket,
"BracketRight" | "RightBracket" => Key::RightBracket,
"Semicolon" | "SemiColon" => Key::SemiColon,
"Quote" => Key::Quote,
"Backslash" | "BackSlash" => Key::BackSlash,
"Comma" => Key::Comma,
"Period" | "Dot" => Key::Dot,
"Slash" => Key::Slash,
_ => return None,
})
}
+355 -2
View File
@@ -1,14 +1,62 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod accessibility;
mod audio_capture;
mod audio_output;
mod clipboard;
mod focus_capture;
#[cfg(desktop)]
mod hotkey_monitor;
#[cfg(desktop)]
mod key_codes;
mod synthetic_keys;
use std::sync::Mutex;
use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent};
use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent, WebviewUrl, WebviewWindowBuilder, PhysicalPosition};
use tauri_plugin_shell::ShellExt;
use tokio::sync::mpsc;
pub const DICTATE_WINDOW_LABEL: &str = "dictate";
const DICTATE_WINDOW_WIDTH: f64 = 420.0;
const DICTATE_WINDOW_HEIGHT: f64 = 64.0;
/// Create the floating dictate webview up front, hidden. The HotkeyMonitor
/// shows it on chord-start; the frontend hides it when the capture pipeline
/// finishes. Starting it at setup avoids a race where the first chord fires
/// before the webview has had a chance to subscribe to the `dictate:*` events.
#[cfg(desktop)]
fn build_dictate_window(app: &tauri::AppHandle) -> tauri::Result<tauri::WebviewWindow> {
let window = WebviewWindowBuilder::new(
app,
DICTATE_WINDOW_LABEL,
WebviewUrl::App("?view=dictate".into()),
)
.title("Voicebox Dictate")
.inner_size(DICTATE_WINDOW_WIDTH, DICTATE_WINDOW_HEIGHT)
.decorations(false)
.transparent(true)
.always_on_top(true)
// Follow the user across macOS Spaces / virtual desktops instead of
// being pinned to the Space where the window was first created.
.visible_on_all_workspaces(true)
.skip_taskbar(true)
.resizable(false)
.shadow(false)
.visible(false)
.build()?;
if let Some(monitor) = window.current_monitor()? {
let monitor_size = monitor.size();
let win_size = window.outer_size()?;
let x = (monitor_size.width as i32 - win_size.width as i32) / 2;
let y = (monitor_size.height as f64 * 0.04) as i32;
window.set_position(PhysicalPosition::new(x, y))?;
}
Ok(window)
}
const LEGACY_PORT: u16 = 8000;
const SERVER_PORT: u16 = 17493;
@@ -709,6 +757,273 @@ fn stop_audio_playback(
state.stop_all_playback()
}
/// Identifier of the Voicebox app itself — used to short-circuit auto-paste
/// when the user fires a chord while focus was inside one of our own
/// windows. Paste into Voicebox-internal targets is step 6 territory and
/// goes through a different (JS-side) injection path.
///
/// Value matches what `focus_capture::capture_focus` writes into
/// `FocusSnapshot::bundle_id` on the current platform — reverse-DNS bundle
/// id on macOS, lowercased exe basename on Windows/Linux.
#[cfg(target_os = "macos")]
const VOICEBOX_BUNDLE_ID: &str = "sh.voicebox.app";
#[cfg(target_os = "windows")]
const VOICEBOX_BUNDLE_ID: &str = "voicebox.exe";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
const VOICEBOX_BUNDLE_ID: &str = "voicebox";
/// Milliseconds to wait between activating the target app and firing the
/// synthetic ⌘V, giving AppKit time to finish re-ordering windows and
/// restoring its last-focused field.
const POST_ACTIVATE_SETTLE_MS: u64 = 120;
/// Milliseconds the staged text lives on the clipboard after the paste
/// keystroke, before we restore the user's original clipboard contents.
/// Too short and slow apps haven't consumed the paste yet; too long and
/// the user sees our text if they look at their clipboard manager.
const PASTE_CONSUME_MS: u64 = 400;
/// Reports whether the process currently has macOS Accessibility trust.
/// Used by the settings UI and the paste debug harness to decide whether
/// synthetic key events will actually land.
#[command]
fn check_accessibility_permission() -> bool {
accessibility::is_trusted()
}
/// Push a new chord configuration into the running `HotkeyMonitor`. The
/// frontend calls this both at startup (replaying the saved chord from
/// capture_settings) and any time the user edits the chord in the picker —
/// no app restart needed because the engine swap is atomic under the
/// monitor's mutex.
///
/// Returns an error when a key name doesn't map to an `rdev::Key`, so the
/// picker UI can surface "this key isn't supported" instead of silently
/// dropping it from the chord.
#[cfg(desktop)]
#[command]
fn update_chord_bindings(
monitor: State<'_, hotkey_monitor::HotkeyMonitor>,
push_to_talk: Vec<String>,
toggle_to_talk: Vec<String>,
) -> Result<(), String> {
use hotkey_monitor::{Bindings, ChordAction};
use rdev::Key;
use std::collections::HashSet;
fn build_chord(name: &str, names: &[String]) -> Result<HashSet<Key>, String> {
if names.is_empty() {
return Err(format!("{name} chord must have at least one key"));
}
let mut chord = HashSet::new();
for raw in names {
let key = key_codes::key_from_str(raw)
.ok_or_else(|| format!("Unsupported key in {name} chord: {raw}"))?;
chord.insert(key);
}
Ok(chord)
}
let push_chord = build_chord("push-to-talk", &push_to_talk)?;
let toggle_chord = build_chord("toggle-to-talk", &toggle_to_talk)?;
let mut bindings = Bindings::new();
bindings.insert(ChordAction::PushToTalk, push_chord);
bindings.insert(ChordAction::ToggleToTalk, toggle_chord);
monitor.update_bindings(bindings);
Ok(())
}
/// Open the Privacy & Security → Accessibility pane in System Settings so
/// the user can grant the permission. The URL scheme is stable across
/// macOS 10.1415; no-op on other platforms.
#[command]
fn open_accessibility_settings(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let url = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
app.shell()
.open(url, None)
.map_err(|e| format!("Failed to open Accessibility settings: {e}"))?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
let _ = app;
Err("Accessibility settings pane is only implemented on macOS".into())
}
}
/// Deliver `text` into the UI that had focus when the chord fired.
///
/// Pipeline: activate the captured PID → settle → save the user's
/// clipboard → write `text` → fire ⌘V → wait for the target to consume it
/// → restore the original clipboard.
///
/// Skips (returns `false`) without touching anything when:
/// - `focus.bundle_id` is Voicebox itself — step 6 will inject directly
/// into our own webview; pasting would just double-insert or miss the
/// real target.
/// - Accessibility is not trusted — `CGEventPost` would silently drop the
/// keystroke, leaving the user's clipboard clobbered with nothing to
/// show for it.
///
/// Returns `true` when the paste sequence completed end-to-end.
#[command]
async fn paste_final_text(
text: String,
focus: focus_capture::FocusSnapshot,
) -> Result<bool, String> {
if focus.bundle_id.as_deref() == Some(VOICEBOX_BUNDLE_ID) {
return Ok(false);
}
if !accessibility::is_trusted() {
return Err(
"Accessibility permission required for auto-paste. Open System Settings → Privacy & Security → Accessibility and enable Voicebox."
.into(),
);
}
focus_capture::activate_pid(focus.pid)?;
tokio::time::sleep(std::time::Duration::from_millis(POST_ACTIVATE_SETTLE_MS)).await;
let snapshot = clipboard::save_clipboard()?;
clipboard::write_text(&text)?;
synthetic_keys::send_paste()?;
tokio::time::sleep(std::time::Duration::from_millis(PASTE_CONSUME_MS)).await;
clipboard::restore_clipboard(&snapshot)?;
Ok(true)
}
/// Inspect the currently focused UI element. Returns the owning app's PID,
/// bundle id, and AX role. Useful for sanity-checking the focus pipeline
/// before committing to a paste.
#[command]
fn debug_capture_focus() -> Result<focus_capture::FocusSnapshot, String> {
focus_capture::capture_focus()
}
/// Full auto-paste rehearsal: snapshot the focus target now, sleep
/// `drift_ms` so the user can deliberately switch to a different app
/// (proving we don't paste into whichever window is frontmost when the
/// transcribe finishes), then activate the captured PID, stage `text`,
/// fire ⌘V, and restore the clipboard.
#[command]
async fn debug_focus_roundtrip(
text: String,
drift_ms: u64,
post_paste_delay_ms: u64,
) -> Result<serde_json::Value, String> {
if !accessibility::is_trusted() {
return Err(
"Accessibility permission not granted. Open System Settings → Privacy & Security → Accessibility and enable Voicebox."
.into(),
);
}
let snapshot = focus_capture::capture_focus()?;
tokio::time::sleep(std::time::Duration::from_millis(drift_ms)).await;
focus_capture::activate_pid(snapshot.pid)?;
// Give AppKit a beat to process the activation before the synthetic
// Cmd+V arrives — without this the paste sometimes races ahead of the
// window-ordering animation and lands in the previous frontmost app.
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
let clip = clipboard::save_clipboard()?;
let after_write = clipboard::write_text(&text)?;
synthetic_keys::send_paste()?;
tokio::time::sleep(std::time::Duration::from_millis(post_paste_delay_ms)).await;
let before_restore = clipboard::current_change_count()?;
clipboard::restore_clipboard(&clip)?;
Ok(serde_json::json!({
"focus": snapshot,
"change_count_after_write": after_write,
"change_count_before_restore": before_restore,
"clobbered_during_paste": before_restore != after_write,
}))
}
/// End-to-end smoke test for the auto-paste pipeline: save the user's
/// clipboard, stage `text`, optionally wait `pre_paste_delay_ms` so the
/// caller has time to focus the target app, synthesise ⌘V, wait
/// `post_paste_delay_ms` for the target app to consume the event, and put
/// the original clipboard back.
///
/// Short-circuits when Accessibility permission is missing — without it
/// `CGEventPost` silently drops events, so running the full sequence
/// would just clobber the clipboard with nothing to show for it.
#[command]
async fn debug_paste_text(
text: String,
pre_paste_delay_ms: u64,
post_paste_delay_ms: u64,
) -> Result<serde_json::Value, String> {
if !accessibility::is_trusted() {
return Err(
"Accessibility permission not granted. Open System Settings → Privacy & Security → Accessibility and enable Voicebox, then try again."
.into(),
);
}
let snapshot = clipboard::save_clipboard()?;
let before = snapshot.change_count();
let after_write = clipboard::write_text(&text)?;
tokio::time::sleep(std::time::Duration::from_millis(pre_paste_delay_ms)).await;
synthetic_keys::send_paste()?;
tokio::time::sleep(std::time::Duration::from_millis(post_paste_delay_ms)).await;
let before_restore = clipboard::current_change_count()?;
clipboard::restore_clipboard(&snapshot)?;
let after_restore = clipboard::current_change_count()?;
Ok(serde_json::json!({
"change_count_before": before,
"change_count_after_write": after_write,
"change_count_before_restore": before_restore,
"change_count_after_restore": after_restore,
"clobbered_during_paste": before_restore != after_write,
}))
}
/// Manual smoke test for the clipboard snapshot/restore primitives used by
/// the auto-paste pipeline. Stages `text` on the pasteboard, waits
/// `hold_ms` so the caller can ⌘V into another app, then puts the original
/// clipboard contents back. The return value reports the change-count deltas
/// so the harness can verify no third party mutated the clipboard mid-paste.
#[command]
async fn debug_clipboard_roundtrip(
text: String,
hold_ms: u64,
) -> Result<serde_json::Value, String> {
let snapshot = clipboard::save_clipboard()?;
let before = snapshot.change_count();
let item_count = snapshot.item_count();
let after_write = clipboard::write_text(&text)?;
tokio::time::sleep(std::time::Duration::from_millis(hold_ms)).await;
let before_restore = clipboard::current_change_count()?;
clipboard::restore_clipboard(&snapshot)?;
let after_restore = clipboard::current_change_count()?;
Ok(serde_json::json!({
"saved_items": item_count,
"change_count_before": before,
"change_count_after_write": after_write,
"change_count_before_restore": before_restore,
"change_count_after_restore": after_restore,
"clobbered_during_hold": before_restore != after_write,
}))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -728,6 +1043,36 @@ pub fn run() {
{
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
if let Err(e) = build_dictate_window(app.handle()) {
eprintln!("Failed to pre-create dictate window: {}", e);
}
let monitor = hotkey_monitor::HotkeyMonitor::spawn(
app.handle().clone(),
hotkey_monitor::default_bindings(),
);
// Stored as state so the chord-picker UI can call
// `update_chord_bindings` to live-swap the engine's chords
// without restarting the listener thread.
app.manage(monitor);
// The frontend emits `dictate:hide` whenever the pill cycle
// finishes (rest-fade → hidden). `hide()` alone has been
// unreliable for transparent always-on-top windows on macOS
// — the NSWindow lingers as an invisible click target that
// steals focus to the Voicebox app when the user clicks
// where it used to be. Park the window off-screen and mark
// it click-through as well, so even if `hide()` no-ops the
// user sees and interacts with nothing.
let handle_for_hide = app.handle().clone();
app.handle().listen("dictate:hide", move |_event| {
if let Some(window) = handle_for_hide.get_webview_window(DICTATE_WINDOW_LABEL) {
let _ = window.set_ignore_cursor_events(true);
let _ = window.set_position(PhysicalPosition::new(-10_000, -10_000));
let _ = window.hide();
}
});
}
// Hide title bar icon on Windows
@@ -797,7 +1142,15 @@ pub fn run() {
is_system_audio_supported,
list_audio_output_devices,
play_audio_to_devices,
stop_audio_playback
stop_audio_playback,
debug_clipboard_roundtrip,
debug_paste_text,
debug_capture_focus,
debug_focus_roundtrip,
check_accessibility_permission,
open_accessibility_settings,
paste_final_text,
update_chord_bindings
])
.on_window_event({
let closing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+200
View File
@@ -0,0 +1,200 @@
//! Synthetic keyboard event posting for the auto-paste pipeline.
//!
//! `send_paste` fires the four-event paste sequence onto the OS input
//! pipeline so the focused app performs its native paste action against
//! whatever the clipboard module has just staged.
//!
//! - **macOS** — Cmd down, V down with Cmd flag, V up with Cmd flag, Cmd
//! up via `CGEventPost` at `kCGHIDEventTap`. Accessibility permission is
//! load-bearing: without it the system swallows the events silently, so
//! callers must gate on [`crate::accessibility::is_trusted`].
//! - **Windows** — Ctrl down, V down, V up, Ctrl up via `SendInput`. No
//! permission gate, but UAC/UIPI blocks delivery into elevated target
//! windows when we run non-elevated — nothing we can do short of also
//! running elevated.
//!
//! The virtual keycode used for V is `kVK_ANSI_V` (9) on macOS and `VK_V`
//! (0x56) on Windows. Both are layout-dependent — they mean "the physical
//! key in the QWERTY V position" — so on Dvorak / Colemak this would fire
//! the wrong shortcut. A later pass will resolve the current layout's V
//! keycode per-platform (`TISCopyCurrentKeyboardInputSource` +
//! `UCKeyTranslate` on macOS; `VkKeyScanExW` on Windows).
#[cfg(target_os = "macos")]
use std::ffi::c_void;
#[cfg(target_os = "macos")]
mod ffi {
use std::ffi::c_void;
#[repr(C)]
pub struct CGEvent {
_opaque: [u8; 0],
}
pub type CGEventRef = *mut CGEvent;
#[repr(C)]
pub struct CGEventSource {
_opaque: [u8; 0],
}
pub type CGEventSourceRef = *mut CGEventSource;
pub type CGEventTapLocation = u32;
pub type CGKeyCode = u16;
pub type CGEventFlags = u64;
pub type CGEventSourceStateID = i32;
/// `kCGHIDEventTap` — posted events enter at the HID level so every
/// downstream tap (including the target app) sees them exactly as if the
/// hardware had produced them.
pub const K_CG_HID_EVENT_TAP: CGEventTapLocation = 0;
/// `kCGEventSourceStateHIDSystemState` — mimics hardware, which is what
/// we want: modifier bookkeeping inside target apps stays consistent.
pub const K_CG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE: CGEventSourceStateID = 1;
/// `kCGEventFlagMaskCommand` — the Cmd modifier bit inside `CGEventFlags`.
pub const K_CG_EVENT_FLAG_MASK_COMMAND: CGEventFlags = 0x00100000;
/// `kVK_ANSI_V`.
pub const KEYCODE_V: CGKeyCode = 9;
/// `kVK_Command` (left Cmd).
pub const KEYCODE_LEFT_CMD: CGKeyCode = 0x37;
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
pub fn CGEventSourceCreate(state_id: CGEventSourceStateID) -> CGEventSourceRef;
pub fn CGEventCreateKeyboardEvent(
source: CGEventSourceRef,
virtual_key: CGKeyCode,
key_down: bool,
) -> CGEventRef;
pub fn CGEventSetFlags(event: CGEventRef, flags: CGEventFlags);
pub fn CGEventPost(tap: CGEventTapLocation, event: CGEventRef);
}
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
pub fn CFRelease(cf: *const c_void);
}
}
/// Post the four-event Cmd+V sequence to the HID event tap.
///
/// Returns after the events are queued — there's no completion callback,
/// so callers should sleep briefly afterwards to let the target app
/// process the paste before any follow-up (e.g. clipboard restore).
#[cfg(target_os = "macos")]
pub fn send_paste() -> Result<(), String> {
use ffi::*;
unsafe {
let source = CGEventSourceCreate(K_CG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE);
if source.is_null() {
return Err("CGEventSourceCreate returned null".into());
}
let _source_guard = scopeguard::guard(source, |s| CFRelease(s as *const c_void));
let events = [
(KEYCODE_LEFT_CMD, true, 0),
(KEYCODE_V, true, K_CG_EVENT_FLAG_MASK_COMMAND),
(KEYCODE_V, false, K_CG_EVENT_FLAG_MASK_COMMAND),
(KEYCODE_LEFT_CMD, false, 0),
];
// Build the four events up front so CFRelease happens after all posts.
// Posting in a loop that interleaved create → post → release would
// work, but keeping the events alive for the full sequence matches
// the pattern CGEventPost's docs show and is easier to reason about.
let mut guards = Vec::with_capacity(events.len());
let mut created = Vec::with_capacity(events.len());
for (key, down, flags) in events {
let event = CGEventCreateKeyboardEvent(source, key, down);
if event.is_null() {
return Err(format!(
"CGEventCreateKeyboardEvent(key={}, down={}) returned null",
key, down
));
}
let guard = scopeguard::guard(event, |e| CFRelease(e as *const c_void));
if flags != 0 {
CGEventSetFlags(event, flags);
}
created.push(event);
guards.push(guard);
}
for event in created {
CGEventPost(K_CG_HID_EVENT_TAP, event);
}
drop(guards);
Ok(())
}
}
#[cfg(target_os = "windows")]
mod win {
use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP,
VIRTUAL_KEY,
};
pub fn make_key(vk: VIRTUAL_KEY, up: bool) -> INPUT {
let flags = if up {
KEYEVENTF_KEYUP
} else {
KEYBD_EVENT_FLAGS(0)
};
INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: vk,
wScan: 0,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
}
}
}
#[cfg(target_os = "windows")]
pub fn send_paste() -> Result<(), String> {
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, VK_CONTROL, VK_V,
};
// Four-event Ctrl+V sequence. Matches the macOS CGEvent pattern: the
// modifier brackets the letter so the target app sees a fully formed
// accelerator rather than a lone V. `dwExtraInfo` is zero — we're not
// tagging these as "ours" because no consumer in the paste path needs
// to distinguish synthetic events from hardware ones.
let events = [
win::make_key(VK_CONTROL, false),
win::make_key(VK_V, false),
win::make_key(VK_V, true),
win::make_key(VK_CONTROL, true),
];
unsafe {
let sent = SendInput(&events, std::mem::size_of::<INPUT>() as i32);
if sent as usize != events.len() {
return Err(format!(
"SendInput delivered {} of {} events — the input desktop may be locked (secure attention sequence) or a higher-integrity window is intercepting.",
sent,
events.len()
));
}
}
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn send_paste() -> Result<(), String> {
Err("synthetic paste is not yet implemented on this platform".into())
}