Merge pull request #451 from aeoform/beta
feat(linux): fcitx5 plugin for Wayland input + deb packaging
This commit is contained in:
28 files changed
+1085
-951
No files matched your search
@@ -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 != ''
|
||||
|
||||
Generated
+11
-112
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
Reference in new issue
Block a user