diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 05079cf..73d5915 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -339,8 +339,37 @@ jobs: throw "MSI installer smoke failed with exit $LASTEXITCODE" } - # ── Linux:产 deb / rpm / AppImage ── - # bundle.resources 里的 Windows TSF DLL 占位对 Linux 包没意义,用空 map 覆盖跳过。 + # ── Linux:先编译 fcitx5 插件,再产 deb / rpm / AppImage ── + - name: Build fcitx5 plugin + if: matrix.platform == 'ubuntu-22.04' + shell: bash + working-directory: 'openless-all/scripts/linux-fcitx5-plugin' + run: | + sudo apt-get install -y cmake fcitx5-dev + mkdir -p build && cd build + cmake .. 2>&1 + make + # 从 cmake 缓存中读出 distro 实际的插件安装路径(multiarch 感知)。 + FCITX_ADDON_DIR=$(cmake -LA . 2>/dev/null \ + | grep "^FCITX_INSTALL_ADDONDIR:" \ + | cut -d= -f2) + FCITX_PKGDATA_DIR=$(cmake -LA . 2>/dev/null \ + | grep "^FCITX_INSTALL_PKGDATADIR:" \ + | cut -d= -f2) + echo "Detected: addon=$FCITX_ADDON_DIR pkgdata=$FCITX_PKGDATA_DIR" + echo "FCITX_ADDON_DIR=$FCITX_ADDON_DIR" >> "$GITHUB_ENV" + echo "FCITX_ADDON_CONF_DIR=${FCITX_PKGDATA_DIR}/addon" >> "$GITHUB_ENV" + # 对 RPM 目标映射路径:Debian multiarch(如 /usr/lib/x86_64-linux-gnu) + # -> /usr/lib64(RPM 标准)。conf 路径跨发行版一致。 + RPM_ADDON_DIR=$(echo "$FCITX_ADDON_DIR" \ + | sed 's|/usr/lib/[^/]*/fcitx5|/usr/lib64/fcitx5|;s|/usr/lib/x86_64-linux-gnu/fcitx5|/usr/lib64/fcitx5|') + echo "FCITX_RPM_ADDON_DIR=$RPM_ADDON_DIR" >> "$GITHUB_ENV" + # 把插件 .so + .conf 复制到 src-tauri/linux-fcitx5-plugin/ 下面, + # 供 tauri deb/rpm bundler 的 files 配置使用。 + mkdir -p "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin" + cp libopenless.so "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/" + cp openless.conf "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/" + - name: Build (Linux) if: matrix.platform == 'ubuntu-22.04' shell: bash @@ -349,13 +378,40 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | + # 构建带 fcitx5 插件的 deb/rpm。AppImage 不含插件(无法安装系统路径), + # 用户需手动安装脚本 scripts/linux-fcitx5-plugin/build.sh 输出的 .so 和 .conf。 + # 插件 .so + .conf 由上一步 Build fcitx5 plugin 生成并复制到 + # src-tauri/linux-fcitx5-plugin/ 下。 + cat > /tmp/tauri-linux-config.json << CONFIG_EOF + { + "bundle": { + "resources": {}, + "linux": { + "deb": { + "depends": ["fcitx5", "fcitx5-module-dbus", "libdbus-1-3"], + "files": { + "${FCITX_ADDON_DIR}/libopenless.so": "linux-fcitx5-plugin/libopenless.so", + "${FCITX_ADDON_CONF_DIR}/openless.conf": "linux-fcitx5-plugin/openless.conf" + } + }, + "rpm": { + "depends": ["fcitx5", "fcitx5-module-dbus"], + "files": { + "${FCITX_RPM_ADDON_DIR}/libopenless.so": "linux-fcitx5-plugin/libopenless.so", + "${FCITX_ADDON_CONF_DIR}/openless.conf": "linux-fcitx5-plugin/openless.conf" + } + } + } + } + } + CONFIG_EOF if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then - npm run tauri -- build --bundles deb,rpm,appimage \ - --config '{"bundle":{"resources":{},"createUpdaterArtifacts":true}}' + jq '.bundle.createUpdaterArtifacts = true' /tmp/tauri-linux-config.json > /tmp/tauri-linux-config-signed.json + CONFIG_FILE=/tmp/tauri-linux-config-signed.json else - npm run tauri -- build --bundles deb,rpm,appimage \ - --config '{"bundle":{"resources":{}}}' + CONFIG_FILE=/tmp/tauri-linux-config.json fi + npm run tauri -- build --bundles deb,rpm,appimage --config "$CONFIG_FILE" - name: Disambiguate macOS updater bundle filename if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != '' diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 6933372..705ee46 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -409,12 +409,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -759,21 +753,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cocoa" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667fdc068627a2816b9ff831201dd9864249d6ee8d190b9532357f1fc0f61ea7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics 0.21.0", - "foreign-types 0.3.2", - "libc", - "objc", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -815,23 +794,13 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" -dependencies = [ - "core-foundation-sys 0.7.0", - "libc", -] - [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", ] @@ -841,46 +810,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", ] -[[package]] -name = "core-foundation-sys" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" - [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.7.0", - "foreign-types 0.3.2", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a67c4378cf203eace8fb6567847eb641fd6ff933c1145a115c6ee820ebb978" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "foreign-types 0.3.2", - "libc", -] - [[package]] name = "core-graphics" version = "0.23.2" @@ -949,7 +888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" dependencies = [ "bitflags 1.3.2", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "coreaudio-sys", ] @@ -969,7 +908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "coreaudio-rs", "dasp_sample", "jni 0.21.1", @@ -2414,7 +2353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", @@ -2871,12 +2810,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -3027,15 +2960,6 @@ dependencies = [ "libc", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -3391,15 +3315,6 @@ dependencies = [ "libc", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc-sys" version = "0.3.5" @@ -3762,6 +3677,7 @@ dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", "cpal", + "dbus", "enigo", "env_logger", "ferrous-opencc", @@ -3777,7 +3693,6 @@ dependencies = [ "once_cell", "parking_lot", "raw-window-handle", - "rdev", "reqwest 0.12.28", "serde", "serde_json", @@ -4444,22 +4359,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rdev" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" -dependencies = [ - "cocoa", - "core-foundation 0.7.0", - "core-foundation-sys 0.7.0", - "core-graphics 0.19.2", - "lazy_static", - "libc", - "winapi", - "x11", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -4785,7 +4684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "jni 0.22.4", "log", "once_cell", @@ -4930,7 +4829,7 @@ checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.11.1", "core-foundation 0.9.4", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", "security-framework-sys", ] @@ -4943,7 +4842,7 @@ checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", "core-foundation 0.10.1", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", "security-framework-sys", ] @@ -4954,7 +4853,7 @@ version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", ] @@ -5452,7 +5351,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", ] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 5600414..aaa89e2 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -50,7 +50,6 @@ global-hotkey = "0.6" cpal = "0.15" enigo = "0.2" arboard = "3" -rdev = "0.5" [target.'cfg(target_os = "macos")'.dependencies.keyring] version = "3.6.3" @@ -62,6 +61,8 @@ version = "3.6.3" default-features = false features = ["windows-native"] +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "0.9" [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies.keyring] version = "3.6.3" default-features = false diff --git a/openless-all/app/src-tauri/src/cli.rs b/openless-all/app/src-tauri/src/cli.rs index a5b1199..e0d00f9 100644 --- a/openless-all/app/src-tauri/src/cli.rs +++ b/openless-all/app/src-tauri/src/cli.rs @@ -1,9 +1,6 @@ //! 极简 CLI 参数解析 — 用于支持桌面环境快捷键调起 OpenLess 触发听写 / QA。 //! -//! 这条路径的来历:Linux Wayland 协议层面禁止"应用监听全局键盘"(除了焦点窗口), -//! 因此 rdev 在 Wayland 上必然失效(issue #420)。本仓库不为 Wayland 引入门户 -//! GlobalShortcuts(GNOME 尚未原生落地,引入会增加合成器分裂的维护负担——见 -//! `docs/issue-420-wayland-hotkey-research.md` 3.1 节),改走桌面环境快捷键 → +//! 这条路径的来历:Linux 上 fcitx5 插件提供了热键 + 文字提交的完整方案, //! `openless --toggle-dictation` → tauri-plugin-single-instance 转发的 CLI 路径。 //! macOS / Windows 上仍走原生 hotkey 监听器,CLI 是补充而非替代。 //! diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 618da13..8dfc0be 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -387,18 +387,6 @@ pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability { coord.hotkey_capability() } -/// Pull-style 查询:当前是否处于 Linux/Wayland session(rdev 不可用、需要走 CLI 路径)。 -/// 前端 RecordingSection mount 时调一次拿状态,直接渲染 callout。 -/// -/// 用 pull 而不是单纯依赖 ready-time 的 `wayland_cli_mode` event:Settings 模态是 -/// 条件渲染(用户首次打开 Settings 才 mount RecordingSection),但 emit 发生在 setup -/// 末尾——一次性 event 不缓冲也不 replay,listener 99% 情况下错过事件 → callout -/// 永远不显示。XDG_SESSION_TYPE 本身在进程生命周期内不会变,多次调用结果一致。 -#[tauri::command] -pub fn is_wayland_cli_mode() -> bool { - crate::hotkey::is_wayland_session() -} - #[tauri::command] pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) { coord.set_shortcut_recording_active(active); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c2c68f3..f592b16 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -479,6 +479,8 @@ impl Coordinator { .name("openless-combo-hotkey-bridge".into()) .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) .ok(); + #[cfg(target_os = "linux")] + sync_custom_dictation_to_plugin(&inner_clone); } Err(e) => { log::warn!("[coord] update combo hotkey binding 失败: {e}"); @@ -698,10 +700,20 @@ impl Coordinator { fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { + #[cfg(target_os = "linux")] + let plugin_binding = binding.clone(); monitor.update_binding(binding); + #[cfg(target_os = "linux")] + if plugin_binding.trigger == crate::types::HotkeyTrigger::Custom { + sync_custom_dictation_to_plugin(&self.inner); + } else { + crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); + } return; } let (tx, rx) = mpsc::channel::(); + #[cfg(target_os = "linux")] + let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { let adapter = monitor.kind(); @@ -717,6 +729,16 @@ impl Coordinator { .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); + // Linux: 启动 fcitx5 插件信号监听作为热键源。 + #[cfg(target_os = "linux")] + { + crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); + if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { + sync_custom_dictation_to_plugin(&self.inner); + } else { + crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + } + } } Err(e) => { *self.inner.hotkey_status.lock() = HotkeyStatus { @@ -762,16 +784,14 @@ impl Coordinator { /// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。 /// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start, - /// Listening → stop。Linux/Wayland 下桌面快捷键 → CLI 转发是唯一触发路径, - /// 必须复用这套语义。 + /// Listening → stop。可用于桌面快捷键 → CLI 转发的备用触发路径。 pub fn dictation_phase_for_cli(&self) -> SessionPhase { self.inner.state.lock().phase } /// CLI 入口的 QA toggle:直接复用 modifier-only QA 热键边沿的处理函数。 /// 与 `handle_qa_hotkey_pressed` 同语义 — Idle → 开浮窗 / Recording → 收尾 / - /// Processing → 忽略。Wayland 下没有 modifier-only / global-hotkey 监听,CLI - /// 是唯一进入点。 + /// Processing → 忽略。桌面快捷键 → CLI 转发的备用进入点。 pub async fn cli_toggle_qa_panel(&self) { handle_qa_hotkey_pressed(&self.inner).await; } @@ -932,13 +952,29 @@ fn hotkey_supervisor_loop(inner: Arc) { if inner.hotkey.lock().is_some() { return; } + // Linux: 启动前检查 fcitx5 插件是否可用 + #[cfg(target_os = "linux")] + if !crate::linux_fcitx::available() { + *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, + state: HotkeyStatusState::Failed, + message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()), + last_error: Some(crate::types::HotkeyInstallError { + code: "fcitx5_unavailable".into(), + message: "fcitx5 插件 DBus 接口无响应".into(), + }), + }; + log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); + attempts += 1; + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } *inner.hotkey_status.lock() = HotkeyStatus { adapter: capability.adapter, state: HotkeyStatusState::Starting, message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)), last_error: None, }; - let (tx, rx) = mpsc::channel::(); let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) .unwrap_or(crate::types::HotkeyTrigger::Custom); let binding = crate::types::HotkeyBinding { @@ -946,6 +982,9 @@ fn hotkey_supervisor_loop(inner: Arc) { mode: prefs.hotkey.mode, keys: None, }; + let (tx, rx) = mpsc::channel::(); + #[cfg(target_os = "linux")] + let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { let adapter = monitor.kind(); @@ -969,6 +1008,16 @@ fn hotkey_supervisor_loop(inner: Arc) { .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); + // Linux: 启动 fcitx5 插件信号监听作为热键源。 + #[cfg(target_os = "linux")] + { + crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); + if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { + sync_custom_dictation_to_plugin(&inner); + } else { + crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + } + } return; } Err(e) => { @@ -1536,6 +1585,21 @@ fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") } +/// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。 +#[cfg(target_os = "linux")] +fn sync_custom_dictation_to_plugin(inner: &Arc) { + let prefs = inner.prefs.get(); + let dictation = &prefs.dictation_hotkey; + let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation); + if key_string.is_empty() { + return; + } + match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { + Ok(()) => log::info!("[fcitx] Synced custom dictation trigger '{}' to plugin", key_string), + Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), + } +} + fn modifier_shortcut_triggers( inner: &Arc, ) -> ( @@ -3600,7 +3664,7 @@ mod tests { #[test] fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, false, false), + dictation_error_code(InsertStatus::Failed, false, false, false), Some("focusRestoreFailed") ); } @@ -3617,7 +3681,7 @@ mod tests { #[cfg(target_os = "windows")] fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, true, false, false), + dictation_error_code(InsertStatus::Failed, false, true, false), Some("windowsImeTsfRequired") ); } @@ -4037,7 +4101,15 @@ fn show_capsule_window_no_activate( // `window.show()` 直接显示,再用 restore_main_window_key_if_active 把焦点还给 // 主窗口。这是 1.2.11 的实现 — 单独走 orderFrontRegardless 会让胶囊在 webview // 未完整初始化时偶发不可见。 -#[cfg(not(target_os = "windows"))] +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn show_capsule_window_no_activate( + _app: &AppHandle, + _window: &tauri::WebviewWindow, +) -> bool { + false +} + +#[cfg(target_os = "macos")] fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, @@ -4121,6 +4193,13 @@ fn emit_capsule( return; }; let show_capsule = inner_for_main.prefs.get().show_capsule; + // Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。 + // 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。 + #[cfg(target_os = "linux")] + { + return; + } + // 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast // (schedule_capsule_idle 之后会回 Idle 隐藏)。 // Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的 @@ -4129,6 +4208,7 @@ fn emit_capsule( maybe_position_capsule_bottom_center(&inner_for_main, &window, translation); if show_capsule && visible { if !show_capsule_window_no_activate(&app_for_main, &window) { + #[cfg(any(target_os = "macos", target_os = "windows"))] let _ = window.show(); } // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走主窗口点击焦点。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 0b74a10..56e661b 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -21,7 +21,7 @@ const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(25 /// - **Windows**:`switch_to_ascii` 是 no-op(SendInput Unicode 绕过 TSF); /// `type_unicode_chunk` 走 `SendInput(KEYEVENTF_UNICODE)`。 /// - **Linux(实验)**:`switch_to_ascii` 是 no-op;`type_unicode_chunk` 走 enigo -/// `Keyboard::text`。X11 / XTest 稳定,Wayland 看 compositor 给不给 libei 权限。 +/// `Keyboard::text`。X11 / XTest 稳定。 /// /// 通用流程: /// 1. `switch_to_ascii`(macOS)/ no-op(其他);失败则降级回一次性 `polish_or_passthrough`。 @@ -329,24 +329,10 @@ fn streaming_insert_eligible( translation_active: bool, mode: PolishMode, raw_uses_llm: bool, - wayland_session: bool, ) -> bool { streaming_insert_enabled && !translation_active && (mode != PolishMode::Raw || raw_uses_llm) - && !wayland_session -} - -fn wayland_done_message(status: InsertStatus, polish_failed: bool) -> Option { - match status { - InsertStatus::Inserted | InsertStatus::PasteSent => None, - InsertStatus::CopiedFallback => Some(if polish_failed { - "Wayland 未启用自动输入,已复制原文到剪贴板,请手动粘贴".to_string() - } else { - "Wayland 未启用自动输入,已复制到剪贴板,请手动粘贴".to_string() - }), - InsertStatus::Failed => Some("Wayland 未启用自动输入,剪贴板写入失败".to_string()), - } } fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option { @@ -1410,16 +1396,14 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { }; // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 - let wayland_session = crate::hotkey::is_wayland_session(); let streaming_eligible = streaming_insert_eligible( prefs.streaming_insert, translation_active, mode, raw_uses_llm, - wayland_session, ); log::info!( - "[coord] polish dispatch: translation={translation_active} mode={mode:?} wayland_session={wayland_session} streaming_eligible={streaming_eligible}" + "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" ); let (polished, polish_error, already_streamed) = if translation_active { @@ -1523,24 +1507,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error ); InsertStatus::Inserted - } else if wayland_session { - log::info!( - "[coord] Wayland session detected; skipping synthetic paste and attempting copy-only fallback ({} chars)", - polished.chars().count() - ); - let status = inner.inserter.copy_fallback(&polished); - match status { - InsertStatus::CopiedFallback => { - log::info!("[coord] Wayland copy-only fallback succeeded") - } - InsertStatus::Failed => { - log::error!("[coord] Wayland copy-only fallback failed: clipboard write failed") - } - other => log::warn!( - "[coord] Wayland copy-only fallback returned unexpected status: {other:?}" - ), - } - status } else if focus_ready_for_paste { #[cfg(target_os = "windows")] { @@ -1563,13 +1529,23 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { .insert(&polished, restore_clipboard, paste_shortcut) } } else { - log::warn!( - "[coord] original insertion target is not foreground; copied output without paste" - ); - if allow_non_tsf_insertion_fallback { - inner.inserter.copy_fallback(&polished) - } else { - InsertStatus::Failed + #[cfg(target_os = "linux")] + { + // Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。 + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) + } + #[cfg(not(target_os = "linux"))] + { + log::warn!( + "[coord] original insertion target is not foreground; copied output without paste" + ); + if allow_non_tsf_insertion_fallback { + inner.inserter.copy_fallback(&polished) + } else { + InsertStatus::Failed + } } }; restore_prepared_windows_ime_session(inner, current_session_id); @@ -1599,7 +1575,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error.is_some(), focus_ready_for_paste, allow_non_tsf_insertion_fallback, - wayland_session, ) .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); @@ -1636,8 +1611,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } let done_message = if tsf_required_insert_failed { Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) - } else if wayland_session { - wayland_done_message(status, polish_error.is_some()) } else { default_done_message(status, polish_error.is_some()) }; @@ -1666,11 +1639,8 @@ pub(super) fn dictation_error_code( polish_failed: bool, focus_ready_for_paste: bool, allow_non_tsf_insertion_fallback: bool, - wayland_session: bool, ) -> Option<&'static str> { - if wayland_session && status == InsertStatus::Failed { - Some("waylandClipboardWriteFailed") - } else if !focus_ready_for_paste && status == InsertStatus::Failed { + if !focus_ready_for_paste && status == InsertStatus::Failed { Some("focusRestoreFailed") } else if cfg!(target_os = "windows") && focus_ready_for_paste @@ -1726,8 +1696,8 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> #[cfg(test)] mod tests { use super::{ - append_typed_prefix, default_done_message, dictation_error_code, finalize_polished_text, - streaming_insert_eligible, wayland_done_message, + append_typed_prefix, default_done_message, dictation_error_code, + finalize_polished_text, streaming_insert_eligible, }; use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode}; @@ -1814,45 +1784,17 @@ mod tests { } #[test] - fn wayland_disables_streaming_insert_even_when_pref_enabled() { - assert!(!streaming_insert_eligible( - true, - false, - PolishMode::Light, - false, - true - )); - } - - #[test] - fn x11_linux_can_still_use_streaming_insert_when_other_gates_pass() { + fn streaming_insert_eligible_when_gates_allow() { assert!(streaming_insert_eligible( true, false, PolishMode::Light, false, - false )); } #[test] - fn wayland_done_message_tells_user_manual_paste_is_required() { - assert_eq!( - wayland_done_message(InsertStatus::CopiedFallback, false), - Some("Wayland 未启用自动输入,已复制到剪贴板,请手动粘贴".to_string()) - ); - assert_eq!( - wayland_done_message(InsertStatus::CopiedFallback, true), - Some("Wayland 未启用自动输入,已复制原文到剪贴板,请手动粘贴".to_string()) - ); - assert_eq!( - wayland_done_message(InsertStatus::Failed, false), - Some("Wayland 未启用自动输入,剪贴板写入失败".to_string()) - ); - } - - #[test] - fn default_done_message_keeps_existing_non_wayland_behavior() { + fn default_done_message_works_correctly() { assert_eq!( default_done_message(InsertStatus::PasteSent, false), Some("已尝试粘贴".to_string()) @@ -1862,12 +1804,4 @@ mod tests { Some("润色失败,已插入原文".to_string()) ); } - - #[test] - fn wayland_clipboard_failure_uses_specific_error_code() { - assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, true, true), - Some("waylandClipboardWriteFailed") - ); - } } diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index c93e7c0..6075f8e 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -6,7 +6,7 @@ //! 非主线程触发 `dispatch_assert_queue_fail` → SIGTRAP abort(已踩坑)。 //! - Windows:原生 `WH_KEYBOARD_LL` low-level keyboard hook,保留 modifier-only //! trigger(如右 Control / 右 Alt)的真实语义,不再把平台能力藏在 `rdev` 抽象里。 -//! - Linux / 其他:继续 best-effort 走 `rdev::listen`。 +//! - Linux:fcitx5 插件提供热键事件(DBus 信号 `DictationKeyEvent`)。 //! //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 @@ -173,21 +173,6 @@ impl Drop for HotkeyMonitor { } } -/// 是否处于 Wayland session。Linux 以外的平台恒返回 false。 -/// -/// 主用途:`lib.rs` 在 hotkey listener 起好后据此决定是否额外 emit -/// `wayland_cli_mode` 事件,让前端 Settings 面板展示「请绑桌面快捷键到 -/// `openless --toggle-dictation`」的引导文案。 -#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -pub fn is_wayland_session() -> bool { - std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland") -} - -#[cfg(any(target_os = "macos", target_os = "windows"))] -pub fn is_wayland_session() -> bool { - false -} - fn install_error(code: &str, message: impl Into) -> HotkeyInstallError { HotkeyInstallError { code: code.into(), @@ -1199,93 +1184,39 @@ mod platform { #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] mod platform { - use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; - use std::sync::Arc; - use std::time::Duration; - - use rdev::{listen, Event, EventType, Key}; - use super::{ - install_error, reset_shared_held_state, start_listener_thread, update_shared_binding, - update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, - }; + use super::{HotkeyAdapter, HotkeyEvent}; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; - /// X11 走 rdev 监听器;Wayland 协议层面禁止应用监听其他窗口的键盘事件 - /// (详见 `docs/issue-420-wayland-hotkey-research.md` 2 节),所以这里 - /// 返回一个"CLI 适配器"占位:不安装任何键盘 hook,但实现 HotkeyAdapter - /// trait 以让上层 `ensure_modifier_hotkey_monitor` 正常走 `Installed` 分支, - /// 不再把 Wayland 当成"安装失败"。 + /// Linux 统一使用 fcitx5 插件作为热键源(Wayland / X11 均可), + /// 不再启用 rdev 监听器。此处返回占位 adapter 让上层走 `Installed` 分支。 /// - /// 用户实际的触发路径变成:桌面环境快捷键 → `openless --toggle-dictation` - /// → tauri-plugin-single-instance 拦截并把 argv 转给主实例 coordinator。 - /// 前端 Settings 面板会监听 `wayland_cli_mode` 事件并展示对应的引导文案。 + /// 实际的热键事件由 `linux_fcitx::start_dictation_signal_listener` 接收 + /// fcitx5 插件的 DBus 信号并转发到 `Sender`。 pub fn start_adapter( - binding: HotkeyBinding, + _binding: HotkeyBinding, tx: Sender, ) -> Result, HotkeyInstallError> { - if super::is_wayland_session() { - log::info!( - "[hotkey] Wayland session detected; rdev listener skipped — \ - use desktop shortcut → `openless --toggle-dictation` instead (issue #420)" - ); - // tx 在 stub adapter 下无人 push 事件 — 持有它直到 adapter 被 drop 即可。 - return Ok(Box::new(WaylandCliAdapter { _tx: tx })); - } - let listener = start_listener_thread( - binding, - tx, - "openless-hotkey-rdev", - "hotkey hook 启动超时", - run_listen_loop, - )?; - let _ = listener.startup; - Ok(Box::new(RdevHotkeyAdapter { - shared: listener.shared, - })) + log::info!( + "[hotkey] Linux — fcitx5 plugin handles hotkeys; rdev listener skipped" + ); + Ok(Box::new(PlaceholderAdapter { _tx: tx })) } - /// Wayland 下的占位 adapter:实现接口但不监听键盘。 - /// 上层 coordinator 仍会把它登记为 `Installed`(hotkey 状态显示正常), - /// 用户的触发路径由 CLI + single-instance 转发承担。 - struct WaylandCliAdapter { + /// Linux 占位 adapter:实现接口但不监听键盘。 + /// 热键事件由 fcitx5 插件的 `DictationKeyEvent` DBus 信号提供。 + struct PlaceholderAdapter { _tx: Sender, } - impl HotkeyAdapter for WaylandCliAdapter { + impl HotkeyAdapter for PlaceholderAdapter { fn kind(&self) -> HotkeyAdapterKind { - // 复用 Rdev kind 显示,避免新增枚举项波及整个序列化层。 - // 真实 adapter 状态由 `wayland_cli_mode` 事件在前端单独引导。 - HotkeyAdapterKind::Rdev + HotkeyAdapterKind::Fcitx5 } fn update_binding(&self, _binding: HotkeyBinding) { - // Wayland 下绑定由桌面环境管理;忽略后端绑定变更,但不报错。 - } - - fn update_modifier_shortcuts( - &self, - _qa_trigger: Option, - _translation_trigger: Option, - ) { - // 同上 — modifier-only 修饰键在 Wayland 上也走不通,留空。 - } - - fn reset_held_state(&self) {} - } - - struct RdevHotkeyAdapter { - shared: Arc, - } - - impl HotkeyAdapter for RdevHotkeyAdapter { - fn kind(&self) -> HotkeyAdapterKind { - HotkeyAdapterKind::Rdev - } - - fn update_binding(&self, binding: HotkeyBinding) { - update_shared_binding(&self.shared, binding); + // fcitx5 插件热键由 sync_binding_to_plugin 单独同步。 } fn update_modifier_shortcuts( @@ -1293,299 +1224,10 @@ mod platform { qa_trigger: Option, translation_trigger: Option, ) { - update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger); - } - - fn reset_held_state(&self) { - reset_shared_held_state(&self.shared); - } - } - - fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { - let status_sent = Arc::new(AtomicBool::new(false)); - let ready_status_sent = Arc::clone(&status_sent); - let ready_status_tx = status_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(350)); - if !ready_status_sent.swap(true, Ordering::SeqCst) { - let _ = ready_status_tx.send(Ok(())); - } - }); - let cb_shared = Arc::clone(&shared); - let result = listen(move |event: Event| { - dispatch_event(&cb_shared, &tx, event); - }); - if let Err(err) = result { - if !status_sent.swap(true, Ordering::SeqCst) { - let _ = status_tx.send(Err(install_error( - "listen_failed", - format!("rdev::listen 启动失败: {err:?}"), - ))); - } - log::error!("[hotkey] rdev::listen 启动失败: {:?}", err); - } - } - - fn dispatch_event(shared: &Shared, tx: &Sender, event: Event) { - let trigger = shared.binding.read().trigger; - match event.event_type { - EventType::KeyPress(key) => { - if key == Key::Escape { - let _ = tx.send(HotkeyEvent::Cancelled); - return; - } - // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - let was_held = shared - .translation_modifier_held - .swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(HotkeyEvent::TranslationModifierPressed); - } - return; - } - handle_optional_modifier_press( - shared, - tx, - key, - *shared.qa_trigger.read(), - &shared.qa_trigger_held, - HotkeyEvent::QaShortcutPressed, - ); - handle_optional_modifier_press( - shared, - tx, - key, - *shared.translation_trigger.read(), - &shared.translation_trigger_held, - HotkeyEvent::TranslationModifierPressed, - ); - if trigger == HotkeyTrigger::Custom { - return; - } - if key == trigger_to_rdev_key(trigger) { - let was_held = shared.trigger_held.swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(HotkeyEvent::Pressed); - } - } - } - EventType::KeyRelease(key) => { - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - return; - } - handle_optional_modifier_release( - shared, - key, - *shared.qa_trigger.read(), - &shared.qa_trigger_held, - ); - handle_optional_modifier_release( - shared, - key, - *shared.translation_trigger.read(), - &shared.translation_trigger_held, - ); - if trigger == HotkeyTrigger::Custom { - return; - } - if key == trigger_to_rdev_key(trigger) { - let was_held = shared.trigger_held.swap(false, Ordering::SeqCst); - if was_held { - let _ = tx.send(HotkeyEvent::Released); - } - } - } - _ => {} - } - } - - fn handle_optional_modifier_press( - shared: &Shared, - tx: &Sender, - key: Key, - trigger: Option, - held: &std::sync::atomic::AtomicBool, - event: HotkeyEvent, - ) { - let Some(trigger) = trigger else { - return; - }; - if trigger == HotkeyTrigger::Custom || key != trigger_to_rdev_key(trigger) { - return; - } - let was_held = held.swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(event); - } - } - - fn handle_optional_modifier_release( - _shared: &Shared, - key: Key, - trigger: Option, - held: &std::sync::atomic::AtomicBool, - ) { - let Some(trigger) = trigger else { - return; - }; - if trigger != HotkeyTrigger::Custom && key == trigger_to_rdev_key(trigger) { - held.store(false, Ordering::SeqCst); + crate::linux_fcitx::sync_qa_binding(qa_trigger); + crate::linux_fcitx::sync_translation_binding(translation_trigger); } - } - - fn trigger_to_rdev_key(trigger: HotkeyTrigger) -> Key { - match trigger { - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => Key::AltGr, - HotkeyTrigger::LeftOption => Key::Alt, - HotkeyTrigger::RightControl => Key::ControlRight, - HotkeyTrigger::LeftControl => Key::ControlLeft, - HotkeyTrigger::RightCommand => Key::MetaRight, - HotkeyTrigger::Fn => Key::Function, - HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), - } - } - - #[cfg(test)] - mod tests { - use super::*; - use parking_lot::RwLock; - use std::sync::atomic::AtomicBool; - use std::sync::mpsc; - use std::time::SystemTime; - - fn shared(trigger: HotkeyTrigger) -> Shared { - Shared { - binding: RwLock::new(HotkeyBinding { - trigger, - mode: crate::types::HotkeyMode::Toggle, - keys: None, - }), - trigger_held: AtomicBool::new(false), - qa_trigger: RwLock::new(None), - qa_trigger_held: AtomicBool::new(false), - translation_trigger: RwLock::new(None), - translation_trigger_held: AtomicBool::new(false), - translation_modifier_held: AtomicBool::new(false), - } - } - - fn key_event(event_type: EventType) -> Event { - Event { - time: SystemTime::UNIX_EPOCH, - name: None, - event_type, - } - } - - fn drain(rx: &mpsc::Receiver) -> Vec { - rx.try_iter().collect() - } - - #[test] - fn rdev_modifier_edges_are_deduped_from_mock_events() { - let shared = shared(HotkeyTrigger::RightControl); - let (tx, rx) = mpsc::channel(); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::ControlRight)), - ); - - assert_eq!( - drain(&rx), - vec![HotkeyEvent::Pressed, HotkeyEvent::Released] - ); - } - - #[test] - fn rdev_modifier_edges_ignore_unrelated_keys_and_reemit_after_release() { - let shared = shared(HotkeyTrigger::RightControl); - let (tx, rx) = mpsc::channel(); - - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlLeft)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlRight)), - ); - - assert_eq!( - drain(&rx), - vec![ - HotkeyEvent::Pressed, - HotkeyEvent::Released, - HotkeyEvent::Pressed - ] - ); - } - - #[test] - fn rdev_optional_modifier_shortcuts_use_independent_latches() { - let shared = shared(HotkeyTrigger::RightControl); - *shared.qa_trigger.write() = Some(HotkeyTrigger::RightCommand); - *shared.translation_trigger.write() = Some(HotkeyTrigger::LeftOption); - let (tx, rx) = mpsc::channel(); - - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::Alt))); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::ShiftLeft))); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::ShiftLeft))); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::MetaRight)), - ); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); - - assert_eq!( - drain(&rx), - vec![ - HotkeyEvent::QaShortcutPressed, - HotkeyEvent::TranslationModifierPressed, - HotkeyEvent::TranslationModifierPressed, - HotkeyEvent::QaShortcutPressed, - ] - ); - } + fn reset_held_state(&self) {} } } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index b48ece8..8be9e6a 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -50,6 +50,21 @@ impl TextInserter { if text.is_empty() { return InsertStatus::CopiedFallback; } + // Linux: 始终优先使用 fcitx5 CommitText 直写(支持中文)。 + // 如果插件未加载,降级到剪贴板拷贝(统一路径,不单独维护 enigo XTest)。 + #[cfg(target_os = "linux")] + { + match crate::linux_fcitx::commit_text(text) { + Ok(()) => return InsertStatus::Inserted, + Err(e) => { + log::warn!("[insertion] fcitx commit_text failed: {e}, fallback to clipboard only"); + if copy_to_clipboard(text) { + return InsertStatus::CopiedFallback; + } + return InsertStatus::Failed; + } + } + } insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut) } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 94bb9bd..8b335fa 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -21,6 +21,8 @@ mod correction; mod global_hotkey_runtime; mod hotkey; mod insertion; +#[cfg(target_os = "linux")] +mod linux_fcitx; mod llm_gemini; mod permissions; mod persistence; @@ -72,7 +74,7 @@ pub fn run() { // 否则两份 OpenLess(如 /Applications/ + dev build)会各自抓全局热键, // 导致按一次键、两个进程同时跑流水线、文本被插入两遍。见 issue #50。 // - // 第二个进程的 argv 还有一个用处:作为 Linux/Wayland 下的「触发器入口」。 + // 第二个进程的 argv 还有一个用处:作为 Linux 下的「触发器入口」。 // 桌面环境快捷键执行 `openless --toggle-dictation` 时,第二个进程被本插件 // 拦截 → argv 直接转给主实例 coordinator。详见 issue #420 / `cli.rs`。 .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { @@ -245,14 +247,6 @@ pub fn run() { show_main_window(app.handle()); } - // Wayland 下没有可用的全局键盘监听(issue #420)。Coordinator 已通过 stub adapter - // 把 hotkey 状态标记为 Installed,整个应用照常起来。前端走 pull 模型:RecordingSection - // mount 时调 `is_wayland_cli_mode` 取状态再渲染 CLI 引导 callout。原本用一次性 event 通知 - // 行不通——Settings 模态是按需 mount,事件不缓冲不 replay,listener 几乎必然错过。 - if hotkey::is_wayland_session() { - log::info!("[startup] Wayland session — frontend will pull via is_wayland_cli_mode"); - } - // 首次启动也可能带 CLI flag(用户双击 .desktop 之前先用 CLI 起一遍)。 // 等 coordinator 准备好后再 dispatch;GUI 仍然照常起来。 let first_run_args: Vec = std::env::args().collect(); @@ -272,7 +266,6 @@ pub fn run() { commands::fetch_latest_beta_release, commands::get_hotkey_status, commands::get_hotkey_capability, - commands::is_wayland_cli_mode, commands::set_shortcut_recording_active, commands::get_windows_ime_status, commands::list_microphone_devices, diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs new file mode 100644 index 0000000..1ca3e5a --- /dev/null +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -0,0 +1,305 @@ +//! Linux fcitx5 插件 DBus 客户端。 +//! +//! 封装对 `org.fcitx.Fcitx.OpenLess1` 接口的调用, +//! 提供文字提交(替代 enigo XTest)和热键设置功能。 +//! +//! 所有函数会静默返回 `None` 如果 fcitx5 / 插件不可用, +//! 调用方应当降级到原有方案(clipboard / enigo)。 + +use std::time::Duration; + +use dbus::blocking::BlockingSender; + +const DEST: &str = "org.fcitx.Fcitx5"; +const PATH: &str = "/openless"; +const IFACE: &str = "org.fcitx.Fcitx.OpenLess1"; +const TIMEOUT: Duration = Duration::from_secs(3); + +/// 通过 fcitx5 插件向当前焦点输入上下文提交文字。 +/// +/// 返回 `Ok(())` 表示文字已提交,`Err` 表示调用失败(插件未加载 / DBus 不通等)。 +pub fn commit_text(text: &str) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "CommitText") + .map_err(|e| format!("build msg: {e}"))? + .append1(text); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("CommitText: {e}"))?; + Ok(()) +} + +/// 通过 fcitx5 插件设置听写触发快捷键。 +/// +/// `keys` 为 Key::parse 格式的字符串数组,例如 `["Control+space"]`。 +pub fn set_hotkey(keys: &[&str]) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let list: Vec = keys.iter().map(|s| s.to_string()).collect(); + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkey") + .map_err(|e| format!("build msg: {e}"))? + .append1(list); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetHotkey: {e}"))?; + Ok(()) +} + +/// 通过 fcitx5 插件直接设置 sym + states 作为触发键。 +pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkeyRaw") + .map_err(|e| format!("build msg: {e}"))? + .append2(sym, states); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetHotkeyRaw: {e}"))?; + Ok(()) +} + +/// 通过 fcitx5 插件设置 QA 面板快捷键 sym + states。 +pub fn set_qa_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetQaHotkeyRaw") + .map_err(|e| format!("build msg: {e}"))? + .append2(sym, states); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetQaHotkeyRaw: {e}"))?; + Ok(()) +} + +/// 通过 fcitx5 插件设置翻译模式修饰键 sym + states。 +pub fn set_translation_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetTranslationHotkeyRaw") + .map_err(|e| format!("build msg: {e}"))? + .append2(sym, states); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetTranslationHotkeyRaw: {e}"))?; + Ok(()) +} + +/// X11 keysym 值(用于 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw, +/// 绕过 Key::parse 的修饰键限制)。 +const KEYSYM_CONTROL_R: u32 = 0xffe4; +const KEYSYM_CONTROL_L: u32 = 0xffe3; +const KEYSYM_ALT_R: u32 = 0xffea; +const KEYSYM_ALT_L: u32 = 0xffe9; +const KEYSYM_SUPER_R: u32 = 0xffec; +const KEYSYM_SUPER_L: u32 = 0xffeb; +const KEYSYM_SHIFT_R: u32 = 0xffe2; +const KEYSYM_SHIFT_L: u32 = 0xffe1; + +/// 将 HotkeyTrigger 转换为 X11 keysym。 +fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 { + match trigger { + crate::types::HotkeyTrigger::RightControl => KEYSYM_CONTROL_R, + crate::types::HotkeyTrigger::LeftControl => KEYSYM_CONTROL_L, + crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => KEYSYM_ALT_R, + crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L, + crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R, + crate::types::HotkeyTrigger::Fn => KEYSYM_CONTROL_R, + crate::types::HotkeyTrigger::Custom => unreachable!(), + } +} + +fn trigger_name(trigger: crate::types::HotkeyTrigger) -> &'static str { + match trigger { + crate::types::HotkeyTrigger::RightControl => "Control_R", + crate::types::HotkeyTrigger::LeftControl => "Control_L", + crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => "Alt_R", + crate::types::HotkeyTrigger::LeftOption => "Alt_L", + crate::types::HotkeyTrigger::RightCommand => "Super_R", + crate::types::HotkeyTrigger::Fn => "Control_R", + crate::types::HotkeyTrigger::Custom => unreachable!(), + } +} + +/// 将 OpenLess 的主听写热键绑定同步到 fcitx5 插件。 +pub fn sync_binding_to_plugin(binding: &crate::types::HotkeyBinding) { + if binding.trigger == crate::types::HotkeyTrigger::Custom { + return; + } + let sym = trigger_to_keysym(binding.trigger); + let name = trigger_name(binding.trigger); + match set_hotkey_raw(sym, 0) { + Ok(()) => log::info!("[fcitx] Synced hotkey {name} (sym={sym}) to plugin via SetHotkeyRaw"), + Err(e) => log::warn!("[fcitx] Failed to sync hotkey to plugin: {e}"), + } +} + +/// 将 ShortcutBinding 转换为 fcitx5 Key::parse 格式的字符串。 +/// +/// 例如 `modifiers: ["Ctrl", "Alt"], primary: "d"` → `"Control+Alt+d"`。 +pub fn binding_to_fcitx_key_string(binding: &crate::types::ShortcutBinding) -> String { + let mut parts: Vec = Vec::new(); + for m in &binding.modifiers { + let lower = m.to_lowercase(); + let normalized = match lower.as_str() { + "ctrl" | "control" => "Control", + "alt" | "option" | "opt" => "Alt", + "shift" => "Shift", + "super" | "meta" | "cmd" | "win" | "command" => "Super", + other => other, + }; + if !parts.contains(&normalized.to_string()) { + parts.push(normalized.to_string()); + } + } + // 主键:取小写,去掉 "Key" 前缀(如 "KeyD" → "d") + let primary = binding.primary.trim(); + let primary = if let Some(stripped) = primary.strip_prefix("Key") { + stripped.to_lowercase() + } else { + primary.to_lowercase() + }; + if primary.is_empty() { + return String::new(); + } + if parts.is_empty() { + primary + } else { + format!("{}+{}", parts.join("+"), primary) + } +} + +/// 通过 fcitx5 插件的 SetCustomDictationTrigger 方法设置自定义组合键。 +pub fn set_custom_dictation_trigger(key_string: &str) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call( + DEST, PATH, IFACE, "SetCustomDictationTrigger", + ) + .map_err(|e| format!("build msg: {e}"))? + .append1(key_string); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetCustomDictationTrigger: {e}"))?; + Ok(()) +} + +/// 将 QA 面板快捷键同步到 fcitx5 插件。 +pub fn sync_qa_binding(trigger: Option) { + let Some(trigger) = trigger else { + // 无 QA 快捷键时清空插件端配置 + let _ = set_qa_hotkey_raw(0, 0); + return; + }; + let sym = trigger_to_keysym(trigger); + let name = trigger_name(trigger); + match set_qa_hotkey_raw(sym, 0) { + Ok(()) => log::info!("[fcitx] Synced QA hotkey {name} (sym={sym}) to plugin via SetQaHotkeyRaw"), + Err(e) => log::warn!("[fcitx] Failed to sync QA hotkey to plugin: {e}"), + } +} + +/// 将翻译模式快捷键同步到 fcitx5 插件。 +pub fn sync_translation_binding(trigger: Option) { + let Some(trigger) = trigger else { + let _ = set_translation_hotkey_raw(0, 0); + return; + }; + let sym = trigger_to_keysym(trigger); + let name = trigger_name(trigger); + match set_translation_hotkey_raw(sym, 0) { + Ok(()) => log::info!("[fcitx] Synced translation hotkey {name} (sym={sym}) to plugin via SetTranslationHotkeyRaw"), + Err(e) => log::warn!("[fcitx] Failed to sync translation hotkey to plugin: {e}"), + } +} + +/// 快速检查 fcitx5 OpenLess 插件是否可用(DBus 对象存在)。 +pub fn available() -> bool { + let conn = match dbus::blocking::Connection::new_session() { + Ok(c) => c, + Err(_) => return false, + }; + let msg = match dbus::Message::new_method_call(DEST, PATH, "org.freedesktop.DBus.Peer", "Ping") + { + Ok(m) => m, + Err(_) => return false, + }; + conn.send_with_reply_and_block(msg, TIMEOUT).is_ok() +} + +/// 启动 fcitx5 DictationKeyEvent 信号监听线程。 +/// +/// 当 fcitx5 OpenLess 插件检测到配置的听写热键被按下或松开时, +/// 发出 `DictationKeyEvent(uub)` DBus 信号(sym, states, isPress)。 +/// 本函数将此信号转发为 `HotkeyEvent::Pressed` / `Released` 到协调器事件通道。 +/// +/// 后台线程在 `tx` 全部 drop(协调器关闭)或 DBus 连接断开时自动退出。 +#[cfg(target_os = "linux")] +pub fn start_dictation_signal_listener( + tx: std::sync::mpsc::Sender, +) { + use std::time::Duration; + + std::thread::Builder::new() + .name("openless-fcitx-signal".into()) + .spawn(move || { + let conn = match dbus::blocking::SyncConnection::new_session() { + Ok(c) => c, + Err(e) => { + log::warn!("[fcitx-hotkey] DBus session failed: {e}"); + return; + } + }; + + // 同时监听所有三个信号 + let rule = match dbus::message::MatchRule::parse( + "type='signal',\ + interface='org.fcitx.Fcitx.OpenLess1'", + ) { + Ok(r) => r, + Err(e) => { + log::warn!("[fcitx-hotkey] Invalid match rule: {e}"); + return; + } + }; + + let tx2 = tx.clone(); + let _match = match conn.add_match(rule, move |args: (u32, u32, bool), _conn, msg| { + let (sym, states, is_press) = args; + let member = msg.member(); + let member_str: String = member.as_ref().map(|m| m.to_string()).unwrap_or_default(); + log::debug!( + "[fcitx-hotkey] Signal {}: sym={}, states={}, isPress={}", + member_str, sym, states, is_press, + ); + if let Some(member) = member { + if member == "DictationKeyEvent" { + let event = if is_press { + crate::hotkey::HotkeyEvent::Pressed + } else { + crate::hotkey::HotkeyEvent::Released + }; + let _ = tx.send(event); + } else if member == "QaShortcutEvent" { + if is_press { + let _ = tx2.send(crate::hotkey::HotkeyEvent::QaShortcutPressed); + } + } else if member == "TranslationModifierEvent" { + if is_press { + let _ = tx2.send(crate::hotkey::HotkeyEvent::TranslationModifierPressed); + } + } + } + true + }) { + Ok(m) => m, + Err(e) => { + log::warn!("[fcitx-hotkey] Failed to add match: {e}"); + return; + } + }; + + log::info!("[fcitx-hotkey] Listening for OpenLess1 signals"); + loop { + if let Err(e) = conn.process(Duration::from_millis(500)) { + log::warn!("[fcitx-hotkey] DBus process error: {e}"); + break; + } + } + }) + .ok(); +} diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index df1b489..53a0b8c 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -5,7 +5,7 @@ //! 走辅助功能 API 直读焦点元素的选区,**不**触碰剪贴板。 //! 2. **macOS / Windows** Cmd+C / Ctrl+C:snapshot 用户原剪贴板 → 模拟复制 → 80ms //! 后读出新内容 → 还原原剪贴板。 -//! 3. **Linux**:返回 `None`(X11/Wayland AX 模式不统一,留作 best-effort 后续)。 +//! 3. **Linux**:返回 `None`(AX 模式不统一,留作 best-effort 后续)。 //! //! 截断策略:超过 4000 字符的选区只保留首 2000 + 尾 2000 + `[…truncated…]` 标记, //! 避免给 LLM 灌过长 context。 diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 5ea66e8..efa323f 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -600,7 +600,7 @@ pub struct UserPreferences { /// 平台原语: /// - macOS:CGEvent Unicode FFI;CJK / 日文 IME 会拦截,session 期间临时切到 ABC /// - Windows:SendInput Unicode(绕过 TSF);不需要切输入法 - /// - Linux(实验性):enigo `Keyboard::text`;X11 稳定,Wayland 看 compositor + /// - Linux:通过 fcitx5 插件 commitString 直写或剪贴板回落。 /// /// 限制: /// - 不再走剪贴板路径,对 secure input 框(密码框 / 1Password)静默拒绝 @@ -1472,7 +1472,7 @@ pub enum HotkeyMode { pub enum HotkeyAdapterKind { MacEventTap, WindowsLowLevel, - Rdev, + Fcitx5, } impl HotkeyAdapterKind { @@ -1480,7 +1480,7 @@ impl HotkeyAdapterKind { match self { HotkeyAdapterKind::MacEventTap => "macOS Event Tap", HotkeyAdapterKind::WindowsLowLevel => "Windows 低层键盘 hook", - HotkeyAdapterKind::Rdev => "rdev 监听器", + HotkeyAdapterKind::Fcitx5 => "fcitx5 输入法插件", } } } @@ -1681,7 +1681,7 @@ impl HotkeyCapability { #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] { Self { - adapter: HotkeyAdapterKind::Rdev, + adapter: HotkeyAdapterKind::Fcitx5, available_triggers: vec![ HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, @@ -1693,7 +1693,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "Linux 仅 best-effort:X11 可尝试 rdev 监听;Wayland 请在桌面环境中绑定 openless --toggle-dictation 等 CLI 命令。".into(), + "Linux 使用 fcitx5 插件监听热键和提交文字;无需桌面环境额外配置。".into(), ), } } diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index fd7d78c..99b39b5 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -14,9 +14,7 @@ //! 必须 `switch_to_ascii` 切到 ABC,session 结束再 `restore_input_source` 切回。 //! - **Windows**:`SendInput(KEYEVENTF_UNICODE)` 直接发 UTF-16 scancode。TSF 不拦 //! Unicode 事件(与 keyboard layout / IME 解耦),所以不需要切输入法。 -//! - **Linux**:enigo `Keyboard::text(...)`。X11 走 XTest 稳定;Wayland 看 compositor -//! 是否给 libei 权限,stock GNOME-Wayland 经常拒绝,调用方应当容忍失败回落到一次性。 -//! 不切输入法 —— Linux 的 fcitx / ibus 与 enigo 的交互非常碎,v1 不尝试。 +//! - **Linux**:走 fcitx5 插件 commitString 直写(DBus)或剪贴板回落。 //! //! ## 已知坑(macOS) //! @@ -412,39 +410,24 @@ mod windows_impl { #[cfg(target_os = "linux")] mod linux_impl { use super::{TisError, TypeError}; - use enigo::{Enigo, Keyboard, Settings}; + #[allow(unused_imports)] use tauri::{AppHandle, Runtime}; pub struct PreviousInputSource; - /// 用 enigo 逐字符发出 chunk。X11 上走 XTest 稳定;Wayland 上看 compositor 是否 - /// 给 libei 权限,stock GNOME-Wayland 通常拒绝 —— 失败时尽量返回已成功字符数, - /// 让调用方的 history / clipboard 与实际落屏内容一致。 - /// - /// 不处理 fcitx / ibus 输入法切换 —— Linux 输入法栈与 X11 合成事件的交互非常 - /// 碎片化,v1 实验阶段直接交给用户保证当前输入源是英文键盘。 + /// 通过 fcitx5 插件一次性提交整段文字(支持中文、Wayland/X11 均可)。 + /// 如果插件未加载返回 Err,调用方降级到剪贴板拷贝。 pub fn type_unicode_chunk(text: &str) -> Result { if text.is_empty() { return Ok(0); } - let mut enigo = - Enigo::new(&Settings::default()).map_err(|e| TypeError::EnigoInit(e.to_string()))?; - let mut typed_chars = 0; - for ch in text.chars() { - if let Err(e) = enigo.text(&ch.to_string()) { - let source = TypeError::EnigoText(e.to_string()); - return if typed_chars == 0 { - Err(source) - } else { - Err(TypeError::Partial { - typed_chars, - source: Box::new(source), - }) - }; - } - typed_chars += 1; + if crate::linux_fcitx::commit_text(text).is_ok() { + Ok(text.chars().count()) + } else { + Err(TypeError::EnigoText( + "fcitx5 plugin unavailable, try clipboard fallback".into(), + )) } - Ok(typed_chars) } pub async fn switch_to_ascii( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 65e915b..0e61fe5 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -406,24 +406,6 @@ export const en: typeof zhCN = { startupAtBoot: 'Launch at login', startupAtBootDesc: 'Start OpenLess automatically when you sign in.', startupAtBootError: 'Failed to toggle launch at login: {{message}}', - wayland: { - calloutTitle: 'Wayland desktop detected', - calloutBody: 'Wayland forbids apps from listening for global shortcuts. Please create a custom shortcut for each command below in your system settings (QA and cancel commands are optional):', - copyButton: 'Copy', - copyButtonCopied: 'Copied', - commandToggleDictationLabel: 'Start / stop dictation', - commandToggleQaLabel: 'Open / close QA panel', - commandCancelDictationLabel: 'Cancel current dictation', - helpToggle: 'Setup steps for each desktop environment', - gnomeTitle: 'GNOME', - gnomeSteps: 'Settings → Keyboard → View and Customize Shortcuts → Custom Shortcuts → Add Shortcut. Repeat 1–3 times, pasting each command above and recording the key combination you want.', - kdeTitle: 'KDE Plasma', - kdeSteps: 'System Settings → Keyboard → Shortcuts → Add New → Command/URL. Repeat for each command above, recording different trigger keys, then Apply.', - hyprlandTitle: 'Hyprland', - hyprlandSteps: 'Edit ~/.config/hypr/hyprland.conf, add any 1–3 of the lines below, then run hyprctl reload:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: 'Edit ~/.config/sway/config, add any 1–3 of the lines below, then run swaymsg reload:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM (polishing)', @@ -567,7 +549,7 @@ export const en: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.', streamingInsertHintLinux: - 'Uses enigo + XTest on X11. On Wayland, streaming insertion is disabled and output is kept in the clipboard for manual paste.', + 'Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.', streamingInsertSaveClipboardLabel: 'Copy to clipboard', streamingInsertSaveClipboardHint: 'After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.', localAsrTitle: 'Local ASR models (experimental)', @@ -732,7 +714,7 @@ export const en: typeof zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows low-level keyboard hook', - rdev: 'rdev listener', + fcitx5: 'fcitx5 input method plugin', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index bd7b5b0..e4ebc05 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -379,24 +379,6 @@ export const ja: typeof zhCN = { startupAtBoot: '起動時に自動起動', startupAtBootDesc: 'ログイン時に OpenLess を自動起動。', startupAtBootError: '自動起動の切り替えに失敗:{{message}}', - wayland: { - calloutTitle: 'Wayland デスクトップを検出', - calloutBody: 'Wayland はセキュリティ上、アプリのグローバルショートカット監視を許可していません。システム設定で以下の各コマンド用にカスタムショートカットを作成してください(QA と録音キャンセルは任意):', - copyButton: 'コピー', - copyButtonCopied: 'コピー済み', - commandToggleDictationLabel: '録音の開始 / 停止', - commandToggleQaLabel: 'QA パネルの表示 / 非表示', - commandCancelDictationLabel: '現在の録音をキャンセル', - helpToggle: '各デスクトップ環境の設定手順', - gnomeTitle: 'GNOME', - gnomeSteps: '設定 → キーボード → ショートカットの表示とカスタマイズ → カスタムショートカット → 追加。1〜3 回繰り返し、コマンド欄に上記の各コマンドを貼り付け、希望のキー組み合わせを入力。', - kdeTitle: 'KDE Plasma', - kdeSteps: 'システム設定 → キーボード → ショートカット → 新規追加 → コマンド/URL。上記の各コマンドに対してトリガーキーを別々に記録し、保存。', - hyprlandTitle: 'Hyprland', - hyprlandSteps: '~/.config/hypr/hyprland.conf に以下から 1〜3 行を追加し、hyprctl reload を実行:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: '~/.config/sway/config に以下から 1〜3 行を追加し、swaymsg reload を実行:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM モデル(整文)', @@ -540,7 +522,7 @@ export const ja: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。', streamingInsertHintLinux: - 'X11 では enigo + XTest でキー合成します。Wayland ではストリーミング入力を無効化し、出力をクリップボードに残して手動貼り付けします。', + 'fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。', streamingInsertSaveClipboardLabel: 'クリップボードに保存', streamingInsertSaveClipboardHint: '挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。', localAsrTitle: 'ローカル ASR モデル(実験的)', @@ -705,7 +687,7 @@ export const ja: typeof zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低レベルキーボードフック', - rdev: 'rdev リスナー', + fcitx5: 'fcitx5 インプットメソッドプラグイン', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 19a54b5..711ed98 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -379,24 +379,6 @@ export const ko: typeof zhCN = { startupAtBoot: '부팅 시 자동 시작', startupAtBootDesc: '로그인 시 OpenLess 자동 시작.', startupAtBootError: '자동 시작 전환 실패: {{message}}', - wayland: { - calloutTitle: 'Wayland 데스크톱 환경 감지됨', - calloutBody: 'Wayland는 보안상 앱의 전역 단축키 감지를 허용하지 않습니다. 시스템 설정에서 아래 각 명령에 대해 사용자 지정 단축키를 만드세요 (QA와 취소 명령은 선택 사항):', - copyButton: '복사', - copyButtonCopied: '복사됨', - commandToggleDictationLabel: '녹음 시작 / 중지', - commandToggleQaLabel: 'QA 패널 열기 / 닫기', - commandCancelDictationLabel: '현재 녹음 취소', - helpToggle: '각 데스크톱 환경별 설정 단계', - gnomeTitle: 'GNOME', - gnomeSteps: '설정 → 키보드 → 단축키 보기 및 사용자 지정 → 사용자 지정 단축키 → 추가. 1-3회 반복하여 명령 칸에 위 각 명령을 붙여넣고 원하는 키 조합을 기록.', - kdeTitle: 'KDE Plasma', - kdeSteps: '시스템 설정 → 키보드 → 단축키 → 새로 추가 → 명령/URL. 위 각 명령에 대해 다른 트리거 키를 기록하고 저장.', - hyprlandTitle: 'Hyprland', - hyprlandSteps: '~/.config/hypr/hyprland.conf 파일에 아래 1-3 줄을 추가하고 hyprctl reload 실행:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: '~/.config/sway/config 파일에 아래 1-3 줄을 추가하고 swaymsg reload 실행:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM 모델(정리)', @@ -540,7 +522,7 @@ export const ko: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.', streamingInsertHintLinux: - 'X11에서는 enigo + XTest로 키를 합성합니다. Wayland에서는 스트리밍 입력을 비활성화하고 출력을 클립보드에 남겨 수동 붙여넣기를 사용합니다.', + 'fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.', streamingInsertSaveClipboardLabel: '클립보드에 저장', streamingInsertSaveClipboardHint: '삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.', localAsrTitle: '로컬 ASR 모델 (실험적)', @@ -705,7 +687,7 @@ export const ko: typeof zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 저수준 키보드 후크', - rdev: 'rdev 리스너', + fcitx5: 'fcitx5 입력기 플러그인', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index cd95e0e..dd91bdc 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -404,24 +404,6 @@ export const zhCN = { startupAtBoot: '开机自启', startupAtBootDesc: '登录系统时自动启动 OpenLess。', startupAtBootError: '开机自启切换失败:{{message}}', - wayland: { - calloutTitle: '检测到 Wayland 桌面环境', - calloutBody: 'Wayland 出于安全考虑不允许应用监听全局快捷键。请在系统设置中为下面的命令分别创建自定义快捷键(QA 与取消录音命令可选):', - copyButton: '复制', - copyButtonCopied: '已复制', - commandToggleDictationLabel: '开始 / 停止录音', - commandToggleQaLabel: '打开 / 关闭 QA 面板', - commandCancelDictationLabel: '取消当前录音', - helpToggle: '查看各桌面环境配置步骤', - gnomeTitle: 'GNOME', - gnomeSteps: '设置 → 键盘 → 查看和自定义快捷键 → 自定义快捷键 → 添加快捷键。重复添加 1-3 组,命令处分别填入上方命令,再录入想用的按键组合。', - kdeTitle: 'KDE Plasma', - kdeSteps: '系统设置 → 键盘 → 快捷键 → 添加新的 → 命令/URL,动作处分别粘贴上方命令并录入不同触发键,保存即可。', - hyprlandTitle: 'Hyprland', - hyprlandSteps: '编辑 ~/.config/hypr/hyprland.conf,加入下面任意 1-3 行后执行 hyprctl reload:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: '编辑 ~/.config/sway/config,加入下面任意 1-3 行后执行 swaymsg reload:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM 模型(润色)', @@ -565,7 +547,7 @@ export const zhCN = { streamingInsertHintWindows: 'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。', streamingInsertHintLinux: - 'X11 使用 enigo + XTest 合成按键;Wayland 下会自动关闭流式输入,并保留到剪贴板供手动粘贴。', + '通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。', streamingInsertSaveClipboardLabel: '同步到剪贴板', streamingInsertSaveClipboardHint: '插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。', localAsrTitle: '本地 ASR 模型(实验性)', @@ -730,7 +712,7 @@ export const zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低层键盘 hook', - rdev: 'rdev 监听器', + fcitx5: 'fcitx5 输入法插件', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f86426f..8486611 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -406,24 +406,6 @@ export const zhTW: typeof zhCN = { startupAtBoot: '開機自啓', startupAtBootDesc: '登錄系統時自動啓動 OpenLess。', startupAtBootError: '開機自啓切換失敗:{{message}}', - wayland: { - calloutTitle: '偵測到 Wayland 桌面環境', - calloutBody: 'Wayland 出於安全考慮不允許應用監聽全域快速鍵。請在系統設定中為下面的命令分別建立自訂快速鍵(QA 與取消錄音命令可選):', - copyButton: '複製', - copyButtonCopied: '已複製', - commandToggleDictationLabel: '開始 / 停止錄音', - commandToggleQaLabel: '開啟 / 關閉 QA 面板', - commandCancelDictationLabel: '取消目前錄音', - helpToggle: '查看各桌面環境設定步驟', - gnomeTitle: 'GNOME', - gnomeSteps: '設定 → 鍵盤 → 檢視並自訂快速鍵 → 自訂快速鍵 → 新增快速鍵。重複新增 1-3 組,命令處分別填入上方命令,再錄入想用的按鍵組合。', - kdeTitle: 'KDE Plasma', - kdeSteps: '系統設定 → 鍵盤 → 快速鍵 → 新增 → 命令/URL,動作處分別貼上上方命令並錄入不同觸發鍵,儲存即可。', - hyprlandTitle: 'Hyprland', - hyprlandSteps: '編輯 ~/.config/hypr/hyprland.conf,加入下面任意 1-3 行後執行 hyprctl reload:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: '編輯 ~/.config/sway/config,加入下面任意 1-3 行後執行 swaymsg reload:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM 模型(潤色)', @@ -567,7 +549,7 @@ export const zhTW: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。', streamingInsertHintLinux: - 'X11 使用 enigo + XTest 合成按鍵;Wayland 下會自動關閉串流輸入,並保留到剪貼簿供手動貼上。', + '通過 fcitx5 插件提交文字;串流輸入使用 enigo + XTest 合成按鍵。', streamingInsertSaveClipboardLabel: '同步到剪貼簿', streamingInsertSaveClipboardHint: '插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。', localAsrTitle: '本地 ASR 模型(實驗性)', @@ -732,7 +714,7 @@ export const zhTW: typeof zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低層鍵盤 hook', - rdev: 'rdev 監聽器', + fcitx5: 'fcitx5 輸入法插件', }, }, localAsr: { diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 98852e8..f83773d 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -509,12 +509,6 @@ export function getHotkeyCapability(): Promise { return invokeOrMock('get_hotkey_capability', undefined, () => mockHotkeyCapability); } -// Linux/Wayland 检测:rdev 监听在 Wayland 协议层面失败(issue #420),需引导用户 -// 把 `openless --toggle-dictation` 绑到桌面环境快捷键。浏览器 / 非 Tauri 环境下永远 false。 -export function isWaylandCliMode(): Promise { - return invokeOrMock('is_wayland_cli_mode', undefined, () => false); -} - export function getWindowsImeStatus(): Promise { return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); } diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 7463125..d419f4f 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -74,7 +74,7 @@ export interface HotkeyBinding { keys?: HotkeyKey[] | null; } -export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'rdev'; +export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'fcitx5'; export interface HotkeyCapability { adapter: HotkeyAdapterKind; diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 725ea49..6041d2a 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -16,7 +16,6 @@ import { import { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder'; import { isTauri, - isWaylandCliMode, listMicrophoneDevices, openExternal, listProviderModels, @@ -200,24 +199,6 @@ function RecordingSection() { const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); const [microphonePickerOpen, setMicrophonePickerOpen] = useState(false); - // Wayland 下 rdev 监听不可用(issue #420)。改用 pull 模型:mount 时 invoke 拉状态。 - // 不能依赖一次性 event — Settings 模态是按需 mount,emit 早在 setup 阶段发完了。 - // XDG_SESSION_TYPE 在进程生命周期内不会变,拉一次即可,无需 polling 或 listener。 - const [waylandCliMode, setWaylandCliMode] = useState(false); - - useEffect(() => { - let cancelled = false; - void isWaylandCliMode() - .then(value => { - if (!cancelled) setWaylandCliMode(value); - }) - .catch((err: unknown) => { - console.warn('[settings] is_wayland_cli_mode query failed', err); - }); - return () => { - cancelled = true; - }; - }, []); const loadMicrophoneDevices = useCallback(async ( signal?: { cancelled: boolean }, @@ -385,11 +366,10 @@ function RecordingSection() { {t('settings.recording.migrationNoticeTitle')}
- {t('settings.recording.migrationNoticeDesc')} + {t('settings.recording.migrationNoticeDesc')}
)} - {waylandCliMode && } (null); - - const onCopy = useCallback(async (command: string) => { - try { - await navigator.clipboard.writeText(command); - setCopiedCommand(command); - // 1.5s 后还原按钮文案;同时校验仍是这条命令,避免被后点的覆盖。 - setTimeout(() => { - setCopiedCommand(prev => (prev === command ? null : prev)); - }, 1500); - } catch (err) { - console.warn('[wayland-callout] clipboard write failed', err); - } - }, []); - - // 三条 CLI 命令 + 用途短标签。aeoform 在 #420 反馈 1.3.1-19 没提 - // --toggle-qa / --cancel-dictation,本次补全。 - const commandRows: Array = [ - ['openless --toggle-dictation', t('settings.recording.wayland.commandToggleDictationLabel')], - ['openless --toggle-qa', t('settings.recording.wayland.commandToggleQaLabel')], - ['openless --cancel-dictation', t('settings.recording.wayland.commandCancelDictationLabel')], - ]; - - const helpEntries: Array = [ - [t('settings.recording.wayland.gnomeTitle'), t('settings.recording.wayland.gnomeSteps')], - [t('settings.recording.wayland.kdeTitle'), t('settings.recording.wayland.kdeSteps')], - [t('settings.recording.wayland.hyprlandTitle'), t('settings.recording.wayland.hyprlandSteps')], - [t('settings.recording.wayland.swayTitle'), t('settings.recording.wayland.swaySteps')], - ]; - - return ( -
-
- {t('settings.recording.wayland.calloutTitle')} -
-
- {t('settings.recording.wayland.calloutBody')} -
-
- {commandRows.map(([command, label]) => ( -
-
- - {command} - - -
- - {label} - -
- ))} -
- - {helpOpen && ( -
- {helpEntries.map(([title, body]) => ( -
-
- {title} -
-
- {body} -
-
- ))} -
- )} -
- ); -} - function HotkeyRecorder({ binding, onCommit, diff --git a/openless-all/app/src/pages/settings/AdvancedSection.tsx b/openless-all/app/src/pages/settings/AdvancedSection.tsx index 5495530..1eda22d 100644 --- a/openless-all/app/src/pages/settings/AdvancedSection.tsx +++ b/openless-all/app/src/pages/settings/AdvancedSection.tsx @@ -114,7 +114,7 @@ export function AdvancedSection() { 时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径): - macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底) - Windows:SendInput Unicode,绕过 TSF / IME,不需要切输入法 - - Linux(实验):X11 走 enigo + XTest;Wayland 下禁用流式输入并回落剪贴板 + - Linux:通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键 - 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级 - 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落 每个平台用各自的 hint key,互相不显示对方平台的细节。 */} diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 014f57d..d964237 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -202,5 +202,5 @@ function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) { function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); - return i18n.t('hotkey.adapter.rdev'); + return i18n.t('hotkey.adapter.fcitx5'); } diff --git a/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt b/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt new file mode 100644 index 0000000..ac219c3 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.12) +project(openless-fcitx5-plugin VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(Fcitx5Core REQUIRED) +find_package(Fcitx5Utils REQUIRED) +find_package(Fcitx5Module REQUIRED COMPONENTS DBus) + +# FCITX_INSTALL_*DIR comes from Fcitx5Utils +message(STATUS "FCITX_INSTALL_LIBDIR: ${FCITX_INSTALL_LIBDIR}") +message(STATUS "FCITX_INSTALL_PKGDATADIR: ${FCITX_INSTALL_PKGDATADIR}") +message(STATUS "FCITX_INSTALL_ADDONDIR: ${FCITX_INSTALL_ADDONDIR}") + +add_library(openless MODULE openless.cpp) +target_link_libraries(openless PRIVATE + Fcitx5::Core + Fcitx5::Utils) + +# Locate fcitx5 module headers (e.g. fcitx-module/dbus/dbus_public.h) +find_path(FCITX5_MODULE_INCLUDE_DIR + NAMES "fcitx-module/dbus/dbus_public.h" + HINTS "/usr/include/Fcitx5/Module" + PATH_SUFFIXES "include/Fcitx5/Module" +) +if(FCITX5_MODULE_INCLUDE_DIR) + target_include_directories(openless PRIVATE "${FCITX5_MODULE_INCLUDE_DIR}") + message(STATUS "FCITX5_MODULE_INCLUDE_DIR: ${FCITX5_MODULE_INCLUDE_DIR}") +else() + message(FATAL_ERROR "Cannot find fcitx5 module headers (fcitx-module/dbus/dbus_public.h)") +endif() + +# Install the plugin .so to fcitx5 addon dir +install(TARGETS openless + LIBRARY DESTINATION "${FCITX_INSTALL_ADDONDIR}") + +# Generate and install addon config +configure_file( + openless.conf.in + openless.conf + @ONLY) +install( + FILES "${CMAKE_CURRENT_BINARY_DIR}/openless.conf" + DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon") diff --git a/openless-all/scripts/linux-fcitx5-plugin/build.sh b/openless-all/scripts/linux-fcitx5-plugin/build.sh new file mode 100755 index 0000000..8eb7f77 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Build and optionally install the fcitx5 OpenLess plugin. +# +# Usage: +# ./build.sh # build only, .so in build/libopenless.so +# ./build.sh install # build + install to system fcitx5 dirs (requires sudo) +# +set -euo pipefail + +cd "$(dirname "$0")" + +BUILD_DIR="${BUILD_DIR:-build}" + +echo "==> Configuring..." +cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release + +echo "==> Building..." +cmake --build "$BUILD_DIR" --parallel + +echo "==> Plugin built: ${BUILD_DIR}/libopenless.so" + +if [ "${1:-}" = "install" ]; then + echo "==> Installing (requires sudo)..." + sudo cmake --install "$BUILD_DIR" + echo "==> Done. Restart fcitx5 to pick up the new plugin." +else + echo "==> Use '$0 install' to install system-wide." +fi diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.conf.in b/openless-all/scripts/linux-fcitx5-plugin/openless.conf.in new file mode 100644 index 0000000..dc54a67 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.conf.in @@ -0,0 +1,15 @@ +[Addon] +Name=OpenLess +Name[zh_CN]=OpenLess 听写辅助 +Comment=OpenLess dictation commit helper — exposes CommitText DBus method and dictation hotkey +Comment[zh_CN]=供 OpenLess 听写提交文字的 DBus 接口及快捷键监听 +Category=Module +Type=SharedLibrary +Library=libopenless +Version=1.0.0 +OnDemand=False +Configurable=False + +[Addon/Dependencies] +0=core:@Fcitx5Core_VERSION@ +1=dbus diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp new file mode 100644 index 0000000..9460cb0 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -0,0 +1,432 @@ +/* + * SPDX-FileCopyrightText: 2025 OpenLess Contributors + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + * fcitx5 插件 — 供 OpenLess 听写文字提交 + 快捷键监听。 + * + * DBus 接口: org.fcitx.Fcitx.OpenLess1 (对象路径 /openless) + * 方法: + * CommitText(s: text) — 将文字提交到当前焦点输入上下文 + * 安全性:本接口在会话总线(session bus)上对同用户 + * 所有进程开放,此为 fcitx5/IBus 体系的标准安全模型 + * (非特权进程隔离)。 + * SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式) + * SetHotkeyRaw(uu: sym, states) — 直接设听写触发 sym+states (不走 parse) + * SetCustomDictationTrigger(s: keyString) — 设置自定义组合键 (Key::parse 格式) + * SetQaHotkeyRaw(uu: sym, states) — 直接设 QA 面板触发 sym+states + * SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states + * 信号: + * DictationKeyEvent(uub: sym, states, isPress) — 听写热键按下/抬起 + * QaShortcutEvent(uub: sym, states, isPress) — QA 快捷键按下/抬起 + * TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起 + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +FCITX_DEFINE_LOG_CATEGORY(openless, "openless"); + +namespace fcitx { + +FCITX_CONFIGURATION(OpenLessConfig, + KeyListOption triggerKey{this, + "TriggerKey", + _("Dictation trigger key"), + {}, + KeyListConstrain()}; +); + +class OpenLess final : public AddonInstance, + public dbus::ObjectVTable { +public: + OpenLess(Instance *instance) + : instance_(instance), + triggerRawSym_(0), + triggerRawStates_(0), + qaRawSym_(0), + qaRawStates_(0), + translationRawSym_(0), + translationRawStates_(0), + hasCustomDictationKey_(false), + savedIc_(nullptr) { + + // 1. 读取配置 + reloadConfig(); + + // 2. 注册 DBus 接口 + auto *dbusMod = instance_->addonManager().addon("dbus", true); + if (dbusMod) { + auto *bus = dbusMod->call(); + if (bus) { + bus->addObjectVTable( + "/openless", + "org.fcitx.Fcitx.OpenLess1", + *this); + FCITX_LOGC(openless, Info) + << "DBus interface registered at /openless"; + } else { + FCITX_LOGC(openless, Warn) + << "Failed to get DBus bus"; + } + } else { + FCITX_LOGC(openless, Warn) + << "DBus module not available"; + } + + // 3. 注册快捷键事件监听 + eventHandlers_.push_back( + instance_->watchEvent( + EventType::InputContextKeyEvent, + EventWatcherPhase::PreInputMethod, + [this](Event &event) { + auto &keyEvent = static_cast(event); + // 保存当前输入上下文:快捷键按下时用户在目标 app 中, + // 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。 + if (!keyEvent.isRelease()) { + savedIc_ = keyEvent.inputContext(); + } + + auto sym = static_cast(keyEvent.key().sym()); + auto states = static_cast(keyEvent.key().states()); + bool isPress = !keyEvent.isRelease(); + + // 检查自定义组合键(优先级最高) + if (hasCustomDictationKey_ && + keyEvent.key().sym() == customDictationKey_.sym() && + keyEvent.key().states() == customDictationKey_.states()) { + FCITX_LOGC(openless, Debug) + << "Custom dictation combo: sym=" << sym + << " states=" << states + << " isPress=" << isPress; + dictationKeyEvent( + static_cast(customDictationKey_.sym()), + static_cast(customDictationKey_.states()), + isPress); + keyEvent.filterAndAccept(); + return; + } + + // 检查听写触发键(raw + keylist 双路径) + if ((triggerRawSym_ != 0 && + sym == triggerRawSym_ && + states == triggerRawStates_) || + (triggerRawSym_ == 0 && [&]() { + for (const auto &hk : triggerKeyList_) { + if (sym == static_cast(hk.sym()) && + states == static_cast(hk.states())) + return true; + } + return false; + }())) { + auto dsym = triggerRawSym_ != 0 + ? triggerRawSym_ + : static_cast(triggerKeyList_[0].sym()); + auto dstates = triggerRawStates_ != 0 + ? triggerRawStates_ + : static_cast(triggerKeyList_[0].states()); + FCITX_LOGC(openless, Debug) + << "Dictation hotkey: sym=" << dsym + << " states=" << dstates + << " isPress=" << isPress; + dictationKeyEvent(dsym, dstates, isPress); + keyEvent.filterAndAccept(); + return; + } + + // 检查 QA 快捷键 + if (qaRawSym_ != 0 && + sym == qaRawSym_ && + states == qaRawStates_) { + FCITX_LOGC(openless, Debug) + << "QA shortcut: sym=" << qaRawSym_ + << " states=" << qaRawStates_ + << " isPress=" << isPress; + qaShortcutEvent(qaRawSym_, qaRawStates_, isPress); + keyEvent.filterAndAccept(); + return; + } + + // 检查翻译模式修饰键(自定义 + 内置 Shift) + bool translationMatched = false; + if (translationRawSym_ != 0 && + sym == translationRawSym_ && + states == translationRawStates_) { + translationMatched = true; + } + // 内置 Shift 修饰键 + if (sym == 0xffe1 || sym == 0xffe2) { + translationMatched = true; + } + if (translationMatched) { + FCITX_LOGC(openless, Debug) + << "Translation modifier: sym=" << sym + << " states=" << states + << " isPress=" << isPress; + translationModifierEvent(sym, states, isPress); + keyEvent.filterAndAccept(); + return; + } + })); + + // 4. 监听 InputContext 销毁事件,自动清空 savedIc_ 避免野指针 + eventHandlers_.push_back( + instance_->watchEvent( + EventType::InputContextDestroyed, + EventWatcherPhase::Default, + [this](Event &event) { + auto &icEvent = static_cast(event); + if (icEvent.inputContext() == savedIc_) { + savedIc_ = nullptr; + } + })); + + FCITX_LOGC(openless, Info) << "OpenLess plugin loaded"; + } + + ~OpenLess() = default; + + // ---- DBus 方法 ---- + // 返回 void 而非 std::tuple<>,以匹配 FCITX_OBJECT_VTABLE_METHOD 的 RET("") + + void commitText(const std::string &text) { + // 优先使用快捷键按下时保存的输入上下文(savedIc_), + // 此时用户在目标 app 中,此后胶囊窗口抢焦点不影响提交。 + // 若 savedIc_ 为空则兜底用 foreachFocused。 + auto *ic = savedIc_; + if (!ic) { + FCITX_LOGC(openless, Warn) + << "CommitText: savedIc_ is null, trying foreachFocused"; + auto &mgr = instance_->inputContextManager(); + mgr.foreachFocused([&](InputContext *focusedIc) { + ic = focusedIc; + return false; + }); + } + if (!ic) { + FCITX_LOGC(openless, Warn) + << "CommitText: no input context available"; + throw std::runtime_error("no focused input context"); + } + FCITX_LOGC(openless, Debug) << "CommitText: " << text; + ic->commitString(text); + } + + void setHotkey(const std::vector &keys) { + // 切换预设修饰键时清空自定义组合键,避免双发 + hasCustomDictationKey_ = false; + KeyList keyList; + for (const auto &s : keys) { + Key key(s); + if (key.isValid()) { + keyList.push_back(key); + } else { + FCITX_LOGC(openless, Warn) + << "SetHotkey: invalid key '" << s << "'"; + } + } + config_.triggerKey.setValue(keyList); + // KeyList 路径激活时清空 raw 路径,避免优先级冲突 + triggerRawSym_ = 0; + triggerRawStates_ = 0; + safeSaveAsIni(config_, configFile()); + // 同时清除磁盘上残留的 TriggerRawSym/TriggerRawStates(旧 raw 模式的持久化值), + // 防止下次 fcitx5 重启 reloadConfig 重新加载旧 raw 热键覆盖新配置。 + { + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("TriggerRawSym", "0"); + raw.setValueByPath("TriggerRawStates", "0"); + safeSaveAsIni(raw, configFile()); + } + rebuildTriggerKeys(); + } + + void setHotkeyRaw(uint32_t sym, uint32_t states) { + // 切换预设修饰键时清空自定义组合键,避免双发 + hasCustomDictationKey_ = false; + triggerRawSym_ = sym; + triggerRawStates_ = states; + // 同时尝试维护 KeyList(如果 sym 可转为有效 key) + Key key(static_cast(sym), + static_cast(states)); + if (key.isValid()) { + KeyList keys = {key}; + config_.triggerKey.setValue(keys); + } else { + // 修饰键无法用 KeyList 表达,清空 KeyList 避免误匹配 + config_.triggerKey.setValue(KeyList{}); + } + // 合并写入 config 和 raw sym/states + RawConfig raw; + raw.setValueByPath("TriggerRawSym", std::to_string(sym)); + raw.setValueByPath("TriggerRawStates", std::to_string(states)); + config_.save(raw); + safeSaveAsIni(raw, configFile()); + rebuildTriggerKeys(); + } + + void setCustomDictationTrigger(const std::string &keyString) { + Key key(keyString); + if (!key.isValid()) { + FCITX_LOGC(openless, Warn) + << "SetCustomDictationTrigger: invalid key '" << keyString << "'"; + hasCustomDictationKey_ = false; + return; + } + customDictationKey_ = key; + hasCustomDictationKey_ = true; + // 有自定义键时清空已有 raw+keylist 路径,避免双发 + triggerRawSym_ = 0; + triggerRawStates_ = 0; + config_.triggerKey.setValue(KeyList{}); + // 同时持久化清空 TriggerRawSym/TriggerRawStates,防止 fcitx5 重启后从 INI 加载旧值 + { + RawConfig raw; + readAsIni(raw, configFile()); + config_.save(raw); + raw.setValueByPath("TriggerRawSym", "0"); + raw.setValueByPath("TriggerRawStates", "0"); + safeSaveAsIni(raw, configFile()); + } + FCITX_LOGC(openless, Info) + << "SetCustomDictationTrigger: '" << keyString << "'" + << " sym=" << static_cast(key.sym()) + << " states=" << static_cast(key.states()); + } + + void setQaHotkeyRaw(uint32_t sym, uint32_t states) { + qaRawSym_ = sym; + qaRawStates_ = states; + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("QaRawSym", std::to_string(sym)); + raw.setValueByPath("QaRawStates", std::to_string(states)); + safeSaveAsIni(raw, configFile()); + FCITX_LOGC(openless, Info) + << "SetQaHotkeyRaw: sym=" << sym << " states=" << states; + } + + void setTranslationHotkeyRaw(uint32_t sym, uint32_t states) { + translationRawSym_ = sym; + translationRawStates_ = states; + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("TranslationRawSym", std::to_string(sym)); + raw.setValueByPath("TranslationRawStates", std::to_string(states)); + safeSaveAsIni(raw, configFile()); + FCITX_LOGC(openless, Info) + << "SetTranslationHotkeyRaw: sym=" << sym << " states=" << states; + } + + FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", ""); + FCITX_OBJECT_VTABLE_METHOD(setHotkey, "SetHotkey", "as", ""); + FCITX_OBJECT_VTABLE_METHOD(setHotkeyRaw, "SetHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setCustomDictationTrigger, "SetCustomDictationTrigger", "s", ""); + FCITX_OBJECT_VTABLE_METHOD(setQaHotkeyRaw, "SetQaHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(translationModifierEvent, "TranslationModifierEvent", "uub"); + + Instance *instance() { return instance_; } + + void reloadConfig() override { + readAsIni(config_, configFile()); + // 加载原始 sym/states(由 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw 写入的持久化键值) + RawConfig raw; + readAsIni(raw, configFile()); + { + auto *v = raw.valueByPath("TriggerRawSym"); + triggerRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("TriggerRawStates"); + triggerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("QaRawSym"); + qaRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("QaRawStates"); + qaRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("TranslationRawSym"); + translationRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("TranslationRawStates"); + translationRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + rebuildTriggerKeys(); + } + + const Configuration *getConfig() const override { + return &config_; + } + + void setConfig(const RawConfig &rawConfig) override { + config_.load(rawConfig, true); + safeSaveAsIni(config_, configFile()); + rebuildTriggerKeys(); + } + +private: + static constexpr const char *configFile() { + return "conf/openless.conf"; + } + + void rebuildTriggerKeys() { + triggerKeyList_ = config_.triggerKey.value(); + } + + Instance *instance_; + OpenLessConfig config_; + KeyList triggerKeyList_; + uint32_t triggerRawSym_; + uint32_t triggerRawStates_; + uint32_t qaRawSym_; + uint32_t qaRawStates_; + uint32_t translationRawSym_; + uint32_t translationRawStates_; + Key customDictationKey_; + bool hasCustomDictationKey_; + /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 + /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 + /// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。 + InputContext *savedIc_; + std::vector>> + eventHandlers_; +}; + +class OpenLessFactory : public AddonFactory { +public: + AddonInstance *create(AddonManager *manager) override { + return new OpenLess(manager->instance()); + } +}; + +} // namespace fcitx + +FCITX_ADDON_FACTORY(fcitx::OpenLessFactory);