Skip to content
15 changes: 6 additions & 9 deletions openless-all/app/src-tauri/src/hotkey.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
//! 全局热键监听:发送按下 / 抬起 / 取消三类边沿事件。
//!
//! - macOS:原生 CGEventTap(core-foundation + core-graphics FFI),与 Swift
//! `OpenLessHotkey/HotkeyMonitor.swift` 同源。**不能用 `rdev`**:rdev 在每个
//! 事件回调里同步调 `TSMGetInputSourceProperty`,macOS 14+ 强制断言主线程,
//! 非主线程触发 `dispatch_assert_queue_fail` → SIGTRAP abort(已踩坑)。
//! `OpenLessHotkey/HotkeyMonitor.swift` 同源。
//! - Windows:原生 `WH_KEYBOARD_LL` low-level keyboard hook,保留 modifier-only
//! trigger(如右 Control / 右 Alt)的真实语义,不再把平台能力藏在 `rdev` 抽象里
//! trigger(如右 Control / 右 Alt)的真实语义。
//! - Linux:fcitx5 插件提供热键事件(DBus 信号 `DictationKeyEvent`)。
//!
//! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。
Expand Down Expand Up @@ -1189,19 +1187,18 @@ mod platform {
use super::{HotkeyAdapter, HotkeyEvent};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger};

/// Linux 统一使用 fcitx5 插件作为热键源(Wayland / X11 均可),
/// 不再启用 rdev 监听器。此处返回占位 adapter 让上层走 `Installed` 分支。
/// Linux 统一使用 fcitx5 插件作为热键源(Wayland / X11 均可)。
///
/// 实际的热键事件由 `linux_fcitx::start_dictation_signal_listener` 接收
/// fcitx5 插件的 DBus 信号并转发到 `Sender<HotkeyEvent>`。
pub fn start_adapter(
_binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
_tx: Sender<HotkeyEvent>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
log::info!(
"[hotkey] Linux — fcitx5 plugin handles hotkeys; rdev listener skipped"
"[hotkey] Linux — fcitx5 plugin handles hotkeys"
);
Ok(Box::new(PlaceholderAdapter { _tx: tx }))
Ok(Box::new(PlaceholderAdapter { _tx }))
}

/// Linux 占位 adapter:实现接口但不监听键盘。
Expand Down
101 changes: 27 additions & 74 deletions openless-all/app/src-tauri/src/linux_fcitx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,88 +404,41 @@ pub fn start_dictation_signal_listener(
.ok();
}

