From e900996639085f71f1ecc19f38c88283edf663ff Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 28 Jun 2026 08:50:51 -0300 Subject: [PATCH 1/5] fix(core): allow Windows Tauri webview origin for desktop auth token The desktop login endpoint only returns the session token in the response body when the request Origin is in DESKTOP_AUTH_TOKEN_BODY_ORIGINS. That set listed the macOS/Linux webview origin (tauri://localhost) and the vite dev origin, but not the Windows WebView2 origin (http(s)://tauri.localhost). On Windows the server replied { ok: true } with no token, so the app saved no session and bounced straight back to the sign-in form. Add http://tauri.localhost and https://tauri.localhost so email/password login works from the Windows desktop app. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/server/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index 15c99ad007..c3f14417f6 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -1129,6 +1129,8 @@ const _desktopExchanges = new Map(); const DESKTOP_EXCHANGE_ERROR_PREFIX = "__error__::"; const DESKTOP_AUTH_TOKEN_BODY_ORIGINS = new Set([ "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", "http://localhost:1420", ]); From e7f97612ccdc351fe178e1ef3605fca51aaa4a29 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 28 Jun 2026 08:51:12 -0300 Subject: [PATCH 2/5] fix(core): don't report wrong password as an invalid email address isAuthEmailValidationMessage rewrote any auth error mentioning "email" plus "invalid/input/required/format" into the friendly "Enter a valid email address" message. Better Auth's credential-failure message ("Invalid email or password") matches that heuristic, so every failed login with a valid email was mislabeled as a malformed-email error. Skip the rewrite when the message mentions password/credential, so genuine format errors still get the friendly message while bad-credential failures surface as "Invalid email or password". Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/server/auth.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index c3f14417f6..05ab94b480 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -842,6 +842,11 @@ function publicAuthError( } function isAuthEmailValidationMessage(message: string): boolean { + // Credential failures (e.g. Better Auth's "Invalid email or password") mention + // "email" + "invalid" but are NOT email-format errors — don't rewrite them to + // the "enter a valid email" message, or every wrong-password attempt looks like + // a malformed-email error. + if (/password|credential/i.test(message)) return false; return ( /\bemail\b/i.test(message) && /(invalid|input|required|format)/i.test(message) From 2b987ca885b76d98342dadce2da6a2176e7be341 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 28 Jun 2026 08:53:19 -0300 Subject: [PATCH 3/5] gitignore Jetbrains .idea folder --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 934e0f2893..34e83a663a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist docs/superpowers .DS_Store +.idea/ + /*.png tmp/ From e7d0f5bfcb8d3ec8f2c5412b380d538511a52128 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 28 Jun 2026 08:58:51 -0300 Subject: [PATCH 4/5] chore(changeset): Windows desktop auth fixes Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/windows-desktop-auth-login.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/windows-desktop-auth-login.md diff --git a/.changeset/windows-desktop-auth-login.md b/.changeset/windows-desktop-auth-login.md new file mode 100644 index 0000000000..b0909a12cc --- /dev/null +++ b/.changeset/windows-desktop-auth-login.md @@ -0,0 +1,11 @@ +--- +"@agent-native/core": patch +--- + +Fix Windows desktop (Tauri) email/password sign-in. The login endpoint now +returns the session token to the Windows WebView2 origin +(`http://tauri.localhost` / `https://tauri.localhost`), which was missing from +the desktop token allowlist, so sign-in no longer silently bounces back to the +form. Also stop reporting wrong-password failures as "Enter a valid email +address" — credential errors now surface as "Invalid email or password" while +genuine malformed-email input still gets the friendly format message. From 0ea4413bcf3632b5f6cc1c02f8da80a6bbd2359e Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 28 Jun 2026 16:35:20 -0300 Subject: [PATCH 5/5] feat(clips): add Windows voice dictation + text delivery setting Voice dictation only delivered text on macOS; on Windows complete_voice_dictation errored and dropped the transcript. Implement the Windows path: write the transcript to the clipboard (arboard) and paste it via synthesized Ctrl+V (SendInput), plus a direct Unicode-typing path. Add a 'Text delivery' setting (paste+copy / copy-only / type-only) wired through voice-dictation.ts and the complete_voice_dictation command so users can choose whether dictation lands on the clipboard, the focused field, or both. Bump the desktop app to 0.2.0 so locally built/installed apps aren't replaced by the published auto-updater build during testing; CI sets the real release version. Co-authored-by: Cursor --- templates/clips/desktop/package.json | 2 +- templates/clips/desktop/src-tauri/Cargo.lock | 180 ++++++++++++++-- templates/clips/desktop/src-tauri/Cargo.toml | 12 +- .../clips/desktop/src-tauri/src/clips/mod.rs | 198 ++++++++++++++++-- .../clips/desktop/src-tauri/tauri.conf.json | 2 +- templates/clips/desktop/src/app.tsx | 52 +++++ .../clips/desktop/src/lib/voice-dictation.ts | 21 +- 7 files changed, 433 insertions(+), 34 deletions(-) diff --git a/templates/clips/desktop/package.json b/templates/clips/desktop/package.json index 1020c43381..adff3d9124 100644 --- a/templates/clips/desktop/package.json +++ b/templates/clips/desktop/package.json @@ -1,6 +1,6 @@ { "name": "clips-desktop", - "version": "0.1.1", + "version": "0.2.0", "private": true, "description": "Clips menu-bar tray app (Tauri 2). Quick-record + recent recordings from the OS tray.", "type": "module", diff --git a/templates/clips/desktop/src-tauri/Cargo.lock b/templates/clips/desktop/src-tauri/Cargo.lock index a6c220360b..e060e02621 100644 --- a/templates/clips/desktop/src-tauri/Cargo.lock +++ b/templates/clips/desktop/src-tauri/Cargo.lock @@ -213,6 +213,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -717,10 +737,20 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "clips-tray" -version = "0.1.1" +version = "0.2.0" dependencies = [ + "arboard", "block2", "chrono", "core-foundation", @@ -752,6 +782,7 @@ dependencies = [ "tokio", "url", "whisper-rs", + "windows 0.62.2", ] [[package]] @@ -880,6 +911,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1320,6 +1357,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1347,6 +1390,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1945,6 +1994,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2315,6 +2375,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "tiff", ] [[package]] @@ -2949,6 +3010,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -3803,6 +3865,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -5100,7 +5168,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5183,7 +5251,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -5438,7 +5506,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -5463,7 +5531,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5526,7 +5594,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-version", ] @@ -5604,6 +5672,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -6422,7 +6504,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -6446,10 +6528,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whisper-rs" version = "0.16.0" @@ -6524,11 +6612,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -6540,6 +6640,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -6574,7 +6683,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -6621,6 +6741,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6759,6 +6889,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -7084,7 +7223,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -7326,6 +7465,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/templates/clips/desktop/src-tauri/Cargo.toml b/templates/clips/desktop/src-tauri/Cargo.toml index 149923960b..0461ba3ded 100644 --- a/templates/clips/desktop/src-tauri/Cargo.toml +++ b/templates/clips/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clips-tray" -version = "0.1.1" +version = "0.2.0" description = "Clips — menu-bar tray app (Tauri 2)" authors = ["Clips"] license = "MIT" @@ -124,6 +124,16 @@ whisper-rs = { version = "0.16.0" } # every version exposes `EKEventStore::eventsMatchingPredicate`); the # slice we need is small enough that hand-rolling is more durable. +# Windows-only: voice-dictation text delivery. `arboard` writes the +# transcript to the Windows clipboard (so the user can paste manually too), +# and the `windows` crate's SendInput synthesizes Ctrl+V into the focused +# field — the Win32 equivalent of the macOS pbcopy + CGEvent Cmd+V path. +[target."cfg(target_os = \"windows\")".dependencies] +arboard = "3.6.1" +windows = { version = "0.62.2", features = [ + "Win32_UI_Input_KeyboardAndMouse", +] } + [profile.release] panic = "abort" codegen-units = 1 diff --git a/templates/clips/desktop/src-tauri/src/clips/mod.rs b/templates/clips/desktop/src-tauri/src/clips/mod.rs index 7053ad7e71..645db6f8cb 100644 --- a/templates/clips/desktop/src-tauri/src/clips/mod.rs +++ b/templates/clips/desktop/src-tauri/src/clips/mod.rs @@ -76,6 +76,29 @@ enum TextInsertionStrategy { UnicodeType, } +/// How a completed dictation is delivered to the user, chosen in +/// Settings → Dictation → Text delivery. Mirrors the `VoiceTextDelivery` +/// union in `voice-dictation.ts`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TextDeliveryMode { + /// Copy to the clipboard AND paste into the focused field (default). + PasteAndCopy, + /// Only copy to the clipboard; never synthesize a keystroke. + CopyOnly, + /// Only type into the focused field via synthetic Unicode key events; + /// never touch the clipboard. + TypeOnly, +} + +fn parse_text_delivery_mode(value: &str) -> TextDeliveryMode { + match value { + "copy-only" => TextDeliveryMode::CopyOnly, + "type-only" => TextDeliveryMode::TypeOnly, + // Includes "paste-and-copy" and any unknown/legacy value. + _ => TextDeliveryMode::PasteAndCopy, + } +} + /// Extra vertical real-estate reserved beneath the circular bubble for the /// hover-controls pill (small-dot + medium-dot). The Tauri window is /// `transparent: true`, so the budget paints through as empty space until the @@ -1296,12 +1319,27 @@ pub async fn hide_flow_bar(app: AppHandle) -> Result<(), String> { } #[tauri::command] -pub async fn complete_voice_dictation(app: AppHandle, text: String) -> Result<(), String> { +pub async fn complete_voice_dictation( + app: AppHandle, + text: String, + // Optional so older frontends (and the default) keep the paste-and-copy + // behavior. Parsed leniently — any unknown value falls back to default. + delivery: Option, +) -> Result<(), String> { let trimmed = text.trim().to_string(); if trimmed.is_empty() { eprintln!("[clips-tray] complete_voice_dictation: empty text — nothing to paste"); return Ok(()); } + let mode = delivery + .as_deref() + .map(parse_text_delivery_mode) + .unwrap_or(TextDeliveryMode::PasteAndCopy); + // Whether to put the transcript on the clipboard, and whether to insert + // it into the focused field, depend on the chosen delivery mode. + let write_to_clipboard = mode != TextDeliveryMode::TypeOnly; + let insert_into_field = mode != TextDeliveryMode::CopyOnly; + #[cfg(target_os = "macos")] let frontmost_bundle_id = frontmost_bundle_identifier(); #[cfg(target_os = "macos")] @@ -1310,15 +1348,17 @@ pub async fn complete_voice_dictation(app: AppHandle, text: String) -> Result<() let strategy = text_insertion_strategy(frontmost_bundle_id.as_deref()); #[cfg(target_os = "macos")] eprintln!( - "[clips-tray] complete_voice_dictation: inserting {} chars via {:?} (frontmost={})", + "[clips-tray] complete_voice_dictation: {} chars, delivery={:?}, strategy={:?} (frontmost={})", trimmed.chars().count(), + mode, strategy, frontmost_bundle_id.as_deref().unwrap_or("unknown"), ); #[cfg(not(target_os = "macos"))] eprintln!( - "[clips-tray] complete_voice_dictation: inserting {} chars", + "[clips-tray] complete_voice_dictation: {} chars, delivery={:?}", trimmed.chars().count(), + mode, ); if let Some(last) = app.try_state::() { if let Ok(mut g) = last.0.lock() { @@ -1331,14 +1371,32 @@ pub async fn complete_voice_dictation(app: AppHandle, text: String) -> Result<() // stream of synthetic Unicode key events through AppKit text input. // Known terminal apps still use direct Unicode typing because custom // terminal paste bindings can intercept Cmd+V or bypass paste handling. - write_clipboard(&trimmed)?; + if write_to_clipboard { + write_clipboard(&trimmed)?; + } + #[cfg(target_os = "macos")] - match strategy { - TextInsertionStrategy::ClipboardPaste => paste_clipboard(voice_target_bundle_id), - TextInsertionStrategy::UnicodeType => type_text_unicode(&trimmed, voice_target_bundle_id), + if insert_into_field { + match (mode, strategy) { + // Type-only never uses the clipboard, so always synthesize the + // text directly regardless of the per-app paste strategy. + (TextDeliveryMode::TypeOnly, _) => type_text_unicode(&trimmed, voice_target_bundle_id), + (_, TextInsertionStrategy::ClipboardPaste) => paste_clipboard(voice_target_bundle_id), + (_, TextInsertionStrategy::UnicodeType) => { + type_text_unicode(&trimmed, voice_target_bundle_id) + } + } + } + + // Windows: paste from the clipboard (Ctrl+V) for paste-and-copy, or type + // the text directly with synthetic Unicode key events for type-only. + #[cfg(target_os = "windows")] + if insert_into_field { + match mode { + TextDeliveryMode::TypeOnly => type_text_unicode_windows(&trimmed), + _ => paste_clipboard_windows(), + } } - #[cfg(not(target_os = "macos"))] - type_text_unicode(&trimmed); Ok(()) } @@ -1437,12 +1495,122 @@ fn write_clipboard(text: &str) -> Result<(), String> { } } -// Voice-dictation paste relies on macOS-specific `pbcopy` + CGEvent paste; the -// non-mac path is an explicit error so the JS layer can surface a clear -// message rather than the user seeing a silent failure. -#[cfg(not(target_os = "macos"))] +// Windows: write the transcript to the system clipboard via arboard. The +// owned clipboard memory persists after the call returns (clipboard-win +// backend), so a subsequent Ctrl+V — or a manual paste by the user — picks +// it up. +#[cfg(target_os = "windows")] +fn write_clipboard(text: &str) -> Result<(), String> { + use arboard::Clipboard; + let mut clipboard = Clipboard::new().map_err(|e| format!("clipboard open: {e}"))?; + clipboard + .set_text(text.to_string()) + .map_err(|e| format!("clipboard set: {e}"))?; + Ok(()) +} + +// Other platforms (Linux): voice-dictation text delivery isn't implemented +// yet, so surface a clear error rather than a silent failure. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] fn write_clipboard(_text: &str) -> Result<(), String> { - Err("voice dictation is currently macOS-only".to_string()) + Err("voice dictation is not yet supported on this OS".to_string()) +} + +// Windows equivalent of macOS's CGEvent Cmd+V: synthesize a Ctrl+V keystroke +// with SendInput so the just-copied transcript pastes into the focused field. +// Runs on a short-lived thread with a small delay so focus has settled back +// on the target window after the non-activating flow bar updated. +#[cfg(target_os = "windows")] +fn paste_clipboard_windows() { + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, + VIRTUAL_KEY, VK_CONTROL, + }; + + thread::spawn(move || { + thread::sleep(Duration::from_millis(90)); + // Virtual-key code for "V" is 0x56. We send a real VK (not a Unicode + // char event) so the OS interprets Ctrl+V as the paste shortcut. + const VK_V: VIRTUAL_KEY = VIRTUAL_KEY(0x56); + let key = |vk: VIRTUAL_KEY, up: bool| INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: if up { + KEYEVENTF_KEYUP + } else { + KEYBD_EVENT_FLAGS(0) + }, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + let inputs = [ + key(VK_CONTROL, false), + key(VK_V, false), + key(VK_V, true), + key(VK_CONTROL, true), + ]; + let sent = unsafe { SendInput(&inputs, std::mem::size_of::() as i32) }; + if sent as usize != inputs.len() { + eprintln!( + "[clips-tray] paste failed: SendInput sent {sent}/{} events", + inputs.len() + ); + } + }); +} + +// Windows type-only delivery: synthesize the transcript directly into the +// focused field as Unicode key events (KEYEVENTF_UNICODE), never touching the +// clipboard. Each UTF-16 code unit becomes a key down+up pair, which lets the +// OS deliver characters (including surrogate pairs / emoji) regardless of the +// active keyboard layout — the Win32 analog of macOS's CGEvent Unicode typing. +#[cfg(target_os = "windows")] +fn type_text_unicode_windows(text: &str) { + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, + VIRTUAL_KEY, + }; + + let units: Vec = text.encode_utf16().collect(); + thread::spawn(move || { + thread::sleep(Duration::from_millis(90)); + let mut inputs: Vec = Vec::with_capacity(units.len() * 2); + for unit in units { + let make = |up: bool| INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(0), + wScan: unit, + dwFlags: if up { + KEYEVENTF_UNICODE | KEYEVENTF_KEYUP + } else { + KEYEVENTF_UNICODE + }, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + inputs.push(make(false)); + inputs.push(make(true)); + } + if inputs.is_empty() { + return; + } + let sent = unsafe { SendInput(&inputs, std::mem::size_of::() as i32) }; + if sent as usize != inputs.len() { + eprintln!( + "[clips-tray] type failed: SendInput sent {sent}/{} events", + inputs.len() + ); + } + }); } #[cfg(target_os = "macos")] @@ -1580,8 +1748,6 @@ fn type_text_unicode(text: &str, target_bundle_id: Option) { }); } -#[cfg(not(target_os = "macos"))] -fn type_text_unicode(_text: &str) {} /// Record the popover's current recording state. When active, clicking the /// tray icon emits a stop event instead of toggling the popover — so the diff --git a/templates/clips/desktop/src-tauri/tauri.conf.json b/templates/clips/desktop/src-tauri/tauri.conf.json index add96bc542..1ca4994c5e 100644 --- a/templates/clips/desktop/src-tauri/tauri.conf.json +++ b/templates/clips/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Clips", - "version": "0.1.1", + "version": "0.2.0", "identifier": "com.clips.tray", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/templates/clips/desktop/src/app.tsx b/templates/clips/desktop/src/app.tsx index b8b622ac21..cc6241179b 100644 --- a/templates/clips/desktop/src/app.tsx +++ b/templates/clips/desktop/src/app.tsx @@ -78,6 +78,7 @@ import { type VoiceMode, type VoiceProvider, type VoiceShortcutPreference, + type VoiceTextDelivery, } from "./lib/voice-dictation"; import { useFeatureConfig, type LocalRecordingMode } from "./shared/config"; @@ -136,6 +137,7 @@ const VOICE_CUSTOM_SHORTCUT_KEY = "clips:voice-custom-shortcut"; const POPOVER_CUSTOM_SHORTCUT_KEY = "clips:popover-custom-shortcut"; const VOICE_MODE_KEY = "clips:voice-mode"; const VOICE_PROVIDER_KEY = "clips:voice-provider"; +const VOICE_TEXT_DELIVERY_KEY = "clips:voice-text-delivery"; const VOICE_INSTRUCTIONS_KEY = "clips:voice-instructions"; const AUTH_TOKEN_KEY = "clips:auth-token"; const SOURCE_KEY = "clips:last-source"; @@ -583,6 +585,14 @@ export function App() { loadString(VOICE_PROVIDER_KEY, nativeVoiceProvider()), ); }); + const [voiceTextDelivery, setVoiceTextDelivery] = useState( + () => { + const saved = loadString(VOICE_TEXT_DELIVERY_KEY, "paste-and-copy"); + return saved === "copy-only" || saved === "type-only" + ? saved + : "paste-and-copy"; + }, + ); const [voiceInstructions, setVoiceInstructions] = useState(() => loadString(VOICE_INSTRUCTIONS_KEY, ""), ); @@ -729,6 +739,7 @@ export function App() { shortcut: voiceShortcut, mode: voiceMode, provider: voiceProvider, + textDelivery: voiceTextDelivery, micDeviceId: selectedMicId || null, micDeviceLabel: selectedMicLabel || null, instructions: voiceInstructions, @@ -739,6 +750,7 @@ export function App() { voiceDictationEnabled, voiceMode, voiceProvider, + voiceTextDelivery, selectedMicId, selectedMicLabel, voiceInstructions, @@ -1554,6 +1566,10 @@ export function App() { () => saveString(VOICE_PROVIDER_KEY, voiceProvider), [voiceProvider], ); + useEffect( + () => saveString(VOICE_TEXT_DELIVERY_KEY, voiceTextDelivery), + [voiceTextDelivery], + ); useEffect( () => saveString(VOICE_INSTRUCTIONS_KEY, voiceInstructions), [voiceInstructions], @@ -2108,6 +2124,7 @@ export function App() { popoverCustomShortcut={popoverCustomShortcut} voiceMode={voiceMode} voiceProvider={voiceProvider} + voiceTextDelivery={voiceTextDelivery} voiceInstructions={voiceInstructions} shortcutRegistrationError={shortcutRegistrationError} onVoiceShortcutChange={updateVoiceShortcut} @@ -2115,6 +2132,7 @@ export function App() { onPopoverCustomShortcutChange={setPopoverCustomShortcut} onVoiceModeChange={setVoiceMode} onVoiceProviderChange={setVoiceProvider} + onVoiceTextDeliveryChange={setVoiceTextDelivery} onVoiceInstructionsChange={setVoiceInstructions} onSignOut={signOut} onConnect={(url) => { @@ -3031,6 +3049,7 @@ function Setup({ popoverCustomShortcut, voiceMode, voiceProvider, + voiceTextDelivery, voiceInstructions, shortcutRegistrationError, onVoiceShortcutChange, @@ -3038,6 +3057,7 @@ function Setup({ onPopoverCustomShortcutChange, onVoiceModeChange, onVoiceProviderChange, + onVoiceTextDeliveryChange, onVoiceInstructionsChange, onConnect, onCancel, @@ -3051,6 +3071,7 @@ function Setup({ popoverCustomShortcut: string; voiceMode: VoiceMode; voiceProvider: VoiceProvider; + voiceTextDelivery: VoiceTextDelivery; voiceInstructions: string; shortcutRegistrationError: string | null; onVoiceShortcutChange: (value: VoiceShortcutPreference) => void; @@ -3058,6 +3079,7 @@ function Setup({ onPopoverCustomShortcutChange: (value: string) => void; onVoiceModeChange: (value: VoiceMode) => void; onVoiceProviderChange: (value: VoiceProvider) => void; + onVoiceTextDeliveryChange: (value: VoiceTextDelivery) => void; onVoiceInstructionsChange: (value: string) => void; onConnect: (url: string) => void; onCancel?: () => void; @@ -3394,6 +3416,13 @@ function Setup({ "push-to-talk": "Hold the shortcut while speaking. Release to stop.", toggle: "Press once to start, again to stop.", }; + const textDeliveryHint: Record = { + "paste-and-copy": + "Paste the text into the focused field and keep a copy on the clipboard.", + "copy-only": "Only copy the text to the clipboard. Paste it yourself.", + "type-only": + "Type the text into the focused field without changing the clipboard.", + }; function selectProviderMode(mode: VoiceProviderMode) { setApiKeyMessage(null); @@ -3995,6 +4024,29 @@ function Setup({

{modeHint[voiceMode]}

+ +
+ + +

{textDeliveryHint[voiceTextDelivery]}

+
) : null} diff --git a/templates/clips/desktop/src/lib/voice-dictation.ts b/templates/clips/desktop/src/lib/voice-dictation.ts index b0f4cdefbf..425d759070 100644 --- a/templates/clips/desktop/src/lib/voice-dictation.ts +++ b/templates/clips/desktop/src/lib/voice-dictation.ts @@ -21,6 +21,18 @@ export type VoiceShortcutPreference = | "both"; export type VoiceMode = "push-to-talk" | "toggle"; +/** + * Where a completed dictation lands. Chosen in Settings → Dictation → Text + * delivery and forwarded to the `complete_voice_dictation` Tauri command: + * + * - "paste-and-copy": copy to the clipboard AND paste into the focused + * field (default). + * - "copy-only": only copy to the clipboard; no keystroke is synthesized. + * - "type-only": only type into the focused field; the clipboard is left + * untouched. + */ +export type VoiceTextDelivery = "paste-and-copy" | "copy-only" | "type-only"; + /** * Which transcription backend to use. The desktop app surfaces this in * Settings → Voice transcription as three modes: native on-device, @@ -68,6 +80,7 @@ interface DesktopVoiceDictationOptions { shortcut: VoiceShortcutPreference; mode: VoiceMode; provider: VoiceProvider; + textDelivery?: VoiceTextDelivery; micDeviceId?: string | null; micDeviceLabel?: string | null; instructions?: string; @@ -417,6 +430,7 @@ export function installDesktopVoiceDictation( let shortcut = options.shortcut; let mode = options.mode; let provider = options.provider; + let textDelivery: VoiceTextDelivery = options.textDelivery ?? "paste-and-copy"; let micDeviceId = concreteMediaDeviceId(options.micDeviceId); let micDeviceLabel = options.micDeviceLabel ?? ""; let instructions = options.instructions ?? ""; @@ -670,7 +684,7 @@ export function installDesktopVoiceDictation( } catch (err) { console.warn("[voice-dictation] backtrack failed, pasting raw:", err); } - await invoke("complete_voice_dictation", { text }); + await invoke("complete_voice_dictation", { text, delivery: textDelivery }); // Wispr-style auto-learn: snapshot the field for ~10s post-paste; any // single-word user edit becomes a persisted vocabulary entry. try { @@ -807,7 +821,10 @@ export function installDesktopVoiceDictation( `[voice-dictation] transcribed (${text.length} chars):`, text.slice(0, 120), ); - await invoke("complete_voice_dictation", { text }); + await invoke("complete_voice_dictation", { + text, + delivery: textDelivery, + }); } else { console.warn( "[voice-dictation] transcribe returned empty text — nothing to paste",