feat(linux): replace enigo with fcitx5 plugin for Wayland input

- fcitx5 C++ plugin (scripts/linux-fcitx5-plugin/) with DBus interface:
  CommitText, SetHotkey/SetHotkeyRaw, DictationKeyEvent signal
- linux_fcitx.rs: DBus client to call plugin from Rust
- coordinator/dictation.rs: Wayland insertion via fcitx5 commit_text,
  streaming insert enabled on Wayland
- insertion.rs: fcitx5 commit_text on Linux with clipboard fallback
- unicode_keystroke.rs: Linux path uses fcitx5 commit_text
- Capsule window show-once on Linux to avoid stealing focus

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
aeoformandClaude Opus 4.7 committed 2026-05-16 08:53:06 +08:00
1 parent e8d0ccf303
commit 2912aefed5
12 files changed
+652 -62

No files matched your search

+1
View File
@@ -3762,6 +3762,7 @@ dependencies = [
"core-foundation 0.10.1",
"core-graphics 0.24.0",
"cpal",
"dbus",
"enigo",
"env_logger",
"ferrous-opencc",
+2
View File
@@ -62,6 +62,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
+47 -2
View File
@@ -150,6 +150,10 @@ struct Inner {
/// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发
/// resize / reposition。
capsule_layout: Mutex<Option<CapsuleLayoutState>>,
/// Linux: 胶囊窗口当前是否已 show 过。防止每次 emit_capsule 都调 window.show()
/// 抢走目标 app 的键盘焦点。首次 Idle→可见时 show 一次,后续可见→可见不重新 show。
/// 回到 Idle 时 reset 为 false,下次 session 再 show。
capsule_window_visible: AtomicBool,
/// QA 用的 ASR 句柄(始终是 Volcengine 流式)。
qa_asr: Mutex<Option<Arc<VolcengineStreamingASR>>>,
/// QA 用的 Recorder 句柄。
@@ -223,6 +227,7 @@ impl Coordinator {
qa_hotkey: Mutex::new(None),
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
capsule_window_visible: AtomicBool::new(false),
qa_asr: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
@@ -273,6 +278,7 @@ impl Coordinator {
qa_hotkey: Mutex::new(None),
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
capsule_window_visible: AtomicBool::new(false),
qa_asr: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
@@ -702,6 +708,8 @@ impl Coordinator {
return;
}
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
#[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 +725,12 @@ impl Coordinator {
.name("openless-hotkey-bridge".into())
.spawn(move || hotkey_bridge_loop(inner_clone, rx))
.ok();
// Wayland: 启动 fcitx5 插件信号监听作为热键源(X11 走 rdev 避免双发)。
#[cfg(target_os = "linux")]
if crate::hotkey::is_wayland_session() {
crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx);
crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding);
}
}
Err(e) => {
*self.inner.hotkey_status.lock() = HotkeyStatus {
@@ -938,7 +952,6 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)),
last_error: None,
};
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey)
.unwrap_or(crate::types::HotkeyTrigger::Custom);
let binding = crate::types::HotkeyBinding {
@@ -946,6 +959,9 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
mode: prefs.hotkey.mode,
keys: None,
};
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
#[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 +985,12 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
.name("openless-hotkey-bridge".into())
.spawn(move || hotkey_bridge_loop(inner_clone, rx))
.ok();
// Wayland: 启动 fcitx5 插件信号监听作为热键源(X11 走 rdev 避免双发)。
#[cfg(target_os = "linux")]
if crate::hotkey::is_wayland_session() {
crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx);
crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding);
}
return;
}
Err(e) => {
@@ -4037,7 +4059,15 @@ fn show_capsule_window_no_activate<R: tauri::Runtime>(
// `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<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
false
}
#[cfg(target_os = "macos")]
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
@@ -4129,6 +4159,17 @@ 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(not(any(target_os = "macos", target_os = "windows")))]
{
// Linux: 仅首次 Idle→可见时 show 窗口,避免每次 emit 都抢焦点。
let was_visible = inner_for_main
.capsule_window_visible
.swap(true, std::sync::atomic::Ordering::SeqCst);
if !was_visible {
let _ = window.show();
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
let _ = window.show();
}
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走主窗口点击焦点。
@@ -4136,6 +4177,10 @@ fn emit_capsule(
#[cfg(target_os = "macos")]
crate::restore_main_window_key_if_active(&app_for_main);
} else {
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
inner_for_main
.capsule_window_visible
.store(false, std::sync::atomic::Ordering::SeqCst);
hide_capsule_window_if_present();
let _ = window.hide();
}
@@ -329,23 +329,21 @@ 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<String> {
fn fcitx_fallback_done_message(status: InsertStatus, polish_failed: bool) -> Option<String> {
match status {
InsertStatus::Inserted | InsertStatus::PasteSent => None,
InsertStatus::CopiedFallback => Some(if polish_failed {
"Wayland 未启用自动输入,已复制原文到剪贴板,请手动粘贴".to_string()
"未检测到 fcitx5,已复制原文到剪贴板,请手动粘贴".to_string()
} else {
"Wayland 未启用自动输入,已复制到剪贴板,请手动粘贴".to_string()
"未检测到 fcitx5,已复制到剪贴板,请手动粘贴".to_string()
}),
InsertStatus::Failed => Some("Wayland 未启用自动输入,剪贴板写入失败".to_string()),
InsertStatus::Failed => Some("fcitx5 不可用,剪贴板写入失败".to_string()),
}
}
@@ -1416,7 +1414,6 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
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}"
@@ -1524,23 +1521,40 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
);
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")
// Wayland: 优先用 fcitx5 插件直写(支持中文),降级到剪贴板拷贝。
#[cfg(target_os = "linux")]
{
if crate::linux_fcitx::commit_text(&polished).is_ok() {
log::info!(
"[coord] Wayland fcitx5 commit succeeded ({} chars)",
polished.chars().count()
);
InsertStatus::Inserted
} else {
log::info!(
"[coord] Wayland fcitx5 unavailable; 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
}
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
#[cfg(not(target_os = "linux"))]
{
let status = inner.inserter.copy_fallback(&polished);
status
}
} else if focus_ready_for_paste {
#[cfg(target_os = "windows")]
{
@@ -1637,7 +1651,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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())
fcitx_fallback_done_message(status, polish_error.is_some())
} else {
default_done_message(status, polish_error.is_some())
};
@@ -1726,8 +1740,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,
fcitx_fallback_done_message, finalize_polished_text, streaming_insert_eligible,
};
use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode};
@@ -1814,13 +1828,12 @@ mod tests {
}
#[test]
fn wayland_disables_streaming_insert_even_when_pref_enabled() {
assert!(!streaming_insert_eligible(
fn streaming_insert_no_longer_blocked_by_wayland() {
assert!(streaming_insert_eligible(
true,
false,
PolishMode::Light,
false,
true
));
}
@@ -1831,23 +1844,22 @@ mod tests {
false,
PolishMode::Light,
false,
false
));
}
#[test]
fn wayland_done_message_tells_user_manual_paste_is_required() {
fn fcitx_fallback_done_message_tells_user_manual_paste_is_required() {
assert_eq!(
wayland_done_message(InsertStatus::CopiedFallback, false),
Some("Wayland 未启用自动输入,已复制到剪贴板,请手动粘贴".to_string())
fcitx_fallback_done_message(InsertStatus::CopiedFallback, false),
Some("未检测到 fcitx5,已复制到剪贴板,请手动粘贴".to_string())
);
assert_eq!(
wayland_done_message(InsertStatus::CopiedFallback, true),
Some("Wayland 未启用自动输入,已复制原文到剪贴板,请手动粘贴".to_string())
fcitx_fallback_done_message(InsertStatus::CopiedFallback, true),
Some("未检测到 fcitx5,已复制原文到剪贴板,请手动粘贴".to_string())
);
assert_eq!(
wayland_done_message(InsertStatus::Failed, false),
Some("Wayland 未启用自动输入,剪贴板写入失败".to_string())
fcitx_fallback_done_message(InsertStatus::Failed, false),
Some("fcitx5 不可用,剪贴板写入失败".to_string())
);
}
@@ -50,6 +50,21 @@ impl TextInserter {
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
// Linux: 使用 fcitx5 CommitText 直写(支持中文、兼容 Wayland/X11)。
// 失败时仅复制到剪贴板,不走 enigo XTest(Wayland 不可用)。
#[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)
}
+1
View File
@@ -21,6 +21,7 @@ mod correction;
mod global_hotkey_runtime;
mod hotkey;
mod insertion;
mod linux_fcitx;
mod llm_gemini;
mod permissions;
mod persistence;
@@ -0,0 +1,176 @@
//! 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<String> = 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(())
}
/// X11 keysym 值(用于 SetHotkeyRaw,绕过 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;
/// 将 OpenLess 的热键绑定同步到 fcitx5 插件。
///
/// 把 `HotkeyTrigger`(如 RightControl)转换为 fcitx5 keysym,
/// 通过 `SetHotkeyRaw` 配置插件(绕过 fcitx5 Key 类对纯修饰键的限制)。
pub fn sync_binding_to_plugin(binding: &crate::types::HotkeyBinding) {
if binding.trigger == crate::types::HotkeyTrigger::Custom {
return;
}
let (sym, name) = match binding.trigger {
crate::types::HotkeyTrigger::RightControl => (KEYSYM_CONTROL_R, "Control_R"),
crate::types::HotkeyTrigger::LeftControl => (KEYSYM_CONTROL_L, "Control_L"),
crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => {
(KEYSYM_ALT_R, "Alt_R")
}
crate::types::HotkeyTrigger::LeftOption => (KEYSYM_ALT_L, "Alt_L"),
crate::types::HotkeyTrigger::RightCommand => (KEYSYM_SUPER_R, "Super_R"),
crate::types::HotkeyTrigger::Fn => (KEYSYM_SUPER_L, "Super_L"),
crate::types::HotkeyTrigger::Custom => unreachable!(),
};
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}"),
}
}
/// 快速检查 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 连接断开时自动退出。
///
/// # 调用时机
///
/// 仅在 Wayland session 下调用(X11 走 rdev 避免双发)。
#[cfg(target_os = "linux")]
pub fn start_dictation_signal_listener(
tx: std::sync::mpsc::Sender<crate::hotkey::HotkeyEvent>,
) {
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',\
member='DictationKeyEvent'",
) {
Ok(r) => r,
Err(e) => {
log::warn!("[fcitx-hotkey] Invalid match rule: {e}");
return;
}
};
// 信号参数: (sym: u32, states: u32, is_press: bool)
if let Err(e) = conn.add_match(rule, move |args: (u32, u32, bool), _conn, _msg| {
let (_sym, _states, is_press) = args;
log::debug!(
"[fcitx-hotkey] DictationKeyEvent: sym={}, states={}, isPress={}",
_sym,
_states,
is_press,
);
let event = if is_press {
crate::hotkey::HotkeyEvent::Pressed
} else {
crate::hotkey::HotkeyEvent::Released
};
let _ = tx.send(event);
true // 保持匹配活跃
}) {
log::warn!("[fcitx-hotkey] Failed to add match: {e}");
return;
}
log::info!("[fcitx-hotkey] Listening for DictationKeyEvent signals");
loop {
if let Err(e) = conn.process(Duration::from_millis(500)) {
log::warn!("[fcitx-hotkey] DBus process error: {e}");
break;
}
}
})
.ok();
}
@@ -412,39 +412,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)。
pub fn type_unicode_chunk(text: &str) -> Result<usize, TypeError> {
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;
// fcitx5 插件能处理全部文字,且是 native 方式(不走键盘合成),
// 所以整个 chunk 一次性 commit 即可,返回全部字符数。
// 失败时不降级——enigo XTest 在 Wayland 不可用。
if crate::linux_fcitx::commit_text(text).is_ok() {
Ok(text.chars().count())
} else {
Err(TypeError::EnigoText("commit_text failed on Wayland, try clipboard fallback".into()))
}
Ok(typed_chars)
}
pub async fn switch_to_ascii<R: Runtime>(
@@ -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")
+29
View File
@@ -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
@@ -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
@@ -0,0 +1,264 @@
/*
* SPDX-FileCopyrightText: 2025 OpenLess Contributors
*
* SPDX-License-Identifier: LGPL-2.1-or-later
*
* fcitx5 插件 — 供 OpenLess 听写文字提交 + 快捷键监听。
*
* DBus 接口: org.fcitx.Fcitx.OpenLess1 (对象路径 /openless)
* 方法:
* CommitText(s: text) — 将文字提交到当前焦点输入上下文
* SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式)
* SetHotkeyRaw(uu: sym, states) — 直接设 sym+states (不走 parse)
* 信号:
* DictationKeyEvent(uu: sym, states) — 热键被按下
*
* 后续: 当需要 IBus 引擎兼容时 (GNOME),另行实现 org.freedesktop.IBus.Engine。
*/
#include <memory>
#include <string>
#include <vector>
#include <fcitx-config/configuration.h>
#include <fcitx-config/iniparser.h>
#include <fcitx-config/option.h>
#include <fcitx-utils/dbus/bus.h>
#include <fcitx-utils/dbus/objectvtable.h>
#include <fcitx-utils/handlertable.h>
#include <fcitx-utils/i18n.h>
#include <fcitx-utils/key.h>
#include <fcitx-utils/log.h>
#include <fcitx/addonfactory.h>
#include <fcitx/addoninstance.h>
#include <fcitx/addonmanager.h>
#include <fcitx/event.h>
#include <fcitx/inputcontext.h>
#include <fcitx/inputcontextmanager.h>
#include <fcitx/instance.h>
#include <fcitx-module/dbus/dbus_public.h>
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<OpenLess> {
public:
OpenLess(Instance *instance)
: instance_(instance),
triggerRawSym_(0),
triggerRawStates_(0),
savedIc_(nullptr) {
// 1. 读取配置
reloadConfig();
// 2. 注册 DBus 接口
auto *dbusMod = instance_->addonManager().addon("dbus", true);
if (dbusMod) {
auto *bus = dbusMod->call<IDBusModule::bus>();
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<KeyEvent &>(event);
// 保存当前输入上下文:快捷键按下时用户在目标 app 中,
// 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。
if (!keyEvent.isRelease()) {
auto *ic = keyEvent.inputContext();
if (ic) {
savedIc_ = ic;
}
}
// 先检查 raw sym/states(修饰键专用路径,绕过 Key::parse 限制)
if ((triggerRawSym_ != 0 &&
keyEvent.key().sym() == static_cast<KeySym>(triggerRawSym_) &&
keyEvent.key().states() == static_cast<KeyStates>(triggerRawStates_)) ||
(triggerRawSym_ == 0 && [&]() {
for (const auto &hk : triggerKeyList_) {
if (keyEvent.key().sym() == hk.sym() &&
keyEvent.key().states() == hk.states())
return true;
}
return false;
}())) {
auto sym = triggerRawSym_ != 0
? triggerRawSym_
: static_cast<uint32_t>(triggerKeyList_[0].sym());
auto states = triggerRawStates_ != 0
? triggerRawStates_
: static_cast<uint32_t>(triggerKeyList_[0].states());
bool isPress = !keyEvent.isRelease();
FCITX_LOGC(openless, Debug)
<< "Dictation hotkey: sym="
<< sym << " states=" << states
<< " isPress=" << isPress;
dictationKeyEvent(sym, states, isPress);
keyEvent.filterAndAccept();
return;
}
}));
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<std::string> &keys) {
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());
rebuildTriggerKeys();
}
void setHotkeyRaw(uint32_t sym, uint32_t states) {
triggerRawSym_ = sym;
triggerRawStates_ = states;
// 同时尝试维护 KeyList(如果 sym 可转为有效 key)
Key key(static_cast<KeySym>(sym),
static_cast<KeyStates>(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();
}
FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", "");
FCITX_OBJECT_VTABLE_METHOD(setHotkey, "SetHotkey", "as", "");
FCITX_OBJECT_VTABLE_METHOD(setHotkeyRaw, "SetHotkeyRaw", "uu", "");
FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub");
Instance *instance() { return instance_; }
void reloadConfig() override {
readAsIni(config_, configFile());
// 加载原始 sym/states(由 SetHotkeyRaw 写入的持久化键值)
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;
}
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_;
/// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。
/// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。
InputContext *savedIc_;
std::vector<std::unique_ptr<HandlerTableEntry<EventHandler>>>
eventHandlers_;
};
class OpenLessFactory : public AddonFactory {
public:
AddonInstance *create(AddonManager *manager) override {
return new OpenLess(manager->instance());
}
};
} // namespace fcitx
FCITX_ADDON_FACTORY(fcitx::OpenLessFactory);