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:
aeoformandChris233 authored and GitHub committed 2026-08-19 20:34:10 +08:00
1 parent 789c82051e
commit 33467a36ea
21 files changed
+587 -238

No files matched your search

+24 -1
View File
@@ -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
+19 -1
View File
@@ -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) {
+385 -7
View File
@@ -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,
}
}
+49 -125
View File
@@ -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")]
-1
View File
@@ -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',
-1
View File
@@ -68,7 +68,6 @@ export const ja: typeof zhCN = {
micLabel: '音声で質問',
micStop: '録音を終了',
selectionPreview: '選択テキスト:',
linuxSelectionToolsMissing: 'Linux の選択範囲を読み取れません。wl-paste、xclip、xsel のいずれかをインストールしてください。',
emptyTitle: 'ご用件は?',
emptyDesc: 'テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。',
recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します',
-1
View File
@@ -68,7 +68,6 @@ export const ko: typeof zhCN = {
micLabel: '음성으로 질문',
micStop: '녹음 종료',
selectionPreview: '선택된 텍스트 기반:',
linuxSelectionToolsMissing: 'Linux 선택 영역을 읽으려면 wl-paste, xclip 또는 xsel 중 하나를 설치하세요.',
emptyTitle: '무엇을 도와드릴까요?',
emptyDesc: '텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.',
recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문',
-1
View File
@@ -64,7 +64,6 @@ export const zhCN = {
micLabel: '语音提问',
micStop: '结束录音',
selectionPreview: '基于选中文本:',
linuxSelectionToolsMissing: '无法读取 Linux 选区。请安装 wl-paste、xclip 或 xsel 后重试。',
emptyTitle: '有什么可以帮你?',
emptyDesc: '选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。',
recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问',
-1
View File
@@ -66,7 +66,6 @@ export const zhTW: typeof zhCN = {
micLabel: '語音提問',
micStop: '結束錄音',
selectionPreview: '基於選中文本:',
linuxSelectionToolsMissing: '無法讀取 Linux 選區。請安裝 wl-paste、xclip 或 xsel 後重試。',
emptyTitle: '有什麼可以幫你?',
emptyDesc: '選中任意文字後開始追問,或直接在下方輸入問題。回答會顯示在這裏,可以連續多輪。',
recordingHint: '錄音中…再按一次 {{recordHotkey}} 結束並提問',
+4 -30
View File
@@ -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');
+4 -15
View File
@@ -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 };
}
-2
View File
@@ -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 事件时附带的本帧增量字符串。 */
+1 -15
View File
@@ -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}
+1 -2
View File
@@ -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");