feat(linux): 选区读取集成进 fcitx5 插件(clipboard addon),删除外部工具依赖 (#961)
* feat(linux): 选区读取集成进 fcitx5 插件(clipboard addon),删除外部工具依赖 - openless 插件新增 GetSelectionText DBus 方法:直读 fcitx clipboard addon 的 PRIMARY 选区缓存(X11 XFIXES / Wayland data-control 统一维护),与 fcitx 剪贴板模块来源一致,不触碰用户剪贴板 - CMake 增加 Fcitx5::Module::Clipboard 依赖 - Rust 侧 selection 改走插件 GetSelectionText,删除 wl-paste/xclip/xsel 外部工具路径及 linux_selection_tools_missing 提醒机制(前端 selection_warning 状态机、i18n、测试一并删除) - acceptQaSessionEvent 的 idle 分支不再依赖 selection_warning 字段, 一律视为新会话 token(事件保序,语义等价) - 打字提问路径(QA 面板 / less-computer)不再 emit Polishing capsule: 回答在面板内可见,避免 Linux 输入法 auxDown 闪「✨ 润色中...」 (cherry picked from commit 384f8876a490cb3e8e992af05cdad33f2af6a8ec) * fix(linux): bundle and sync fcitx5 selection plugin (cherry picked from commit 7e8bf825c08f5e935c7a25746c121136d92b7926) * fix(linux): use DBus selection reader during validation (cherry picked from commit d13403343b08fe1b4070e0f0d2f219a0a69d363e) * refactor(linux): 收尾 PR 961 审查发现的小毛病 - capsule 反馈参数从裸 bool 改为具名枚举 CapsuleFeedback(Show/Hide): 语音/听写路径 Show、打字路径 Hide,调用点自文档化,避免将来误传 - AppImage 插件子路径提取为 APPIMAGE_PLUGIN_SUBDIR 常量并注明与 CI bundle.resources 的契约,避免三处硬编码静默失配 - selection.rs 移除文件级 Linux 大赦(lib.rs 的 crate 级兜底仍在), Duration import 按平台限定,消除 Linux unused 隐患 --------- Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
1 parent
789c82051e
commit
33467a36ea
21 files changed
+587
-238
No files matched your search
@@ -101,6 +101,7 @@ jobs:
|
||||
libxdo-dev \
|
||||
patchelf \
|
||||
rpm \
|
||||
squashfs-tools \
|
||||
wget
|
||||
|
||||
- name: Install npm deps
|
||||
@@ -439,6 +440,8 @@ jobs:
|
||||
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/"
|
||||
test -s "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/libopenless.so"
|
||||
test -s "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/openless.conf"
|
||||
|
||||
- name: Build (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -453,10 +456,15 @@ jobs:
|
||||
# ensure_plugin_installed() 自动安装到 ~/.local/ 下。
|
||||
# 插件 .so + .conf 由上一步 Build fcitx5 plugin 生成并复制到
|
||||
# src-tauri/linux-fcitx5-plugin/ 下。
|
||||
test -s src-tauri/linux-fcitx5-plugin/libopenless.so
|
||||
test -s src-tauri/linux-fcitx5-plugin/openless.conf
|
||||
cat > /tmp/tauri-linux-config.json << CONFIG_EOF
|
||||
{
|
||||
"bundle": {
|
||||
"resources": ["linux-fcitx5-plugin/libopenless.so"],
|
||||
"resources": [
|
||||
"linux-fcitx5-plugin/libopenless.so",
|
||||
"linux-fcitx5-plugin/openless.conf"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["fcitx5", "fcitx5-module-dbus", "libdbus-1-3"],
|
||||
@@ -484,6 +492,21 @@ jobs:
|
||||
fi
|
||||
npm run tauri -- build --bundles deb,rpm,appimage --config "$CONFIG_FILE"
|
||||
|
||||
APPIMAGE_PATH=$(find src-tauri/target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit)
|
||||
if [ -z "$APPIMAGE_PATH" ]; then
|
||||
echo "::error::AppImage bundle was not produced"
|
||||
exit 1
|
||||
fi
|
||||
APPIMAGE_CONTENTS=$(unsquashfs -l "$APPIMAGE_PATH")
|
||||
if ! grep -Eq '(^|/)usr/lib/OpenLess/linux-fcitx5-plugin/libopenless\.so$' <<< "$APPIMAGE_CONTENTS"; then
|
||||
echo "::error::AppImage is missing usr/lib/OpenLess/linux-fcitx5-plugin/libopenless.so"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq '(^|/)usr/lib/OpenLess/linux-fcitx5-plugin/openless\.conf$' <<< "$APPIMAGE_CONTENTS"; then
|
||||
echo "::error::AppImage is missing usr/lib/OpenLess/linux-fcitx5-plugin/openless.conf"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Disambiguate macOS updater bundle filename
|
||||
if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != ''
|
||||
shell: bash
|
||||
|
||||
@@ -121,6 +121,17 @@ enum CapsuleShowStrategy {
|
||||
FallbackShow,
|
||||
}
|
||||
|
||||
/// 是否在回答期间显示「处理中 / 润色中」胶囊反馈。
|
||||
///
|
||||
/// 语音 / 听写路径显示(用户熟悉的小录音条状态机;Linux 下映射到 fcitx5
|
||||
/// auxDown,显示在候选词栏下方);打字提问路径不显示(回答在 QA 面板内
|
||||
/// 流式可见,不应在输入法候选栏闪「✨ 润色中...」)。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CapsuleFeedback {
|
||||
Show,
|
||||
Hide,
|
||||
}
|
||||
|
||||
fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy {
|
||||
// ⚠️ 如果改下面的 cfg 列表,**必须**同步更新单元测试
|
||||
// `capsule_show_strategy_matches_platform_activation_contract` 的两组 cfg —
|
||||
@@ -2174,7 +2185,14 @@ impl Coordinator {
|
||||
// callback (SIGABRT). Tauri's runtime handle is safe from either thread.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let session_id = crate::coordinator_state::new_session_id();
|
||||
if let Err(e) = dictation::run_voice_agent_transcript(&inner, session_id, text, 0).await
|
||||
if let Err(e) = dictation::run_voice_agent_transcript(
|
||||
&inner,
|
||||
session_id,
|
||||
text,
|
||||
0,
|
||||
CapsuleFeedback::Hide,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("[less-computer] text submit run failed: {e}");
|
||||
}
|
||||
|
||||
@@ -1333,20 +1333,27 @@ pub(super) async fn run_voice_agent_transcript(
|
||||
_session_id: SessionId,
|
||||
transcript: String,
|
||||
elapsed: u64,
|
||||
// 语音路径 Show:显示胶囊「处理中」反馈(既有行为);打字路径
|
||||
// (less_computer_submit_text)Hide —— 对话在浮窗里已可见,不应在输入法
|
||||
// auxDown 闪「润色中」,用户已确认。
|
||||
capsule_feedback: super::CapsuleFeedback,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"[coord] Cloud Agent 语音:指令 {} 字",
|
||||
transcript.chars().count()
|
||||
);
|
||||
// 胶囊保留「处理中」反馈(用户熟悉的小录音条状态机);聊天浮窗承载完整对话。
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Polishing,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("Agent 处理中…".to_string()),
|
||||
None,
|
||||
);
|
||||
// Linux 下会映射到 fcitx5 auxDown("✨ 润色中...")显示在候选词栏下方。
|
||||
if capsule_feedback == super::CapsuleFeedback::Show {
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Polishing,
|
||||
0.0,
|
||||
elapsed,
|
||||
Some("Agent 处理中…".to_string()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// 聊天浮窗:显示窗口 + 落用户气泡(语音指令转写)。macOS only(helper 内部 gating)。
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
@@ -4083,8 +4090,14 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
|
||||
// Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。
|
||||
if inner.state.lock().voice_agent {
|
||||
return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed)
|
||||
.await;
|
||||
return run_voice_agent_transcript(
|
||||
inner,
|
||||
current_session_id,
|
||||
raw.text.clone(),
|
||||
elapsed,
|
||||
super::CapsuleFeedback::Show,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
|
||||
@@ -4675,7 +4688,14 @@ async fn finish_dictation_multimodal(
|
||||
|
||||
// Less Computer:转写文本交给 CLI agent,不走插入/历史(agent 流程自己收尾)。
|
||||
if voice_agent {
|
||||
return run_voice_agent_transcript(inner, current_session_id, output, elapsed).await;
|
||||
return run_voice_agent_transcript(
|
||||
inner,
|
||||
current_session_id,
|
||||
output,
|
||||
elapsed,
|
||||
super::CapsuleFeedback::Show,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let correction_rules = match inner.correction_rules.list() {
|
||||
|
||||
@@ -117,7 +117,6 @@ pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
|
||||
serde_json::json!({
|
||||
"kind": "idle",
|
||||
"session_id": session_id,
|
||||
"selection_warning": null,
|
||||
"messages": Vec::<crate::types::QaChatMessage>::new(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -144,7 +144,6 @@ pub(super) async fn finalize_dictation_as_qa_question(inner: &Arc<Inner>) -> Res
|
||||
log::info!("[coord] QA finalize from overlay: capturing selection before opening panel");
|
||||
let capture = crate::selection::capture_selection_with_status();
|
||||
let selection = capture.selection;
|
||||
let selection_warning = capture.warning_code;
|
||||
let selection_preview_text = selection.as_ref().map(|s| s.text.clone());
|
||||
|
||||
log::info!("[coord] QA finalize from overlay: opening panel and waiting for ASR result");
|
||||
@@ -173,7 +172,6 @@ pub(super) async fn finalize_dictation_as_qa_question(inner: &Arc<Inner>) -> Res
|
||||
"kind": "loading",
|
||||
"session_id": session_id,
|
||||
"selection_preview": selection_preview_text,
|
||||
"selection_warning": selection_warning,
|
||||
"messages": state.messages.clone(),
|
||||
}),
|
||||
);
|
||||
@@ -208,6 +206,7 @@ pub(super) async fn finalize_dictation_as_qa_question(inner: &Arc<Inner>) -> Res
|
||||
raw.duration_ms,
|
||||
session_id,
|
||||
None,
|
||||
super::CapsuleFeedback::Show,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -245,7 +244,6 @@ pub(super) async fn submit_qa_text_question(
|
||||
.selection
|
||||
.as_ref()
|
||||
.map(|selection| selection.text.clone());
|
||||
let selection_warning = capture.warning_code;
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
if !qa_turn_can_continue(&state, session_id) {
|
||||
@@ -263,14 +261,21 @@ pub(super) async fn submit_qa_text_question(
|
||||
"kind": "thinking",
|
||||
"session_id": session_id,
|
||||
"selection_preview": selection_preview_text,
|
||||
"selection_warning": selection_warning,
|
||||
"messages": state.messages.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
answer_qa_question_text(inner, question, 0, session_id, None).await
|
||||
answer_qa_question_text(
|
||||
inner,
|
||||
question,
|
||||
0,
|
||||
session_id,
|
||||
None,
|
||||
super::CapsuleFeedback::Hide,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn take_current_dictation_transcript_for_qa(
|
||||
@@ -310,8 +315,15 @@ pub(super) async fn take_current_dictation_transcript_for_qa(
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
answer_qa_question_text(inner, String::new(), duration_ms, qa_session_id, Some(wav))
|
||||
.await?;
|
||||
answer_qa_question_text(
|
||||
inner,
|
||||
String::new(),
|
||||
duration_ms,
|
||||
qa_session_id,
|
||||
Some(wav),
|
||||
super::CapsuleFeedback::Show,
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -655,6 +667,10 @@ pub(super) async fn answer_qa_question_text(
|
||||
duration_ms: u64,
|
||||
session_id: SessionId,
|
||||
audio_wav: Option<Vec<u8>>,
|
||||
// QA 面板打字提问传 Hide:回答在面板内流式可见,不应在输入法 auxDown
|
||||
// 闪「✨ 润色中...」(Linux 下 Polishing 会映射到候选词栏)。
|
||||
// 语音/听写路径保持 Show(用户熟悉的小录音条反馈)。
|
||||
capsule_feedback: super::CapsuleFeedback,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let state = inner.qa_state.lock();
|
||||
@@ -706,7 +722,9 @@ pub(super) async fn answer_qa_question_text(
|
||||
}
|
||||
}
|
||||
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None);
|
||||
if capsule_feedback == super::CapsuleFeedback::Show {
|
||||
emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None);
|
||||
}
|
||||
|
||||
let prefs = inner.prefs.get();
|
||||
let working_languages = prefs.working_languages.clone();
|
||||
@@ -880,7 +898,6 @@ pub(super) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
// 每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。
|
||||
let capture = capture_qa_turn_selection(inner);
|
||||
let selection = capture.selection;
|
||||
let selection_warning = capture.warning_code;
|
||||
let selection_preview_text = selection.as_ref().map(|s| s.text.clone());
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
@@ -897,7 +914,6 @@ pub(super) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
"kind": "recording",
|
||||
"session_id": session_id,
|
||||
"selection_preview": selection_preview_text,
|
||||
"selection_warning": selection_warning,
|
||||
"messages": state.messages.clone(),
|
||||
}),
|
||||
);
|
||||
@@ -1115,8 +1131,15 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
};
|
||||
let duration_ms = pcm_consumer.duration_ms();
|
||||
let wav = pcm_bytes_to_wav(&pcm_consumer.pcm());
|
||||
return answer_qa_question_text(inner, String::new(), duration_ms, session_id, Some(wav))
|
||||
.await;
|
||||
return answer_qa_question_text(
|
||||
inner,
|
||||
String::new(),
|
||||
duration_ms,
|
||||
session_id,
|
||||
Some(wav),
|
||||
super::CapsuleFeedback::Show,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let asr = match take_qa_asr_for_session(inner, session_id) {
|
||||
@@ -1538,7 +1561,15 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
answer_qa_question_text(inner, question, raw.duration_ms, session_id, None).await
|
||||
answer_qa_question_text(
|
||||
inner,
|
||||
question,
|
||||
raw.duration_ms,
|
||||
session_id,
|
||||
None,
|
||||
super::CapsuleFeedback::Show,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户
|
||||
|
||||
@@ -148,16 +148,13 @@ pub(super) async fn run_selection_polish(inner: &Arc<Inner>) -> Result<(), Strin
|
||||
let insertion_target = crate::selection::capture_selection_insertion_target();
|
||||
let capture = crate::selection::capture_selection_with_status();
|
||||
if selection_polish_plan(capture.selection.as_ref()) == SelectionPolishPlan::NoSelection {
|
||||
let code = capture
|
||||
.warning_code
|
||||
.unwrap_or("selectionPolishNoSelection")
|
||||
.to_string();
|
||||
let code = "selectionPolishNoSelection";
|
||||
finish_selection_polish_capsule(
|
||||
inner,
|
||||
CapsuleState::Cancelled,
|
||||
selection_polish_feedback_message(&code),
|
||||
selection_polish_feedback_message(code),
|
||||
);
|
||||
return Err(code);
|
||||
return Err(code.to_string());
|
||||
}
|
||||
let selection = capture.selection.expect("selection plan checked above");
|
||||
if !crate::selection::selection_insertion_target_is_captured(&insertion_target) {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! 封装对 `org.fcitx.Fcitx.OpenLess1` 接口的调用,
|
||||
//! 提供文字提交(替代 enigo XTest)和热键设置功能。
|
||||
//!
|
||||
//! 所有函数会静默返回 `None` 如果 fcitx5 / 插件不可用,
|
||||
//! 调用方应当降级到原有方案(clipboard / enigo)。
|
||||
//! 插件不可用时,各调用方按自身能力记录 warning 并继续运行;Linux 选区读取
|
||||
//! 只通过插件的 `GetSelectionText()` DBus 方法,失败视为无选区,不回退外部命令。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -295,6 +295,23 @@ pub fn available() -> bool {
|
||||
conn.send_with_reply_and_block(msg, TIMEOUT).is_ok()
|
||||
}
|
||||
|
||||
/// 通过 fcitx5 插件获取当前 PRIMARY 选区文本。
|
||||
///
|
||||
/// 插件直接读取 fcitx5 clipboard addon 维护的 PRIMARY 缓存,不触碰用户剪贴板。
|
||||
/// 返回空字符串表示无选区;插件不可用或 DBus 调用失败则返回错误。
|
||||
pub fn get_selection_text() -> Result<String, 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, "GetSelectionText")
|
||||
.map_err(|e| format!("build msg: {e}"))?;
|
||||
let reply = conn
|
||||
.send_with_reply_and_block(msg, TIMEOUT)
|
||||
.map_err(|e| format!("GetSelectionText: {e}"))?;
|
||||
reply
|
||||
.read1::<String>()
|
||||
.map_err(|e| format!("GetSelectionText reply: {e}"))
|
||||
}
|
||||
|
||||
/// 启动 fcitx5 DictationKeyEvent 信号监听线程。
|
||||
///
|
||||
/// 当 fcitx5 OpenLess 插件检测到配置的听写热键被按下或松开时,
|
||||
@@ -479,13 +496,17 @@ pub fn start_dictation_signal_listener(
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// 检查 fcitx5 插件是否已安装到系统路径。
|
||||
/// 确保 Linux fcitx5 插件可用。
|
||||
///
|
||||
/// 所有 Linux 格式(deb/rpm/AppImage)的插件安装都在打包时完成
|
||||
///(`scripts/inject-fcitx5-plugin.sh`),此处仅确认文件存在。
|
||||
/// 未安装时输出警告,不做任何文件 I/O。
|
||||
/// AppImage 从 bundled resources 同步到用户级 XDG 路径;deb/rpm 保持系统路径检查,
|
||||
/// 不写入用户目录。所有同步失败只记录 warning,不阻塞应用启动。
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn ensure_plugin_installed(_app: &tauri::AppHandle) {
|
||||
pub fn ensure_plugin_installed(app: &tauri::AppHandle) {
|
||||
if is_appimage_runtime() {
|
||||
ensure_appimage_plugin_installed(app);
|
||||
return;
|
||||
}
|
||||
|
||||
// fcitx5 在不同发行版的 lib 路径不同,同时支持用户 XDG 安装
|
||||
let lib_dirs = [
|
||||
"/usr/lib/x86_64-linux-gnu/fcitx5", // Debian multiarch
|
||||
@@ -541,6 +562,211 @@ pub fn ensure_plugin_installed(_app: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn is_appimage_runtime() -> bool {
|
||||
["APPDIR", "APPIMAGE"].iter().any(|name| {
|
||||
std::env::var_os(name)
|
||||
.map(|value| !value.is_empty())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// AppImage 里 bundled resources 下的插件子路径(相对 resource_dir)。
|
||||
///
|
||||
/// ⚠️ 与 `.github/workflows/release-tauri.yml` 的 `bundle.resources` 数组
|
||||
/// 和 AppImage 内容校验(`unsquashfs -l` 的 grep)**必须保持一致**——
|
||||
/// 改这里要同步改 CI,反之亦然。CI 校验能在打包时兜底抓失配,但那是
|
||||
/// 发布后才发现;此处常量让 Rust 侧所有引用(含测试)共享同一来源。
|
||||
#[cfg(target_os = "linux")]
|
||||
const APPIMAGE_PLUGIN_SUBDIR: &str = "linux-fcitx5-plugin";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn appimage_resource_paths(
|
||||
resource_dir: &std::path::Path,
|
||||
) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
(
|
||||
resource_dir.join(APPIMAGE_PLUGIN_SUBDIR).join("libopenless.so"),
|
||||
resource_dir.join(APPIMAGE_PLUGIN_SUBDIR).join("openless.conf"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_appimage_plugin_installed(app: &tauri::AppHandle) {
|
||||
use tauri::Manager;
|
||||
|
||||
let resource_dir = match app.path().resource_dir() {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
log::warn!("[fcitx] AppImage resource directory unavailable: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (source_so, source_conf) = appimage_resource_paths(&resource_dir);
|
||||
let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) else {
|
||||
log::warn!("[fcitx] HOME is unavailable; keeping any existing user plugin");
|
||||
return;
|
||||
};
|
||||
let home = std::path::PathBuf::from(home);
|
||||
let target_so = home.join(".local/lib/fcitx5/libopenless.so");
|
||||
let target_conf = home.join(".local/share/fcitx5/addon/openless.conf");
|
||||
|
||||
match sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf) {
|
||||
Ok(false) => return,
|
||||
Ok(true) => log::info!("[fcitx] Updated AppImage fcitx5 plugin in ~/.local"),
|
||||
Err(error) => {
|
||||
log::warn!("[fcitx] AppImage fcitx5 plugin sync failed: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
reload_fcitx5_if_running();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn sync_plugin_pair(
|
||||
source_so: &std::path::Path,
|
||||
source_conf: &std::path::Path,
|
||||
target_so: &std::path::Path,
|
||||
target_conf: &std::path::Path,
|
||||
) -> Result<bool, String> {
|
||||
let so = std::fs::read(source_so)
|
||||
.map_err(|error| format!("read {}: {error}", source_so.display()))?;
|
||||
let conf = std::fs::read(source_conf)
|
||||
.map_err(|error| format!("read {}: {error}", source_conf.display()))?;
|
||||
if so.is_empty() {
|
||||
return Err(format!("bundled resource {} is empty", source_so.display()));
|
||||
}
|
||||
if conf.is_empty() {
|
||||
return Err(format!(
|
||||
"bundled resource {} is empty",
|
||||
source_conf.display()
|
||||
));
|
||||
}
|
||||
|
||||
let so_changed = !target_matches(target_so, &so)?;
|
||||
let conf_changed = !target_matches(target_conf, &conf)?;
|
||||
if !so_changed && !conf_changed {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
for target in [target_so, target_conf] {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| format!("target has no parent: {}", target.display()))?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("create {}: {error}", parent.display()))?;
|
||||
}
|
||||
|
||||
let so_temp = if so_changed {
|
||||
Some(stage_atomic_write(&so, target_so)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let conf_temp = if conf_changed {
|
||||
match stage_atomic_write(&conf, target_conf) {
|
||||
Ok(path) => Some(path),
|
||||
Err(error) => {
|
||||
if let Some(path) = so_temp.as_ref() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = (|| {
|
||||
if let Some(temp) = so_temp.as_ref() {
|
||||
commit_atomic_write(temp, target_so)?;
|
||||
}
|
||||
if let Some(temp) = conf_temp.as_ref() {
|
||||
commit_atomic_write(temp, target_conf)?;
|
||||
}
|
||||
Ok(true)
|
||||
})();
|
||||
if result.is_err() {
|
||||
for temp in [so_temp.as_ref(), conf_temp.as_ref()].into_iter().flatten() {
|
||||
let _ = std::fs::remove_file(temp);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn target_matches(target: &std::path::Path, source: &[u8]) -> Result<bool, String> {
|
||||
let target_bytes = match std::fs::read(target) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => return Err(format!("read {}: {error}", target.display())),
|
||||
};
|
||||
Ok(sha256(&target_bytes) == sha256(source))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn sha256(bytes: &[u8]) -> [u8; 32] {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(bytes).into()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn stage_atomic_write(
|
||||
bytes: &[u8],
|
||||
target: &std::path::Path,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
use std::io::Write;
|
||||
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| format!("target has no parent: {}", target.display()))?;
|
||||
let name = target
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("target has no filename: {}", target.display()))?
|
||||
.to_string_lossy();
|
||||
let temp = parent.join(format!(".{name}.tmp-{}", uuid::Uuid::new_v4()));
|
||||
let result = (|| {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temp)
|
||||
.map_err(|error| format!("create {}: {error}", temp.display()))?;
|
||||
file.write_all(bytes)
|
||||
.map_err(|error| format!("write {}: {error}", temp.display()))?;
|
||||
file.sync_all()
|
||||
.map_err(|error| format!("sync {}: {error}", temp.display()))?;
|
||||
Ok(temp.clone())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn commit_atomic_write(temp: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
|
||||
std::fs::rename(temp, target)
|
||||
.map_err(|error| format!("rename {} to {}: {error}", temp.display(), target.display()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reload_fcitx5_if_running() {
|
||||
let conn = match dbus::blocking::SyncConnection::new_session() {
|
||||
Ok(conn) => conn,
|
||||
Err(error) => {
|
||||
log::warn!("[fcitx] Cannot check fcitx5 before reload: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !fcitx5_name_has_owner(&conn) {
|
||||
return;
|
||||
}
|
||||
match std::process::Command::new("fcitx5").arg("-r").status() {
|
||||
Ok(status) if status.success() => log::info!("[fcitx] Reloaded fcitx5 after plugin update"),
|
||||
Ok(status) => log::warn!("[fcitx] fcitx5 -r failed with status {status}"),
|
||||
Err(error) => log::warn!("[fcitx] Could not run fcitx5 -r: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步主听写热键:自定义组合键走 SetCustomDictationTrigger,预设修饰键走 SetHotkeyRaw。
|
||||
fn resync_main_binding(binding: &crate::types::HotkeyBinding, custom_trigger_key: Option<&str>) {
|
||||
if let Some(key_string) = custom_trigger_key {
|
||||
@@ -573,3 +799,155 @@ fn fcitx5_name_has_owner(conn: &dbus::blocking::SyncConnection) -> bool {
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new() -> Self {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("openless-fcitx-test-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_resources_use_the_expected_subdirectory() {
|
||||
// 这些字面量是**契约的一部分**(与 CI 的 bundle.resources 数组一致),
|
||||
// 故意不引用 APPIMAGE_PLUGIN_SUBDIR,避免测试与实现相互印证而漏掉契约漂移。
|
||||
let (so, conf) = appimage_resource_paths(Path::new("/opt/openless/resources"));
|
||||
assert_eq!(
|
||||
so,
|
||||
Path::new("/opt/openless/resources/linux-fcitx5-plugin/libopenless.so")
|
||||
);
|
||||
assert_eq!(
|
||||
conf,
|
||||
Path::new("/opt/openless/resources/linux-fcitx5-plugin/openless.conf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_targets_are_created_and_parent_directories_are_made() {
|
||||
let dir = TestDir::new();
|
||||
let source_so = dir.path().join("source/libopenless.so");
|
||||
let source_conf = dir.path().join("source/openless.conf");
|
||||
let target_so = dir.path().join("home/.local/lib/fcitx5/libopenless.so");
|
||||
let target_conf = dir
|
||||
.path()
|
||||
.join("home/.local/share/fcitx5/addon/openless.conf");
|
||||
fs::create_dir_all(source_so.parent().unwrap()).unwrap();
|
||||
fs::write(&source_so, b"so-v1").unwrap();
|
||||
fs::write(&source_conf, b"conf-v1").unwrap();
|
||||
|
||||
assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap());
|
||||
assert_eq!(fs::read(&target_so).unwrap(), b"so-v1");
|
||||
assert_eq!(fs::read(&target_conf).unwrap(), b"conf-v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_targets_are_not_rewritten() {
|
||||
let dir = TestDir::new();
|
||||
let source_so = dir.path().join("source.so");
|
||||
let source_conf = dir.path().join("source.conf");
|
||||
let target_so = dir.path().join("target.so");
|
||||
let target_conf = dir.path().join("target.conf");
|
||||
fs::write(&source_so, b"same-so").unwrap();
|
||||
fs::write(&source_conf, b"same-conf").unwrap();
|
||||
fs::write(&target_so, b"same-so").unwrap();
|
||||
fs::write(&target_conf, b"same-conf").unwrap();
|
||||
|
||||
assert!(!sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap());
|
||||
assert_eq!(fs::read(&target_so).unwrap(), b"same-so");
|
||||
assert_eq!(fs::read(&target_conf).unwrap(), b"same-conf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_targets_are_updated_as_a_pair() {
|
||||
let dir = TestDir::new();
|
||||
let source_so = dir.path().join("source.so");
|
||||
let source_conf = dir.path().join("source.conf");
|
||||
let target_so = dir.path().join("target.so");
|
||||
let target_conf = dir.path().join("target.conf");
|
||||
fs::write(&source_so, b"new-so").unwrap();
|
||||
fs::write(&source_conf, b"new-conf").unwrap();
|
||||
fs::write(&target_so, b"old-so").unwrap();
|
||||
fs::write(&target_conf, b"old-conf").unwrap();
|
||||
|
||||
assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap());
|
||||
assert_eq!(fs::read(&target_so).unwrap(), b"new-so");
|
||||
assert_eq!(fs::read(&target_conf).unwrap(), b"new-conf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_resource_keeps_existing_targets() {
|
||||
let dir = TestDir::new();
|
||||
let source_so = dir.path().join("missing.so");
|
||||
let source_conf = dir.path().join("source.conf");
|
||||
let target_so = dir.path().join("target.so");
|
||||
let target_conf = dir.path().join("target.conf");
|
||||
fs::write(&source_conf, b"new-conf").unwrap();
|
||||
fs::write(&target_so, b"old-so").unwrap();
|
||||
fs::write(&target_conf, b"old-conf").unwrap();
|
||||
|
||||
assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).is_err());
|
||||
assert_eq!(fs::read(&target_so).unwrap(), b"old-so");
|
||||
assert_eq!(fs::read(&target_conf).unwrap(), b"old-conf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_target_directory_keeps_existing_targets() {
|
||||
let dir = TestDir::new();
|
||||
let source_so = dir.path().join("source.so");
|
||||
let source_conf = dir.path().join("source.conf");
|
||||
let target_so = dir.path().join("targets/libopenless.so");
|
||||
let target_conf = dir.path().join("blocked/openless.conf");
|
||||
fs::write(&source_so, b"new-so").unwrap();
|
||||
fs::write(&source_conf, b"new-conf").unwrap();
|
||||
fs::create_dir_all(target_so.parent().unwrap()).unwrap();
|
||||
fs::write(&target_so, b"old-so").unwrap();
|
||||
fs::write(dir.path().join("blocked"), b"not a directory").unwrap();
|
||||
|
||||
assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).is_err());
|
||||
assert_eq!(fs::read(&target_so).unwrap(), b"old-so");
|
||||
assert!(target_conf.is_dir() || !target_conf.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_update_leaves_no_temporary_files() {
|
||||
let dir = TestDir::new();
|
||||
let source_so = dir.path().join("source.so");
|
||||
let source_conf = dir.path().join("source.conf");
|
||||
let target_so = dir.path().join("target.so");
|
||||
let target_conf = dir.path().join("target.conf");
|
||||
fs::write(&source_so, b"so").unwrap();
|
||||
fs::write(&source_conf, b"conf").unwrap();
|
||||
|
||||
sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap();
|
||||
let leftovers = fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||
.filter(|name| name.contains(".tmp-"))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"temporary files left behind: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,11 @@ pub struct SelectionContext {
|
||||
|
||||
pub struct SelectionCaptureOutcome {
|
||||
pub selection: Option<SelectionContext>,
|
||||
pub warning_code: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
|
||||
SelectionCaptureOutcome {
|
||||
selection: capture_selection(),
|
||||
warning_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
#![cfg_attr(
|
||||
target_os = "linux",
|
||||
allow(dead_code, unused_imports, unused_variables)
|
||||
)]
|
||||
//! 跨平台「划词捕获」工具:在用户触发 QA 快捷键时尝试拿到当前前台 app 的选区文本。
|
||||
//!
|
||||
//! 三级 fallback:
|
||||
//! 平台路径:
|
||||
//! 1. **macOS** AX:`AXUIElementCopyAttributeValue(focused, kAXSelectedTextAttribute)`
|
||||
//! 走辅助功能 API 直读焦点元素的选区,**不**触碰剪贴板。
|
||||
//! 2. **macOS / Windows** Cmd+C / Ctrl+C:snapshot 用户原剪贴板 → 模拟复制 → 80ms
|
||||
//! 后读出新内容 → 还原原剪贴板。
|
||||
//! 3. **Linux**:返回 `None`(AX 模式不统一,留作 best-effort 后续)。
|
||||
//! 3. **Linux**:通过 fcitx5 插件的 `GetSelectionText()` DBus 方法读取 PRIMARY
|
||||
//! 选区缓存,不触碰用户剪贴板;插件不可用、调用失败或返回空文本均视为无选区。
|
||||
//!
|
||||
//! 截断策略:超过 4000 字符的选区只保留首 2000 + 尾 2000 + `[…truncated…]` 标记,
|
||||
//! 避免给 LLM 灌过长 context。
|
||||
//!
|
||||
//! 模块依赖:仅 `arboard`(跨平台剪贴板)+ libc + 平台 native 框架;不依赖其它
|
||||
//! Rust 模块(与 CLAUDE.md 对齐)。
|
||||
//! 模块依赖:`arboard`(跨平台剪贴板)+ libc + 平台 native 框架,Linux 另依赖
|
||||
//! `linux_fcitx` 的 DBus 客户端。
|
||||
|
||||
// 仅 macOS / Windows 的模拟复制路径用 sleep;Linux 走 fcitx5 DBus 直读,无 sleep。
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use std::time::Duration;
|
||||
|
||||
const SELECTION_MAX_CHARS: usize = 4000;
|
||||
@@ -97,12 +96,8 @@ impl SelectionInsertionTargetValidation {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
const LINUX_SELECTION_TOOLS_MISSING_WARNING: &str = "linux_selection_tools_missing";
|
||||
|
||||
pub struct SelectionCaptureOutcome {
|
||||
pub selection: Option<SelectionContext>,
|
||||
pub warning_code: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Snapshot the insertion target before starting an asynchronous Selection
|
||||
@@ -142,9 +137,7 @@ pub(crate) fn capture_selection_insertion_target() -> SelectionInsertionTarget {
|
||||
///
|
||||
/// 非 Windows/macOS(Linux / mobile)尚未实现等效的前台校验:Linux 依赖
|
||||
/// PRIMARY selection 重读做轻量校验,移动端不提供选区润色。
|
||||
pub(crate) fn selection_insertion_target_is_captured(
|
||||
target: &SelectionInsertionTarget,
|
||||
) -> bool {
|
||||
pub(crate) fn selection_insertion_target_is_captured(target: &SelectionInsertionTarget) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
target.windows.is_some()
|
||||
@@ -224,7 +217,7 @@ pub(crate) fn validate_selection_insertion_target(
|
||||
return SelectionInsertionTargetValidation::Valid;
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Linux:重读 PRIMARY selection 与捕获文本比较——用户改了选区 / 清空
|
||||
// PRIMARY 就拒绝粘贴(fcitx CommitText 直接写焦点输入上下文,无需
|
||||
@@ -241,6 +234,11 @@ pub(crate) fn validate_selection_insertion_target(
|
||||
}
|
||||
SelectionInsertionTargetValidation::Valid
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
SelectionInsertionTargetValidation::TargetUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS 专用:以与捕获时相同的形式(trim + truncate)重读当前选区,供
|
||||
@@ -320,8 +318,7 @@ fn activate_app_by_pid(pid: i32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 捕获选区并返回可向用户展示的非阻断平台提醒。
|
||||
/// 目前仅 Linux 在 `wl-paste`、`xclip`、`xsel` 均未安装时返回提醒码。
|
||||
/// 捕获选区。Linux 只通过 fcitx5 DBus 读取 PRIMARY 选区,失败统一视为无选区。
|
||||
pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
|
||||
let source_app = current_front_app();
|
||||
|
||||
@@ -343,7 +340,6 @@ pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
|
||||
text: truncate_selection(trimmed),
|
||||
source_app,
|
||||
}),
|
||||
warning_code: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -366,13 +362,12 @@ pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
|
||||
text: truncate_selection(trimmed),
|
||||
source_app,
|
||||
}),
|
||||
warning_code: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Linux:best-effort 读 PRIMARY selection(wl-paste / xclip / xsel)。
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
// 3. Linux:通过 fcitx5 DBus 读取 PRIMARY selection。
|
||||
#[cfg(target_os = "linux")]
|
||||
match linux_selection::read_selected_text() {
|
||||
linux_selection::LinuxSelectionRead::Text(text) => {
|
||||
let trimmed = text.trim();
|
||||
@@ -389,25 +384,12 @@ pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
|
||||
text: truncate_selection(trimmed),
|
||||
source_app,
|
||||
}),
|
||||
warning_code: None,
|
||||
};
|
||||
}
|
||||
linux_selection::LinuxSelectionRead::ToolsUnavailable => {
|
||||
log::warn!(
|
||||
"[selection] linux primary selection unavailable: install wl-paste, xclip, or xsel"
|
||||
);
|
||||
return SelectionCaptureOutcome {
|
||||
selection: None,
|
||||
warning_code: Some(LINUX_SELECTION_TOOLS_MISSING_WARNING),
|
||||
};
|
||||
}
|
||||
linux_selection::LinuxSelectionRead::NoSelection => {}
|
||||
}
|
||||
|
||||
SelectionCaptureOutcome {
|
||||
selection: None,
|
||||
warning_code: None,
|
||||
}
|
||||
SelectionCaptureOutcome { selection: None }
|
||||
}
|
||||
|
||||
/// 长度截断到首 + 尾 + 标记。
|
||||
@@ -492,12 +474,7 @@ fn selected_text_for_validation() -> Option<String> {
|
||||
(!trimmed.is_empty()).then(|| truncate_selection(trimmed))
|
||||
}
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
target_os = "macos",
|
||||
target_os = "linux",
|
||||
test
|
||||
))]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux", test))]
|
||||
fn selection_text_matches(expected: &str, actual: Option<&str>) -> bool {
|
||||
actual.is_some_and(|actual| actual == expected)
|
||||
}
|
||||
@@ -577,108 +554,58 @@ fn post_copy_shortcut() -> bool {
|
||||
windows_paste::send_ctrl_c().is_ok()
|
||||
}
|
||||
|
||||
#[cfg(any(all(not(target_os = "macos"), not(target_os = "windows")), test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_selection {
|
||||
use std::io::ErrorKind;
|
||||
use std::process::Command;
|
||||
|
||||
const PRIMARY_SELECTION_COMMANDS: &[(&str, &[&str])] = &[
|
||||
("wl-paste", &["--primary", "--no-newline"]),
|
||||
("xclip", &["-o", "-selection", "primary"]),
|
||||
("xsel", &["--primary", "--output"]),
|
||||
];
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum LinuxSelectionRead {
|
||||
Text(String),
|
||||
NoSelection,
|
||||
ToolsUnavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ReaderAttempt {
|
||||
Text(String),
|
||||
AvailableWithoutText,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub fn read_selected_text() -> LinuxSelectionRead {
|
||||
read_selected_text_with(run_capture)
|
||||
classify_selection_result(crate::linux_fcitx::get_selection_text())
|
||||
}
|
||||
|
||||
fn read_selected_text_with<F>(mut run: F) -> LinuxSelectionRead
|
||||
where
|
||||
F: FnMut(&str, &[&str]) -> ReaderAttempt,
|
||||
{
|
||||
let mut has_available_reader = false;
|
||||
for (bin, args) in PRIMARY_SELECTION_COMMANDS {
|
||||
match run(bin, args) {
|
||||
ReaderAttempt::Text(text) => return LinuxSelectionRead::Text(text),
|
||||
ReaderAttempt::AvailableWithoutText => has_available_reader = true,
|
||||
ReaderAttempt::Unavailable => {}
|
||||
fn classify_selection_result(result: Result<String, String>) -> LinuxSelectionRead {
|
||||
match result {
|
||||
Ok(text) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
LinuxSelectionRead::NoSelection
|
||||
} else {
|
||||
LinuxSelectionRead::Text(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::debug!("[selection] fcitx5 GetSelectionText unavailable: {error}");
|
||||
LinuxSelectionRead::NoSelection
|
||||
}
|
||||
}
|
||||
if has_available_reader {
|
||||
LinuxSelectionRead::NoSelection
|
||||
} else {
|
||||
LinuxSelectionRead::ToolsUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
fn run_capture(bin: &str, args: &[&str]) -> ReaderAttempt {
|
||||
let output = match Command::new(bin).args(args).output() {
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return ReaderAttempt::Unavailable,
|
||||
Err(_) => return ReaderAttempt::AvailableWithoutText,
|
||||
};
|
||||
if !output.status.success() {
|
||||
return ReaderAttempt::AvailableWithoutText;
|
||||
}
|
||||
let Ok(text) = String::from_utf8(output.stdout) else {
|
||||
return ReaderAttempt::AvailableWithoutText;
|
||||
};
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return ReaderAttempt::AvailableWithoutText;
|
||||
}
|
||||
ReaderAttempt::Text(trimmed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reports_tools_unavailable_only_when_all_three_are_missing() {
|
||||
let result = read_selected_text_with(|_, _| ReaderAttempt::Unavailable);
|
||||
assert_eq!(result, LinuxSelectionRead::ToolsUnavailable);
|
||||
|
||||
let mut attempts = 0;
|
||||
let result = read_selected_text_with(|_, _| {
|
||||
attempts += 1;
|
||||
if attempts == 2 {
|
||||
ReaderAttempt::AvailableWithoutText
|
||||
} else {
|
||||
ReaderAttempt::Unavailable
|
||||
}
|
||||
});
|
||||
assert_eq!(result, LinuxSelectionRead::NoSelection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_text_from_first_reader_that_has_a_selection() {
|
||||
let result = read_selected_text_with(|bin, _| {
|
||||
if bin == "xclip" {
|
||||
ReaderAttempt::Text("selected text".to_string())
|
||||
} else {
|
||||
ReaderAttempt::Unavailable
|
||||
}
|
||||
});
|
||||
fn maps_dbus_text_to_selection() {
|
||||
let result = classify_selection_result(Ok(" selected text ".to_string()));
|
||||
assert_eq!(
|
||||
result,
|
||||
LinuxSelectionRead::Text("selected text".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_empty_dbus_text_to_no_selection() {
|
||||
let result = classify_selection_result(Ok(" \n".to_string()));
|
||||
assert_eq!(result, LinuxSelectionRead::NoSelection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_dbus_error_to_no_selection() {
|
||||
let result = classify_selection_result(Err("DBus unavailable".to_string()));
|
||||
assert_eq!(result, LinuxSelectionRead::NoSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1150,10 +1077,7 @@ mod tests {
|
||||
|
||||
let mut another_control = captured;
|
||||
another_control.focused_window += 100;
|
||||
assert!(!windows_selection_targets_match(
|
||||
captured,
|
||||
another_control
|
||||
));
|
||||
assert!(!windows_selection_targets_match(captured, another_control));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -66,7 +66,6 @@ export const en: typeof zhCN = {
|
||||
micLabel: 'Ask by voice',
|
||||
micStop: 'Stop recording',
|
||||
selectionPreview: 'From selected text:',
|
||||
linuxSelectionToolsMissing: 'Linux selection capture needs wl-paste, xclip, or xsel. Install one and try again.',
|
||||
emptyTitle: 'How can I help?',
|
||||
emptyDesc: 'Select any text to ask about it, or just type your question below. Answers appear here — ask as many follow-ups as you like.',
|
||||
recordingHint: 'Recording… press {{recordHotkey}} again to submit',
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ja: typeof zhCN = {
|
||||
micLabel: '音声で質問',
|
||||
micStop: '録音を終了',
|
||||
selectionPreview: '選択テキスト:',
|
||||
linuxSelectionToolsMissing: 'Linux の選択範囲を読み取れません。wl-paste、xclip、xsel のいずれかをインストールしてください。',
|
||||
emptyTitle: 'ご用件は?',
|
||||
emptyDesc: 'テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。',
|
||||
recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します',
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ko: typeof zhCN = {
|
||||
micLabel: '음성으로 질문',
|
||||
micStop: '녹음 종료',
|
||||
selectionPreview: '선택된 텍스트 기반:',
|
||||
linuxSelectionToolsMissing: 'Linux 선택 영역을 읽으려면 wl-paste, xclip 또는 xsel 중 하나를 설치하세요.',
|
||||
emptyTitle: '무엇을 도와드릴까요?',
|
||||
emptyDesc: '텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.',
|
||||
recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문',
|
||||
|
||||
@@ -64,7 +64,6 @@ export const zhCN = {
|
||||
micLabel: '语音提问',
|
||||
micStop: '结束录音',
|
||||
selectionPreview: '基于选中文本:',
|
||||
linuxSelectionToolsMissing: '无法读取 Linux 选区。请安装 wl-paste、xclip 或 xsel 后重试。',
|
||||
emptyTitle: '有什么可以帮你?',
|
||||
emptyDesc: '选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。',
|
||||
recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问',
|
||||
|
||||
@@ -66,7 +66,6 @@ export const zhTW: typeof zhCN = {
|
||||
micLabel: '語音提問',
|
||||
micStop: '結束錄音',
|
||||
selectionPreview: '基於選中文本:',
|
||||
linuxSelectionToolsMissing: '無法讀取 Linux 選區。請安裝 wl-paste、xclip 或 xsel 後重試。',
|
||||
emptyTitle: '有什麼可以幫你?',
|
||||
emptyDesc: '選中任意文字後開始追問,或直接在下方輸入問題。回答會顯示在這裏,可以連續多輪。',
|
||||
recordingHint: '錄音中…再按一次 {{recordHotkey}} 結束並提問',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { acceptQaSessionEvent, nextQaSelectionWarning, splitQaUserMessage } from './qaMessage';
|
||||
import type { QaChatMessage, QaStatePayload } from './types';
|
||||
import { acceptQaSessionEvent, splitQaUserMessage } from './qaMessage';
|
||||
import type { QaChatMessage } from './types';
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, message: string) {
|
||||
if (actual !== expected) {
|
||||
@@ -38,28 +38,6 @@ const legacy = splitQaUserMessage(
|
||||
assertEqual(legacy.selection, '旧选区', 'legacy messages still expose their selection');
|
||||
assertEqual(legacy.question, '旧问题', 'legacy messages still expose their question');
|
||||
|
||||
const warning: QaStatePayload['selection_warning'] = 'linux_selection_tools_missing';
|
||||
assertEqual(
|
||||
nextQaSelectionWarning('', { kind: 'recording', selection_warning: warning }),
|
||||
warning,
|
||||
'recording surfaces a Linux dependency warning',
|
||||
);
|
||||
assertEqual(
|
||||
nextQaSelectionWarning(warning, { kind: 'idle' }),
|
||||
'',
|
||||
'an idle reset clears a stale warning even when the field is omitted',
|
||||
);
|
||||
assertEqual(
|
||||
nextQaSelectionWarning(warning, { kind: 'idle', selection_warning: null }),
|
||||
'',
|
||||
'an explicit idle reset clears a stale warning',
|
||||
);
|
||||
assertEqual(
|
||||
nextQaSelectionWarning(warning, { kind: 'thinking' }),
|
||||
warning,
|
||||
'a transitional event without a warning preserves the current warning',
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
acceptQaSessionEvent('new-session', { kind: 'answer_delta', session_id: 'old-session' }).accepted,
|
||||
false,
|
||||
@@ -71,13 +49,9 @@ assertEqual(
|
||||
'a recording event activates the next turn token',
|
||||
);
|
||||
assertEqual(
|
||||
acceptQaSessionEvent('old-session', {
|
||||
kind: 'idle',
|
||||
session_id: 'new-session',
|
||||
selection_warning: null,
|
||||
}).sessionId,
|
||||
acceptQaSessionEvent('old-session', { kind: 'idle', session_id: 'new-session' }).sessionId,
|
||||
'new-session',
|
||||
'an explicit panel-open reset activates the reopened panel token',
|
||||
'a panel-open idle activates the reopened panel token',
|
||||
);
|
||||
|
||||
console.log('qaMessage.test.ts passed');
|
||||
@@ -26,30 +26,19 @@ function splitQaUserContent(content: string): { selection: string; question: str
|
||||
return { selection: '', question: content };
|
||||
}
|
||||
|
||||
export function nextQaSelectionWarning(
|
||||
current: string,
|
||||
payload: Pick<QaStatePayload, 'kind' | 'selection_warning'>,
|
||||
): string {
|
||||
if (payload.kind === 'idle' || payload.kind === 'recording') {
|
||||
return payload.selection_warning ?? '';
|
||||
}
|
||||
if (payload.kind === 'loading' || payload.kind === 'thinking') {
|
||||
return payload.selection_warning === undefined ? current : (payload.selection_warning ?? '');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function acceptQaSessionEvent(
|
||||
currentSessionId: string | null,
|
||||
payload: Pick<QaStatePayload, 'kind' | 'session_id' | 'selection_warning'>,
|
||||
payload: Pick<QaStatePayload, 'kind' | 'session_id'>,
|
||||
): { accepted: boolean; sessionId: string | null } {
|
||||
if (!payload.session_id) {
|
||||
return { accepted: true, sessionId: currentSessionId };
|
||||
}
|
||||
// idle 一律视为新会话 token:open_qa_panel 的 idle 总是携带新生成的 session_id,
|
||||
// 且事件按发送顺序到达,complete/turn 收尾的 idle 一定先于下一次 open。
|
||||
const startsTurn = payload.kind === 'recording'
|
||||
|| payload.kind === 'loading'
|
||||
|| payload.kind === 'thinking'
|
||||
|| (payload.kind === 'idle' && payload.selection_warning !== undefined);
|
||||
|| payload.kind === 'idle';
|
||||
if (currentSessionId && !startsTurn && currentSessionId !== payload.session_id) {
|
||||
return { accepted: false, sessionId: currentSessionId };
|
||||
}
|
||||
|
||||
@@ -578,8 +578,6 @@ export interface QaStatePayload {
|
||||
messages?: QaChatMessage[];
|
||||
/** recording 状态时附带的选区预览(前 60 字)。 */
|
||||
selection_preview?: string | null;
|
||||
/** Linux 选区工具缺失时的非阻断提醒码。 */
|
||||
selection_warning?: 'linux_selection_tools_missing' | null;
|
||||
/** error 状态时附带的提示。 */
|
||||
error?: string;
|
||||
/** answer_delta 事件时附带的本帧增量字符串。 */
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
qaToggleRecording,
|
||||
qaWindowDismiss,
|
||||
} from '../lib/ipc';
|
||||
import { acceptQaSessionEvent, nextQaSelectionWarning, splitQaUserMessage } from '../lib/qaMessage';
|
||||
import { acceptQaSessionEvent, splitQaUserMessage } from '../lib/qaMessage';
|
||||
import type { QaChatMessage, QaStatePayload } from '../lib/types';
|
||||
import '../components/chat/chat.css';
|
||||
|
||||
@@ -126,7 +126,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
const [selectionPreview, setSelectionPreview] = useState<string>('');
|
||||
const [selectionWarning, setSelectionWarning] = useState<string>('');
|
||||
const [composerText, setComposerText] = useState<string>('');
|
||||
/** 流式 LLM 答案:answer_delta 累积、answer 事件来时清空(最终内容已落到 messages)。 */
|
||||
const [streamingAnswer, setStreamingAnswer] = useState<string>('');
|
||||
@@ -162,7 +161,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
|
||||
if (payload.messages) {
|
||||
setMessages(payload.messages);
|
||||
}
|
||||
setSelectionWarning(current => nextQaSelectionWarning(current, payload));
|
||||
switch (payload.kind) {
|
||||
case 'idle':
|
||||
setStatus('idle');
|
||||
@@ -216,7 +214,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
|
||||
const dismissHandle = await listen<unknown>('qa:dismiss', () => {
|
||||
activeSessionIdRef.current = null;
|
||||
setSelectionPreview('');
|
||||
setSelectionWarning('');
|
||||
setComposerText('');
|
||||
if (embeddedRef.current) {
|
||||
onRequestCloseRef.current?.();
|
||||
@@ -250,7 +247,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
|
||||
setErrorMsg('');
|
||||
setStreamingAnswer('');
|
||||
setSelectionPreview('');
|
||||
setSelectionWarning('');
|
||||
setComposerText('');
|
||||
}, [closing]);
|
||||
|
||||
@@ -405,16 +401,6 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
|
||||
{status === 'recording' && selectionPreview && (
|
||||
<SelectionChip text={selectionPreview} t={t} />
|
||||
)}
|
||||
{selectionWarning === 'linux_selection_tools_missing' && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="w-full rounded-md border border-amber-500/35 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t('qa.linuxSelectionToolsMissing')}
|
||||
</div>
|
||||
)}
|
||||
<Composer
|
||||
value={composerText}
|
||||
status={status}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
# 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.
|
||||
# AppImage resources are bundled separately and installed to ~/.local/ at runtime.
|
||||
set -euo pipefail
|
||||
|
||||
PKG="$1"
|
||||
|
||||
@@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
find_package(Fcitx5Core REQUIRED)
|
||||
find_package(Fcitx5Utils REQUIRED)
|
||||
find_package(Fcitx5Module REQUIRED COMPONENTS DBus)
|
||||
find_package(Fcitx5Module REQUIRED COMPONENTS DBus Clipboard)
|
||||
|
||||
# FCITX_INSTALL_*DIR comes from Fcitx5Utils
|
||||
message(STATUS "FCITX_INSTALL_LIBDIR: ${FCITX_INSTALL_LIBDIR}")
|
||||
@@ -16,7 +16,8 @@ message(STATUS "FCITX_INSTALL_ADDONDIR: ${FCITX_INSTALL_ADDONDIR}")
|
||||
add_library(openless MODULE openless.cpp)
|
||||
target_link_libraries(openless PRIVATE
|
||||
Fcitx5::Core
|
||||
Fcitx5::Utils)
|
||||
Fcitx5::Utils
|
||||
Fcitx5::Module::Clipboard)
|
||||
|
||||
# Locate fcitx5 module headers (e.g. fcitx-module/dbus/dbus_public.h)
|
||||
find_path(FCITX5_MODULE_INCLUDE_DIR
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states
|
||||
* SetAuxDown(s: text) — 在候选词列表下方显示状态文本
|
||||
* ClearAuxDown() — 清除候选词列表下方文本
|
||||
* GetSelectionText() -> s — 读取当前 PRIMARY 选区文本(由 clipboard addon 维护)
|
||||
* 信号:
|
||||
* DictationKeyEvent(uub: sym, states, isPress) — 听写热键按下/抬起
|
||||
* QaShortcutEvent(uub: sym, states, isPress) — QA 快捷键按下/抬起
|
||||
@@ -46,6 +47,7 @@
|
||||
#include <fcitx/inputcontextmanager.h>
|
||||
#include <fcitx/inputpanel.h>
|
||||
#include <fcitx/instance.h>
|
||||
#include <fcitx-module/clipboard/clipboard_public.h>
|
||||
#include <fcitx-module/dbus/dbus_public.h>
|
||||
|
||||
FCITX_DEFINE_LOG_CATEGORY(openless, "openless");
|
||||
@@ -454,6 +456,22 @@ public:
|
||||
<< "SetTranslationHotkeyRaw: sym=" << sym << " states=" << states;
|
||||
}
|
||||
|
||||
/// 读取当前 PRIMARY 选区文本。空字符串表示无选区或 clipboard addon 不可用。
|
||||
std::string getSelectionText() {
|
||||
auto *clipboard = instance_->addonManager().addon("clipboard");
|
||||
if (!clipboard) {
|
||||
FCITX_LOGC(openless, Debug)
|
||||
<< "GetSelectionText: clipboard addon not loaded";
|
||||
return std::string();
|
||||
}
|
||||
// primary() 签名接收 const InputContext*,clipboard 模块实现中未使用该参数
|
||||
// (读的是全局 primary_ 缓存),这里传 nullptr 即可。
|
||||
std::string text = clipboard->call<IClipboard::primary>(nullptr);
|
||||
FCITX_LOGC(openless, Debug)
|
||||
<< "GetSelectionText: " << text.size() << " chars";
|
||||
return text;
|
||||
}
|
||||
|
||||
FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setAuxDown, "SetAuxDown", "s", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(clearAuxDown, "ClearAuxDown", "", "");
|
||||
@@ -463,6 +481,7 @@ public:
|
||||
FCITX_OBJECT_VTABLE_METHOD(setQaHotkeyRaw, "SetQaHotkeyRaw", "uu", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setSelectionPolishHotkeyRaw, "SetSelectionPolishHotkeyRaw", "uu", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(getSelectionText, "GetSelectionText", "", "s");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyCombined, "DictationKeyCombined", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub");
|
||||
|
||||
Reference in new issue
Block a user