Merge pull request #451 from aeoform/beta

feat(linux): fcitx5 plugin for Wayland input + deb packaging
This commit is contained in:
TRIP authored and GitHub committed 2026-05-18 09:07:13 +08:00
commit c4bbec9cef
28 files changed
+1085 -951

No files matched your search

+62 -6
View File
@@ -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 != ''
+11 -112
View File
@@ -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",
]
+2 -1
View File
@@ -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
+1 -4
View File
@@ -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 是补充而非替代。
//!
@@ -484,18 +484,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);
+91 -10
View File
@@ -80,11 +80,11 @@ enum CapsuleShowStrategy {
}
fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy {
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
CapsuleShowStrategy::NoActivate
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
CapsuleShowStrategy::FallbackShow
}
@@ -520,6 +520,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}");
@@ -739,10 +741,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::<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();
@@ -758,6 +770,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 {
@@ -803,16 +825,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;
}
@@ -973,13 +993,29 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
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::<HotkeyEvent>();
let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey)
.unwrap_or(crate::types::HotkeyTrigger::Custom);
let binding = crate::types::HotkeyBinding {
@@ -987,6 +1023,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();
@@ -1010,6 +1049,16 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
.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) => {
@@ -1577,6 +1626,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<Inner>) {
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<Inner>,
) -> (
@@ -3656,7 +3720,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")
);
}
@@ -3673,7 +3737,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")
);
}
@@ -4126,7 +4190,17 @@ fn show_capsule_window_no_activate<R: tauri::Runtime>(
true
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
#[cfg(target_os = "linux")]
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
// Linux/fcitx5: Wayland 上弹胶囊窗口会触发 workspace 跳转且无法可靠
// no-activate。返回 true 抑制胶囊窗口,不让 wrapper fallback 到 window.show()。
true
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
@@ -4210,6 +4284,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 加的
@@ -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<String> {
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<String> {
@@ -1410,16 +1396,14 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<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())
} 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")
);
}
}
+20 -378
View File
@@ -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<String>) -> 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<HotkeyEvent>`。
pub fn start_adapter(
binding: HotkeyBinding,
_binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
) -> Result<Box<dyn HotkeyAdapter>, 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<HotkeyEvent>,
}
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<HotkeyTrigger>,
_translation_trigger: Option<HotkeyTrigger>,
) {
// 同上 — modifier-only 修饰键在 Wayland 上也走不通,留空。
}
fn reset_held_state(&self) {}
}
struct RdevHotkeyAdapter {
shared: Arc<Shared>,
}
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<HotkeyTrigger>,
translation_trigger: Option<HotkeyTrigger>,
) {
update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger);
crate::linux_fcitx::sync_qa_binding(qa_trigger);
crate::linux_fcitx::sync_translation_binding(translation_trigger);
}
fn reset_held_state(&self) {
reset_shared_held_state(&self.shared);
}
}
fn run_listen_loop(shared: Arc<Shared>, tx: Sender<HotkeyEvent>, 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<HotkeyEvent>, 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<HotkeyEvent>,
key: Key,
trigger: Option<HotkeyTrigger>,
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<HotkeyTrigger>,
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);
}
}
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<HotkeyEvent>) -> Vec<HotkeyEvent> {
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) {}
}
}
@@ -42,6 +42,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)
}
+3 -10
View File
@@ -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<String> = std::env::args().collect();
@@ -273,7 +267,6 @@ pub fn run() {
commands::app_check_update_with_channel,
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,
@@ -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<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(())
}
/// 通过 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<String> = 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<crate::types::HotkeyTrigger>) {
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<crate::types::HotkeyTrigger>) {
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<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'",
) {
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();
}
+1 -1
View File
@@ -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。
+5 -5
View File
@@ -636,7 +636,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)静默拒绝
@@ -1825,7 +1825,7 @@ pub enum HotkeyMode {
pub enum HotkeyAdapterKind {
MacEventTap,
WindowsLowLevel,
Rdev,
Fcitx5,
}
impl HotkeyAdapterKind {
@@ -1833,7 +1833,7 @@ impl HotkeyAdapterKind {
match self {
HotkeyAdapterKind::MacEventTap => "macOS Event Tap",
HotkeyAdapterKind::WindowsLowLevel => "Windows 低层键盘 hook",
HotkeyAdapterKind::Rdev => "rdev 监听器",
HotkeyAdapterKind::Fcitx5 => "fcitx5 输入法插件",
}
}
}
@@ -2034,7 +2034,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,
@@ -2046,7 +2046,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(),
),
}
}
@@ -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<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;
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<R: Runtime>(
+2 -20
View File
@@ -571,24 +571,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)',
@@ -732,7 +714,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)',
@@ -901,7 +883,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: {
+2 -20
View File
@@ -573,24 +573,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 モデル(整文)',
@@ -734,7 +716,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 モデル(実験的)',
@@ -903,7 +885,7 @@ export const ja: typeof zhCN = {
adapter: {
macEventTap: 'macOS Event Tap',
windowsLowLevel: 'Windows 低レベルキーボードフック',
rdev: 'rdev リスナー',
fcitx5: 'fcitx5 インプットメソッドプラグイン',
},
},
localAsr: {
+2 -20
View File
@@ -573,24 +573,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 모델(정리)',
@@ -734,7 +716,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 모델 (실험적)',
@@ -903,7 +885,7 @@ export const ko: typeof zhCN = {
adapter: {
macEventTap: 'macOS Event Tap',
windowsLowLevel: 'Windows 저수준 키보드 후크',
rdev: 'rdev 리스너',
fcitx5: 'fcitx5 입력기 플러그인',
},
},
localAsr: {
+2 -20
View File
@@ -569,24 +569,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 模型(润色)',
@@ -730,7 +712,7 @@ export const zhCN = {
streamingInsertHintWindows:
'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。',
streamingInsertHintLinux:
'X11 使用 enigo + XTest 合成按键;Wayland 下会自动关闭流式输入,并保留到剪贴板供手动粘贴。',
'通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。',
streamingInsertSaveClipboardLabel: '同步到剪贴板',
streamingInsertSaveClipboardHint: '插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。',
localAsrTitle: '本地 ASR 模型(实验性)',
@@ -899,7 +881,7 @@ export const zhCN = {
adapter: {
macEventTap: 'macOS Event Tap',
windowsLowLevel: 'Windows 低层键盘 hook',
rdev: 'rdev 监听器',
fcitx5: 'fcitx5 输入法插件',
},
},
localAsr: {
+2 -20
View File
@@ -571,24 +571,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 模型(潤色)',
@@ -732,7 +714,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 模型(實驗性)',
@@ -901,7 +883,7 @@ export const zhTW: typeof zhCN = {
adapter: {
macEventTap: 'macOS Event Tap',
windowsLowLevel: 'Windows 低層鍵盤 hook',
rdev: 'rdev 監聽器',
fcitx5: 'fcitx5 輸入法插件',
},
},
localAsr: {
-6
View File
@@ -507,12 +507,6 @@ export function getHotkeyCapability(): Promise<HotkeyCapability> {
return invokeOrMock('get_hotkey_capability', undefined, () => mockHotkeyCapability);
}
// Linux/Wayland 检测:rdev 监听在 Wayland 协议层面失败(issue #420),需引导用户
// 把 `openless --toggle-dictation` 绑到桌面环境快捷键。浏览器 / 非 Tauri 环境下永远 false。
export function isWaylandCliMode(): Promise<boolean> {
return invokeOrMock('is_wayland_cli_mode', undefined, () => false);
}
export function getWindowsImeStatus(): Promise<WindowsImeStatus> {
return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus);
}
+1 -1
View File
@@ -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;
+1 -186
View File
@@ -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<string | null>(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 },
@@ -383,11 +364,10 @@ function RecordingSection() {
{t('settings.recording.migrationNoticeTitle')}
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
{t('settings.recording.migrationNoticeDesc')}
{t('settings.recording.migrationNoticeDesc')}
</div>
</div>
)}
{waylandCliMode && <WaylandHotkeyCallout />}
<SettingRow label={t('settings.recording.hotkeyLabel')} desc={hotkeyDesc}>
<ShortcutRecorder
value={prefs.dictationHotkey}
@@ -637,171 +617,6 @@ function RecordingSection() {
);
}
/// Wayland 引导 callout:告诉用户绑桌面环境快捷键到 `openless --toggle-dictation`。
/// 显示条件由父组件控制(mount 时 invoke `is_wayland_cli_mode` 拉状态后渲染)。
/// 文案逐桌面环境列出步骤,每段 ≤ 5 行,详细的命令在 README 里。
function WaylandHotkeyCallout() {
const { t } = useTranslation();
const [helpOpen, setHelpOpen] = useState(false);
// 三条命令各自独立的"已复制"反馈。用 string 而非 boolean 数组,
// 避免 stale state(重复点击不同按钮时旧 timer 把别人擦掉)。
const [copiedCommand, setCopiedCommand] = useState<string | null>(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<readonly [string, string]> = [
['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<readonly [string, string]> = [
[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 (
<div
style={{
marginTop: 10,
marginBottom: 8,
padding: '12px 14px',
borderRadius: 10,
background: 'rgba(217,119,6,0.08)',
border: '0.5px solid rgba(217,119,6,0.22)',
}}
>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#b45309', marginBottom: 4 }}>
{t('settings.recording.wayland.calloutTitle')}
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, marginBottom: 8 }}>
{t('settings.recording.wayland.calloutBody')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
{commandRows.map(([command, label]) => (
<div
key={command}
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 10px',
borderRadius: 8,
background: 'rgba(255,255,255,0.7)',
border: '0.5px solid rgba(0,0,0,0.08)',
flex: 1,
minWidth: 0,
}}
>
<code
style={{
flex: 1,
minWidth: 0,
fontSize: 12,
fontFamily: 'ui-monospace, SF Mono, Menlo, Consolas, monospace',
color: 'var(--ol-ink)',
userSelect: 'all',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{command}
</code>
<button
type="button"
onClick={() => onCopy(command)}
style={{
padding: '4px 10px',
fontSize: 11,
fontWeight: 500,
border: '0.5px solid rgba(0,0,0,0.12)',
borderRadius: 6,
background: '#fff',
color: 'var(--ol-ink-2)',
cursor: 'pointer',
fontFamily: 'inherit',
flexShrink: 0,
}}
>
{copiedCommand === command
? t('settings.recording.wayland.copyButtonCopied')
: t('settings.recording.wayland.copyButton')}
</button>
</div>
<span
style={{
fontSize: 11,
color: 'var(--ol-ink-3)',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{label}
</span>
</div>
))}
</div>
<button
type="button"
onClick={() => setHelpOpen(prev => !prev)}
style={{
padding: 0,
background: 'transparent',
border: 0,
color: '#b45309',
fontSize: 11.5,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{helpOpen ? '▾ ' : '▸ '}
{t('settings.recording.wayland.helpToggle')}
</button>
{helpOpen && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
{helpEntries.map(([title, body]) => (
<div key={title}>
<div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--ol-ink-2)', marginBottom: 2 }}>
{title}
</div>
<div
style={{
fontSize: 11,
color: 'var(--ol-ink-3)',
lineHeight: 1.55,
whiteSpace: 'pre-line',
}}
>
{body}
</div>
</div>
))}
</div>
)}
</div>
);
}
function HotkeyRecorder({
binding,
onCommit,
@@ -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,互相不显示对方平台的细节。 */}
@@ -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');
}
@@ -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,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 <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),
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<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()) {
savedIc_ = keyEvent.inputContext();
}
auto sym = static_cast<uint32_t>(keyEvent.key().sym());
auto states = static_cast<uint32_t>(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<uint32_t>(customDictationKey_.sym()),
static_cast<uint32_t>(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<uint32_t>(hk.sym()) &&
states == static_cast<uint32_t>(hk.states()))
return true;
}
return false;
}())) {
auto dsym = triggerRawSym_ != 0
? triggerRawSym_
: static_cast<uint32_t>(triggerKeyList_[0].sym());
auto dstates = triggerRawStates_ != 0
? triggerRawStates_
: static_cast<uint32_t>(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<InputContextEvent &>(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<std::string> &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<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();
}
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<uint32_t>(key.sym())
<< " states=" << static_cast<uint32_t>(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<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);