/// AppImage / 便携版:每次启动时从 bundled resources 复制插件到
/// `~/.local/lib/fcitx5/` 和 `~/.local/share/fcitx5/addon/`,始终覆盖已有文件。
/// 检查 fcitx5 插件是否已安装到系统路径。
///
/// 这确保 AppImage 版本与插件版本一致——插件新增 DBus 方法时旧 .so 不会缺少符号。
/// 系统路径(deb/rpm 安装)不会被覆盖
/// 安装后需要用户重启 fcitx5(`fcitx5 -r`)才能加载新插件
/// 所有 Linux 格式(deb/rpm/AppImage)的插件安装都在打包时完成
///(`scripts/inject-fcitx5-plugin.sh`),此处仅确认文件存在
/// 未安装时输出警告,不做任何文件 I/O
#[cfg(target_os = "linux")]
pub fn ensure_plugin_installed(app: &tauri::AppHandle) {
use tauri::Manager;

let resource_dir = match app.path().resource_dir() {
Ok(d) => d,
Err(e) => {
log::warn!("[fcitx-install] Cannot resolve resource dir: {e}");
return;
}
};

let so_src = resource_dir.join("linux-fcitx5-plugin").join("libopenless.so");
if !so_src.exists() {
log::info!(
"[fcitx-install] Bundled plugin not found at {:?} — not an AppImage or plugin not bundled",
so_src
pub fn ensure_plugin_installed(_app: &tauri::AppHandle) {
// fcitx5 在不同发行版的 lib 路径不同
let lib_dirs = [
"/usr/lib/x86_64-linux-gnu/fcitx5", // Debian multiarch
"/usr/lib64/fcitx5", // RPM 64-bit
"/usr/lib/fcitx5", // 通用回退
];
let system_conf = std::path::Path::new("/usr/share/fcitx5/addon/openless.conf");

if !system_conf.exists() {
log::warn!(
"[fcitx] fcitx5 addon config not installed at {:?}. \
The OpenLess package may be incomplete.",
system_conf
);
return;
}

let Ok(home) = std::env::var("HOME") else {
log::warn!("[fcitx-install] Cannot determine HOME dir");
return;
};
let home = std::path::PathBuf::from(home);
let found = lib_dirs.iter().any(|dir| {
std::path::Path::new(dir).join("libopenless.so").exists()
});

let lib_dir = home.join(".local").join("lib").join("fcitx5");
let addon_dir = home.join(".local").join("share").join("fcitx5").join("addon");

if let Err(e) = std::fs::create_dir_all(&lib_dir) {
log::warn!("[fcitx-install] Failed to create {:?}: {e}", lib_dir);
return;
}
if let Err(e) = std::fs::create_dir_all(&addon_dir) {
log::warn!("[fcitx-install] Failed to create {:?}: {e}", addon_dir);
return;
}

let so_dest = lib_dir.join("libopenless.so");
if let Err(e) = std::fs::copy(&so_src, &so_dest) {
log::warn!("[fcitx-install] Failed to copy plugin .so: {e}");
return;
}
log::info!("[fcitx-install] Installed plugin .so to {:?}", so_dest);

let config_content = format!(
concat!(
"[Addon]\n",
"Name=OpenLess\n",
"Name[zh_CN]=OpenLess 听写辅助\n",
"Comment=OpenLess dictation commit helper\n",
"Comment[zh_CN]=供 OpenLess 听写提交文字的 DBus 接口及快捷键监听\n",
"Category=Module\n",
"Type=SharedLibrary\n",
"Library={}\n",
"Version=1.0.0\n",
"OnDemand=False\n",
"Configurable=False\n",
"\n",
"[Addon/Dependencies]\n",
"0=core\n",
"1=dbus\n",
),
so_dest.display()
);

let conf_dest = addon_dir.join("openless.conf");
if let Err(e) = std::fs::write(&conf_dest, &config_content) {
log::warn!("[fcitx-install] Failed to write addon config: {e}");
return;
if !found {
log::warn!(
"[fcitx] fcitx5 plugin .so not found in any of {:?}. \
The OpenLess package may be incomplete.",
lib_dirs
);
}
log::info!("[fcitx-install] Installed addon config to {:?}", conf_dest);
log::info!(
"[fcitx-install] Done. Run `fcitx5 -r` to load the plugin, then restart OpenLess."
);
}

/// 同步主听写热键:自定义组合键走 SetCustomDictationTrigger,预设修饰键走 SetHotkeyRaw。
Expand Down
4 changes: 2 additions & 2 deletions openless-all/app/src-tauri/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! - macOS Accessibility:`AXIsProcessTrusted` 检查;
//! `AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: true})` 弹系统授权框。
//! - macOS Microphone:`AVAudioApplication.shared.recordPermission` + requestRecordPermission。
//! - Windows:rdev / cpal 不需要 Accessibility 等价权限;麦克风首次使用时 Win10+ 弹一次系统提示。
//! - Windows:cpal 不需要 Accessibility 等价权限;麦克风首次使用时 Win10+ 弹一次系统提示。

use serde::Serialize;

Expand Down Expand Up @@ -256,7 +256,7 @@ mod platform {
#[cfg(target_os = "windows")]
use winreg::RegKey;

/// Windows / Linux 不存在 macOS 那种 Accessibility 概念;rdev 直接监听键盘
/// Windows / Linux 不存在 macOS 那种 Accessibility 概念。
pub fn check_accessibility() -> PermissionStatus {
PermissionStatus::NotApplicable
}
Expand Down
3 changes: 1 addition & 2 deletions openless-all/app/src-tauri/src/unicode_keystroke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@
//! - `type_unicode_chunk`(CGEventPost)任意线程可调,对齐 `insertion.rs::macos::
//! simulate_paste` 现状。
//! - TIS(`switch_to_ascii` / `restore_input_source`)调度到主线程,规避 macOS 14+
//! 对 TSM/TIS 主线程的 `dispatch_assert_queue_fail` SIGTRAP(与
//! `feedback_rdev_macos_trap.md` 同款风险类别)。
//! 对 TSM/TIS 主线程的 `dispatch_assert_queue_fail` SIGTRAP。

#[allow(unused_imports)]
use tauri::{AppHandle, Runtime};
Expand Down
28 changes: 28 additions & 0 deletions openless-all/app/src-tauri/tauri.linux.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,33 @@
"acceptFirstMouse": true
}
]
},
"bundle": {
"linux": {
"deb": {
"depends": [
"libasound2",
"libdbus-1-3",
"libssl3",
"libjavascriptcoregtk-4.1-0",
"libsoup-3.0-0"
],
"recommends": [
"fcitx5"
]
},
"rpm": {
"depends": [
"alsa-lib",
"dbus-libs",
"openssl-libs",
"webkit2gtk4.1",
"libsoup3"
],
"recommends": [
"fcitx5"
]
}
}
}
}
69 changes: 69 additions & 0 deletions openless-all/scripts/inject-fcitx5-plugin.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Inject fcitx5 plugin files into Linux packages at system paths.
# Usage: ./inject-fcitx5-plugin.sh <package-path>
#
# Supports: .deb, .rpm
# AppImage is NOT supported — fcitx5 runs on the host and cannot load
# addons from inside the AppImage mount.
set -euo pipefail

PKG="$1"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_DIR="$SCRIPT_DIR/linux-fcitx5-plugin/build_release"
SO_SRC="$PLUGIN_DIR/libopenless.so"
CONF_SRC="$PLUGIN_DIR/openless.conf"

if [ ! -f "$SO_SRC" ] || [ ! -f "$CONF_SRC" ]; then
echo "[inject-fcitx5] Plugin not built — run build.sh first. Skipping."
exit 0
fi

TARGET_CONF="/usr/share/fcitx5/addon/openless.conf"

case "$PKG" in
*.deb)
# Detect multiarch triplet for the target architecture
MULTIARCH=$(dpkg-architecture -qDEB_HOST_MULTIARCH 2>/dev/null || echo "")
if [ -n "$MULTIARCH" ]; then
TARGET_LIB="/usr/lib/$MULTIARCH/fcitx5/libopenless.so"
else
TARGET_LIB="/usr/lib/fcitx5/libopenless.so"
fi
echo "[inject-fcitx5] Injecting into deb ($MULTIARCH): $PKG"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
dpkg-deb -R "$PKG" "$TMPDIR"
mkdir -p "$TMPDIR/$(dirname "$TARGET_LIB")"
mkdir -p "$TMPDIR/$(dirname "$TARGET_CONF")"
cp "$SO_SRC" "$TMPDIR/$TARGET_LIB"
cp "$CONF_SRC" "$TMPDIR/$TARGET_CONF"
dpkg-deb -b "$TMPDIR" "$PKG"
echo "[inject-fcitx5] Done — deb updated"
;;
*.rpm)
TARGET_LIB="/usr/lib64/fcitx5/libopenless.so"
echo "[inject-fcitx5] Injecting into rpm: $PKG"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
rpm2cpio "$PKG" | cpio -idm 2>/dev/null || true
mkdir -p "$(dirname ".$TARGET_LIB")"
mkdir -p "$(dirname ".$TARGET_CONF")"
cp "$SO_SRC" ".$TARGET_LIB"
cp "$CONF_SRC" ".$TARGET_CONF"
if command -v rpmrebuild &>/dev/null; then
rpmrebuild -np -d "$TMPDIR" "$PKG" 2>/dev/null || {
echo "[inject-fcitx5] ERROR: rpmrebuild failed" >&2
exit 1
}
else
echo "[inject-fcitx5] ERROR: rpmrebuild not found — required for RPM injection. Install it with: sudo dnf install rpmrebuild" >&2
exit 1
fi
echo "[inject-fcitx5] Done — rpm updated"
;;
*)
echo "[inject-fcitx5] Unknown package format: $PKG (supported: .deb, .rpm)"
exit 1
;;
esac
Loading