diff --git a/Cargo.lock b/Cargo.lock index 6e7220d..0a42d6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,6 +435,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -1595,6 +1607,18 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix 0.29.0", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1806,6 +1830,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -2268,6 +2298,26 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "handy-keys" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a007b6c921d3273fd88aac45b516eee01e5a9dc2d76bb6f1a981500cc0d818a" +dependencies = [ + "bitflags 2.13.0", + "block2", + "evdev", + "libc", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "serde", + "thiserror 2.0.18", + "windows 0.58.0", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3246,6 +3296,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -3399,10 +3461,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -3422,6 +3491,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -3433,7 +3503,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -3444,10 +3516,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.0", + "block2", "dispatch2", + "libc", "objc2", "objc2-core-foundation", "objc2-io-surface", + "objc2-metal", ] [[package]] @@ -3482,6 +3557,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3521,6 +3609,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -3725,7 +3824,7 @@ checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.31.3", "objc2", "objc2-foundation", "objc2-ui-kit", @@ -4301,6 +4400,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.7" @@ -5813,6 +5918,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.46" @@ -6117,6 +6228,7 @@ dependencies = [ "core-foundation 0.9.4", "core-graphics 0.23.2", "flashvoice-utils", + "handy-keys", "lazy_static", "objc", "serde", @@ -6332,7 +6444,9 @@ dependencies = [ "specta-typescript", "tauri", "tauri-plugin", + "tauri-plugin-clipboard-manager", "tauri-specta", + "tracing", "windows 0.52.0", ] @@ -7719,6 +7833,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -7772,6 +7896,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -7820,6 +7957,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -7842,6 +7990,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -7906,6 +8065,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7924,6 +8092,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -8308,6 +8486,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6f6aa0f..c40ad1f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,7 +3,7 @@ "identifier": "default", "description": "Minimum permissions required by the FlashVoice desktop windows", "windows": ["*"], - "platforms": ["macOS", "windows"], + "platforms": ["macOS", "windows", "linux"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/crates/audio/src/speaker/linux.rs b/src-tauri/crates/audio/src/speaker/linux.rs index 213b30e..a9b0a68 100644 --- a/src-tauri/crates/audio/src/speaker/linux.rs +++ b/src-tauri/crates/audio/src/speaker/linux.rs @@ -1,14 +1,15 @@ +use anyhow::Result; use futures_util::Stream; pub struct SpeakerInput {} impl SpeakerInput { - pub fn new(_sample_rate_override: Option) -> Self { - Self {} + pub fn new(_sample_rate_override: Option) -> Result { + Ok(Self {}) } - pub fn stream(self) -> SpeakerStream { - SpeakerStream::new() + pub fn stream(self) -> Result { + Ok(SpeakerStream::new()) } } diff --git a/src-tauri/crates/audio/src/speaker/mod.rs b/src-tauri/crates/audio/src/speaker/mod.rs index 33729a3..9acb5d4 100644 --- a/src-tauri/crates/audio/src/speaker/mod.rs +++ b/src-tauri/crates/audio/src/speaker/mod.rs @@ -54,7 +54,13 @@ impl SpeakerInput { Ok(SpeakerStream { inner }) } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + pub fn stream(self) -> Result { + let inner = self.inner.stream()?; + Ok(SpeakerStream { inner }) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] pub fn stream(self) -> Result { Err(anyhow::anyhow!( "'SpeakerInput::stream' is not supported on this platform" @@ -74,25 +80,26 @@ impl Stream for SpeakerStream { mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - #[cfg(any(target_os = "macos", target_os = "windows"))] + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] { self.inner.poll_next_unpin(cx) } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { + let _ = (self, cx); std::task::Poll::Pending } } } impl crate::AsyncSource for SpeakerStream { - #[cfg(any(target_os = "macos", target_os = "windows"))] + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] fn sample_rate(&self) -> u32 { self.inner.sample_rate() } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] fn sample_rate(&self) -> u32 { 0 } diff --git a/src-tauri/plugins/keyhook/Cargo.toml b/src-tauri/plugins/keyhook/Cargo.toml index 5007da5..f8d237b 100644 --- a/src-tauri/plugins/keyhook/Cargo.toml +++ b/src-tauri/plugins/keyhook/Cargo.toml @@ -40,3 +40,6 @@ core-graphics = "0.23" core-foundation = "0.9" cocoa = "0.25" objc = "0.2" + +[target.'cfg(target_os = "linux")'.dependencies] +handy-keys = "0.3.4" diff --git a/src-tauri/plugins/keyhook/src/lib.rs b/src-tauri/plugins/keyhook/src/lib.rs index 364368c..2e617e4 100644 --- a/src-tauri/plugins/keyhook/src/lib.rs +++ b/src-tauri/plugins/keyhook/src/lib.rs @@ -9,6 +9,9 @@ mod windows; #[cfg(target_os = "macos")] mod macos; +#[cfg(target_os = "linux")] +mod linux; + #[derive(Clone, serde::Serialize, specta::Type, tauri_specta::Event)] #[serde(rename_all = "camelCase")] /// Platform-neutral keyboard event emitted by the native key hook. @@ -50,6 +53,9 @@ fn start_listen(app: tauri::AppHandle) { #[cfg(target_os = "macos")] macos::start_hook_thread(); + + #[cfg(target_os = "linux")] + linux::start_hook_thread(); } #[tauri::command] @@ -68,6 +74,9 @@ fn stop_listen() { #[cfg(target_os = "macos")] macos::stop_hook_thread(); + #[cfg(target_os = "linux")] + linux::stop_hook_thread(); + if let Ok(mut guard) = APP_HANDLE.lock() { *guard = None; } diff --git a/src-tauri/plugins/keyhook/src/linux.rs b/src-tauri/plugins/keyhook/src/linux.rs new file mode 100644 index 0000000..6ae804e --- /dev/null +++ b/src-tauri/plugins/keyhook/src/linux.rs @@ -0,0 +1,172 @@ +use crate::{KeyEventPayload, APP_HANDLE, IS_LISTENING, SHOULD_STOP}; +use handy_keys::{Key, KeyboardListener, Modifiers}; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; +use tauri_specta::Event; + +pub fn start_hook_thread() { + thread::spawn(|| { + tracing::info!("[keyhook-linux] starting handy-keys KeyboardListener thread"); + let listener = match KeyboardListener::new() { + Ok(l) => l, + Err(e) => { + tracing::error!( + %e, + "[keyhook-linux] failed to create handy-keys KeyboardListener. Check if user is in 'input' group: sudo usermod -aG input $USER or configure udev rule" + ); + IS_LISTENING.store(false, Ordering::SeqCst); + return; + } + }; + + while !SHOULD_STOP.load(Ordering::SeqCst) { + match listener.recv_timeout(Duration::from_millis(100)) { + Ok(event) => { + let event_type = if event.is_key_down { "down" } else { "up" }; + + // Determine keycode + let keycode = if let Some(key) = event.key { + map_key_to_name(key) + } else if let Some(m) = event.changed_modifier { + map_modifier_to_name(m) + } else { + None + }; + + if let Some(code_str) = keycode { + let payload = KeyEventPayload { + keycode: code_str, + raw_code: 0, + event_type: event_type.to_string(), + }; + + if let Ok(guard) = APP_HANDLE.lock() { + if let Some(app) = guard.as_ref() { + if let Err(err) = payload.emit(app) { + tracing::error!(%err, "[keyhook-linux] failed to emit KeyEventPayload"); + } + } + } + } + } + Err(handy_keys::Error::Timeout) => { + continue; + } + Err(e) => { + tracing::warn!(%e, "[keyhook-linux] handy-keys error in listener loop"); + break; + } + } + } + IS_LISTENING.store(false, Ordering::SeqCst); + tracing::info!("[keyhook-linux] handy-keys KeyboardListener thread stopped"); + }); +} + +pub fn stop_hook_thread() { + tracing::debug!("[keyhook-linux] requested hook thread to stop"); +} + +fn map_key_to_name(key: Key) -> Option { + match key { + Key::A => Some("A".to_string()), + Key::B => Some("B".to_string()), + Key::C => Some("C".to_string()), + Key::D => Some("D".to_string()), + Key::E => Some("E".to_string()), + Key::F => Some("F".to_string()), + Key::G => Some("G".to_string()), + Key::H => Some("H".to_string()), + Key::I => Some("I".to_string()), + Key::J => Some("J".to_string()), + Key::K => Some("K".to_string()), + Key::L => Some("L".to_string()), + Key::M => Some("M".to_string()), + Key::N => Some("N".to_string()), + Key::O => Some("O".to_string()), + Key::P => Some("P".to_string()), + Key::Q => Some("Q".to_string()), + Key::R => Some("R".to_string()), + Key::S => Some("S".to_string()), + Key::T => Some("T".to_string()), + Key::U => Some("U".to_string()), + Key::V => Some("V".to_string()), + Key::W => Some("W".to_string()), + Key::X => Some("X".to_string()), + Key::Y => Some("Y".to_string()), + Key::Z => Some("Z".to_string()), + Key::Num0 => Some("0".to_string()), + Key::Num1 => Some("1".to_string()), + Key::Num2 => Some("2".to_string()), + Key::Num3 => Some("3".to_string()), + Key::Num4 => Some("4".to_string()), + Key::Num5 => Some("5".to_string()), + Key::Num6 => Some("6".to_string()), + Key::Num7 => Some("7".to_string()), + Key::Num8 => Some("8".to_string()), + Key::Num9 => Some("9".to_string()), + Key::Space => Some("Space".to_string()), + Key::Return => Some("Enter".to_string()), + Key::Tab => Some("Tab".to_string()), + Key::Escape => Some("Escape".to_string()), + Key::Delete => Some("Backspace".to_string()), + Key::ForwardDelete => Some("Delete".to_string()), + Key::UpArrow => Some("ArrowUp".to_string()), + Key::DownArrow => Some("ArrowDown".to_string()), + Key::LeftArrow => Some("ArrowLeft".to_string()), + Key::RightArrow => Some("ArrowRight".to_string()), + Key::PageUp => Some("PageUp".to_string()), + Key::PageDown => Some("PageDown".to_string()), + Key::Home => Some("Home".to_string()), + Key::End => Some("End".to_string()), + Key::F1 => Some("F1".to_string()), + Key::F2 => Some("F2".to_string()), + Key::F3 => Some("F3".to_string()), + Key::F4 => Some("F4".to_string()), + Key::F5 => Some("F5".to_string()), + Key::F6 => Some("F6".to_string()), + Key::F7 => Some("F7".to_string()), + Key::F8 => Some("F8".to_string()), + Key::F9 => Some("F9".to_string()), + Key::F10 => Some("F10".to_string()), + Key::F11 => Some("F11".to_string()), + Key::F12 => Some("F12".to_string()), + Key::Minus => Some("-".to_string()), + Key::Equal => Some("=".to_string()), + Key::LeftBracket => Some("[".to_string()), + Key::RightBracket => Some("]".to_string()), + Key::Backslash => Some("\\".to_string()), + Key::Semicolon => Some(";".to_string()), + Key::Quote => Some("'".to_string()), + Key::Comma => Some(",".to_string()), + Key::Period => Some(".".to_string()), + Key::Slash => Some("/".to_string()), + Key::Grave => Some("`".to_string()), + _ => None, + } +} + +fn map_modifier_to_name(m: Modifiers) -> Option { + if m.contains(Modifiers::CTRL_LEFT) { + Some("LeftCtrl".to_string()) + } else if m.contains(Modifiers::CTRL_RIGHT) { + Some("RightCtrl".to_string()) + } else if m.contains(Modifiers::SHIFT_LEFT) { + Some("LeftShift".to_string()) + } else if m.contains(Modifiers::SHIFT_RIGHT) { + Some("RightShift".to_string()) + } else if m.contains(Modifiers::OPT_LEFT) { + Some("LeftAlt".to_string()) + } else if m.contains(Modifiers::OPT_RIGHT) { + Some("RightAlt".to_string()) + } else if m.contains(Modifiers::CMD_LEFT) { + Some("LeftWindows".to_string()) + } else if m.contains(Modifiers::CMD_RIGHT) { + Some("RightWindows".to_string()) + } else if m.contains(Modifiers::FN) { + Some("Fn".to_string()) + } else { + None + } +} diff --git a/src-tauri/plugins/textinject/Cargo.toml b/src-tauri/plugins/textinject/Cargo.toml index 2d46626..77e1bc9 100644 --- a/src-tauri/plugins/textinject/Cargo.toml +++ b/src-tauri/plugins/textinject/Cargo.toml @@ -20,6 +20,10 @@ tauri = { workspace = true } serde = { workspace = true } tauri-specta = { workspace = true, features = ["derive", "typescript"] } specta = { workspace = true } +tracing = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +tauri-plugin-clipboard-manager = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.52", features = [ diff --git a/src-tauri/plugins/textinject/src/lib.rs b/src-tauri/plugins/textinject/src/lib.rs index 046a484..6f61966 100644 --- a/src-tauri/plugins/textinject/src/lib.rs +++ b/src-tauri/plugins/textinject/src/lib.rs @@ -13,11 +13,15 @@ use core_graphics::event::{CGEvent, CGEventTapLocation}; #[cfg(target_os = "macos")] use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +#[cfg(target_os = "linux")] +mod linux; + #[tauri::command] #[specta::specta] -fn type_text(text: String) -> Result<(), String> { +fn type_text(app: tauri::AppHandle, text: String) -> Result<(), String> { #[cfg(target_os = "windows")] { + let _ = &app; let mut inputs = Vec::new(); for ch in text.encode_utf16() { // Key down @@ -62,6 +66,7 @@ fn type_text(text: String) -> Result<(), String> { #[cfg(target_os = "macos")] { + let _ = &app; let sanitized_text = text.replace(['\n', '\r'], " "); let source = CGEventSource::new(CGEventSourceStateID::Private) @@ -73,8 +78,14 @@ fn type_text(text: String) -> Result<(), String> { Ok(()) } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[cfg(target_os = "linux")] + { + linux::type_text_linux(&app, &text) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { + let _ = &app; Err("Unsupported platform".to_string()) } } diff --git a/src-tauri/plugins/textinject/src/linux.rs b/src-tauri/plugins/textinject/src/linux.rs new file mode 100644 index 0000000..d10e80a --- /dev/null +++ b/src-tauri/plugins/textinject/src/linux.rs @@ -0,0 +1,309 @@ +// Portions of this implementation are derived from Handy (https://github.com/cjpais/handy) +// Copyright (c) 2025 CJ Pais, licensed under the MIT License. + +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; +use std::time::Duration; +use tauri_plugin_clipboard_manager::ClipboardExt; +use tracing::{info, warn}; + +/// Check if using the Wayland display server protocol +pub fn is_wayland() -> bool { + std::env::var("WAYLAND_DISPLAY").is_ok() + || std::env::var("XDG_SESSION_TYPE") + .map(|v| v.to_lowercase() == "wayland") + .unwrap_or(false) +} + +/// Check if running on KDE Plasma desktop environment +pub fn is_kde_plasma() -> bool { + std::env::var("XDG_CURRENT_DESKTOP") + .map(|v| v.to_uppercase().contains("KDE")) + .unwrap_or(false) + || std::env::var("KDE_SESSION_VERSION").is_ok() +} + +/// Check if running on KDE Plasma with Wayland +pub fn is_kde_wayland() -> bool { + is_wayland() && is_kde_plasma() +} + +/// Check if running on GNOME desktop environment +pub fn is_gnome() -> bool { + std::env::var("XDG_CURRENT_DESKTOP") + .map(|v| v.to_uppercase().contains("GNOME")) + .unwrap_or(false) +} + +/// Check if running on GNOME with Wayland +pub fn is_gnome_wayland() -> bool { + is_wayland() && is_gnome() +} + +/// Check if a binary command is available in PATH +fn is_command_available(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +pub fn is_wtype_available() -> bool { + is_command_available("wtype") +} + +pub fn is_ydotool_available() -> bool { + is_command_available("ydotool") +} + +pub fn is_xdotool_available() -> bool { + is_command_available("xdotool") +} + +pub fn is_wl_copy_available() -> bool { + is_command_available("wl-copy") +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum YdotoolKeySyntax { + Symbolic, + RawKeycodes, +} + +const YDOTOOL_UNKNOWN_HELP_FALLBACK: YdotoolKeySyntax = YdotoolKeySyntax::RawKeycodes; +static YDOTOOL_KEY_SYNTAX: OnceLock = OnceLock::new(); + +/// Prepares a Command for ydotool, automatically detecting daemon socket if not already set. +fn prepare_ydotool_command(args: &[&str]) -> Command { + let mut cmd = Command::new("ydotool"); + cmd.args(args); + + if std::env::var_os("YDOTOOL_SOCKET").is_none() { + // 1. Check NixOS systemd service path + if Path::new("/run/ydotoold/socket").exists() { + cmd.env("YDOTOOL_SOCKET", "/run/ydotoold/socket"); + } else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + // 2. Check standard user runtime path + let user_sock = Path::new(&runtime_dir).join(".ydotool_socket"); + if user_sock.exists() { + cmd.env("YDOTOOL_SOCKET", user_sock); + } + } + } + + cmd +} + +/// Classifies `ydotool key --help` output without relying on version or distro metadata. +fn classify_ydotool_key_syntax(help: &str) -> Option { + let help = help.to_ascii_lowercase(); + + if help.contains("syntax: :") + || help.contains("[keycodes]") + || help.contains("using raw keycodes") + { + Some(YdotoolKeySyntax::RawKeycodes) + } else if help.contains("separated by plus (+)") + || (help.contains("") && help.contains("alt+r")) + { + Some(YdotoolKeySyntax::Symbolic) + } else { + None + } +} + +/// Detects and caches a recognized ydotool key syntax. +fn detect_ydotool_key_syntax() -> YdotoolKeySyntax { + if let Some(syntax) = YDOTOOL_KEY_SYNTAX.get() { + return *syntax; + } + + match prepare_ydotool_command(&["key", "--help"]).output() { + Ok(output) => { + let mut help = String::from_utf8_lossy(&output.stdout).into_owned(); + if !help.is_empty() && !output.stderr.is_empty() { + help.push('\n'); + } + help.push_str(&String::from_utf8_lossy(&output.stderr)); + + if let Some(syntax) = classify_ydotool_key_syntax(&help) { + *YDOTOOL_KEY_SYNTAX.get_or_init(|| { + info!("Detected ydotool key syntax: {:?}", syntax); + syntax + }) + } else { + warn!( + "Could not recognize ydotool key --help output (exit status {:?}); using raw-keycode syntax", + output.status.code() + ); + YDOTOOL_UNKNOWN_HELP_FALLBACK + } + } + Err(error) => { + warn!( + "Could not query ydotool key syntax: {}; using raw-keycode syntax", + error + ); + YDOTOOL_UNKNOWN_HELP_FALLBACK + } + } +} + +/// Write text to clipboard via wl-copy (Wayland clipboard tool). +/// Uses Stdio::null() to avoid blocking on repeated calls — wl-copy forks a +/// daemon that inherits piped fds, causing read_to_end to hang indefinitely. +fn write_clipboard_via_wl_copy(text: &str) -> Result<(), String> { + let status = Command::new("wl-copy") + .arg("--") + .arg(text) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| format!("Failed to execute wl-copy: {}", e))?; + + if !status.success() { + return Err("wl-copy failed".into()); + } + + Ok(()) +} + +/// Write text to the system clipboard using the best available method. +fn write_text_to_clipboard(app: &tauri::AppHandle, text: &str) -> Result<(), String> { + if is_wayland() && is_wl_copy_available() { + info!("Using wl-copy for clipboard write on Wayland"); + return write_clipboard_via_wl_copy(text); + } + + app.clipboard() + .write_text(text) + .map_err(|e| format!("Failed to write to clipboard: {}", e)) +} + +/// Read text from the system clipboard. +fn read_text_from_clipboard(app: &tauri::AppHandle) -> Option { + app.clipboard().read_text().ok().filter(|t| !t.is_empty()) +} + +/// Send Ctrl+V key combination via ydotool. +fn send_paste_via_ydotool() -> Result<(), String> { + let syntax = detect_ydotool_key_syntax(); + let args: &[&str] = match syntax { + YdotoolKeySyntax::Symbolic => &["key", "ctrl+v"], + // 29: KEY_LEFTCTRL, 47: KEY_V, -d 12 ensures keys are held long enough for compositor registration + YdotoolKeySyntax::RawKeycodes => &["key", "-d", "12", "29:1", "47:1", "47:0", "29:0"], + }; + + let output = prepare_ydotool_command(args) + .output() + .map_err(|e| format!("Failed to execute ydotool: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!( + "ydotool paste failed (exit {:?}): {}", + output.status.code(), + stderr.trim() + ); + return Err(format!("ydotool paste failed: {}", stderr.trim())); + } + + info!("ydotool paste command completed successfully"); + Ok(()) +} + +/// Send Ctrl+V key combination via wtype on Wayland. +fn send_paste_via_wtype() -> Result<(), String> { + let output = Command::new("wtype") + .args(["-M", "ctrl", "-k", "v", "-m", "ctrl"]) + .output() + .map_err(|e| format!("Failed to execute wtype: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("wtype paste failed: {}", stderr.trim())); + } + + Ok(()) +} + +/// Send Ctrl+V key combination via xdotool on X11. +fn send_paste_via_xdotool() -> Result<(), String> { + let output = Command::new("xdotool") + .args(["key", "--clearmodifiers", "ctrl+v"]) + .output() + .map_err(|e| format!("Failed to execute xdotool: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("xdotool paste failed: {}", stderr.trim())); + } + + Ok(()) +} + +/// Attempts to send Ctrl+V key combination using available Linux tools. +fn send_paste_key_combo() -> Result<(), String> { + if is_wayland() { + // Note: wtype doesn't work on KDE or GNOME/Mutter (protocol not implemented) + if !is_kde_wayland() && !is_gnome_wayland() && is_wtype_available() { + info!("Using wtype for paste key combo"); + return send_paste_via_wtype(); + } + if is_ydotool_available() { + info!("Using ydotool for paste key combo"); + return send_paste_via_ydotool(); + } + if is_xdotool_available() { + // Some Wayland compositors support XWayland focus + info!("Falling back to xdotool for paste key combo"); + return send_paste_via_xdotool(); + } + } else { + // X11: prefer xdotool, then ydotool + if is_xdotool_available() { + info!("Using xdotool for paste key combo"); + return send_paste_via_xdotool(); + } + if is_ydotool_available() { + info!("Using ydotool for paste key combo"); + return send_paste_via_ydotool(); + } + } + + Err("No supported key injection tool found on Linux. Please install and enable ydotool or xdotool.".to_string()) +} + +/// Types text into active window by copying text to clipboard, sending Ctrl+V, +/// and safely restoring previous clipboard content in the background. +pub fn type_text_linux(app: &tauri::AppHandle, text: &str) -> Result<(), String> { + let saved_text = read_text_from_clipboard(app); + + // Write text to clipboard first + write_text_to_clipboard(app, text)?; + + // Short pause for clipboard to populate and window focus/key release to settle + std::thread::sleep(Duration::from_millis(100)); + + // Send paste shortcut + let paste_result = send_paste_key_combo(); + + // Safely restore previous clipboard in a background thread after the target application + // has completed reading the Wayland pipe (safe 1000ms delay avoids race conditions). + if paste_result.is_ok() { + if let Some(orig) = saved_text { + if !orig.is_empty() && orig != text { + let app_clone = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1000)); + let _ = write_text_to_clipboard(&app_clone, &orig); + }); + } + } + } + + paste_result +} diff --git a/src-tauri/plugins/trayv2/src/ext.rs b/src-tauri/plugins/trayv2/src/ext.rs index 46122fb..a727955 100644 --- a/src-tauri/plugins/trayv2/src/ext.rs +++ b/src-tauri/plugins/trayv2/src/ext.rs @@ -209,10 +209,8 @@ impl> Trayv2PluginExt for T { #[cfg(target_os = "macos")] let icon = Image::from_bytes(include_bytes!("../icons/tray_macos.png"))?; - #[cfg(target_os = "windows")] + #[cfg(not(target_os = "macos"))] let icon = Image::from_bytes(include_bytes!("../icons/tray_windows.png"))?; - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - let icon = Image::from_bytes(include_bytes!("../icons/tray_macos.png"))?; let mut builder = TrayIconBuilder::with_id(TRAY_ID) .icon(icon) diff --git a/src-tauri/plugins/windows/src/commands.rs b/src-tauri/plugins/windows/src/commands.rs index 987adbd..4718a73 100644 --- a/src-tauri/plugins/windows/src/commands.rs +++ b/src-tauri/plugins/windows/src/commands.rs @@ -175,7 +175,10 @@ pub async fn window_set_dock_visibility( window: FlashVoiceWindow, visible: bool, ) -> Result<(), String> { - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let _ = (&app, &window, visible); + + #[cfg(target_os = "macos")] let _ = &window; #[cfg(target_os = "macos")] diff --git a/src-tauri/plugins/windows/src/ext.rs b/src-tauri/plugins/windows/src/ext.rs index 3799a29..f4e8d1c 100644 --- a/src-tauri/plugins/windows/src/ext.rs +++ b/src-tauri/plugins/windows/src/ext.rs @@ -99,7 +99,7 @@ impl FlashVoiceWindow { pub fn get_min_size(&self) -> LogicalSize { match self { Self::Main => LogicalSize::new(1000.0, 700.0), - Self::Indicator => LogicalSize::new(280.0, 34.0), + Self::Indicator => LogicalSize::new(120.0, 34.0), } } @@ -109,9 +109,14 @@ impl FlashVoiceWindow { pos: KnownPosition, ) -> Result<(), crate::Error> { if let Some(window) = self.get(app) { - let monitor = window - .current_monitor()? - .ok_or(crate::Error::MonitorNotFound)?; + let monitor = match window.current_monitor()? { + Some(m) => Some(m), + None => match window.primary_monitor()? { + Some(m) => Some(m), + None => window.available_monitors()?.into_iter().next(), + }, + } + .ok_or(crate::Error::MonitorNotFound)?; let monitor_size = monitor.size(); let window_size = window.outer_size()?; @@ -143,9 +148,18 @@ impl FlashVoiceWindow { } fn position_indicator_bottom_center(&self, window: &WebviewWindow) -> Result<(), crate::Error> { - let monitor = window - .current_monitor()? - .ok_or(crate::Error::MonitorNotFound)?; + let monitor = match window.current_monitor()? { + Some(m) => Some(m), + None => match window.primary_monitor()? { + Some(m) => Some(m), + None => window.available_monitors()?.into_iter().next(), + }, + }; + + let Some(monitor) = monitor else { + tracing::debug!("No monitor found when positioning indicator"); + return Ok(()); + }; let scale_factor = monitor.scale_factor(); let work_area = monitor.work_area(); @@ -167,7 +181,7 @@ impl FlashVoiceWindow { indicator_y = position.y, "Positioned the live transcription indicator within the monitor work area" ); - window.set_position(position)?; + let _ = window.set_position(position); Ok(()) } @@ -225,7 +239,7 @@ impl FlashVoiceWindow { window.set_size(default_size)?; window.set_min_size(Some(min_size))?; - if cfg!(target_os = "windows") { + if cfg!(any(target_os = "windows", target_os = "linux")) { window.set_decorations(false)?; window.set_shadow(true)?; } @@ -233,19 +247,21 @@ impl FlashVoiceWindow { window.center()?; } Self::Indicator => { + let _ = window.set_size(default_size); + let _ = window.set_min_size(Some(min_size)); + let _ = window.set_decorations(false); + let _ = window.set_shadow(false); + let _ = window.set_always_on_top(true); + let _ = window.set_skip_taskbar(true); + let _ = window.set_focusable(false); + #[cfg(not(target_os = "linux"))] window.set_resizable(false)?; - window.set_size(default_size)?; - window.set_min_size(Some(min_size))?; - window.set_decorations(false)?; - window.set_shadow(false)?; - window.set_always_on_top(true)?; - window.set_skip_taskbar(true)?; } }; } if self == &Self::Indicator { - self.position_indicator_bottom_center(&window)?; + let _ = self.position_indicator_bottom_center(&window); } // Skip focus for indicator to avoid stealing focus from other apps @@ -287,6 +303,13 @@ impl FlashVoiceWindow { .skip_taskbar(true); } + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + if self == &Self::Main { + builder = builder.decorations(false); + } + } + #[cfg(target_os = "macos")] { if self == &Self::Main { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e11e2c4..46a2580 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,16 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + #[cfg(target_os = "linux")] + { + if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + } + if let Err(error) = flashvoice_lib::main() { eprintln!("FlashVoice failed to start: {error}"); std::process::exit(1); diff --git a/src/components/custom/wx-app-titlebar.vue b/src/components/custom/wx-app-titlebar.vue index b0a7877..24294b8 100644 --- a/src/components/custom/wx-app-titlebar.vue +++ b/src/components/custom/wx-app-titlebar.vue @@ -25,7 +25,7 @@ onMounted(async () => { isMaximized.value = is_maximized; } }); - isWindows.value = appInfoStore.isWindows; + isWindows.value = appInfoStore.hasCustomTitlebar; }); onUnmounted(() => { @@ -38,7 +38,7 @@ onUnmounted(() => {