diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index b4270dbc5..1a55b4ef1 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -128,6 +128,12 @@ impl LinuxBackendBuilder { Arc::clone(&task_spawner), )); + // 光标上下文与自动学词共用 fcitx5 的 surrounding text;这两条 adapter + // 不注入的话 Core 用的是 Noop(等于两个功能都不存在)。 + services.host_context = Arc::new(crate::host_context::LinuxHostContextAdapter::default()); + services.edit_observation = + Arc::new(crate::host_context::LinuxEditObservationAdapter::default()); + Ok(Self::new(config, transcription, polisher) .with_task_spawner(task_spawner) .with_auxiliary_polisher(auxiliary_polisher) diff --git a/openless-all/app/linux-egui/src/fcitx5.rs b/openless-all/app/linux-egui/src/fcitx5.rs index 5a7031fe7..30fa6ec62 100644 --- a/openless-all/app/linux-egui/src/fcitx5.rs +++ b/openless-all/app/linux-egui/src/fcitx5.rs @@ -968,6 +968,54 @@ pub(crate) fn rekey_selection_target(_: &str, _: &str) -> Result<(), BackendErro )) } + +/// 焦点输入上下文自报的 surrounding text。 +/// +/// Linux 的光标上下文(给润色的前后文)与自动学词(观察用户是否改了刚插入的 +/// 文本)都以它为准:宿主本来就在这条输入链路里提交文字,应用自报的光标位置 +/// 比遍历无障碍树(AT-SPI)可靠,也不需要额外权限。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SurroundingText { + /// 应用名(fcitx5 的 `ic->program()`),用于敏感应用门禁。 + pub program: String, + pub text: String, + /// 光标偏移(**字符**数,不是字节)。 + pub cursor: usize, + pub anchor: usize, +} + +/// 读取 surrounding text。`Ok(None)` = 焦点应用不支持(valid=0)。 +#[cfg(target_os = "linux")] +pub fn surrounding_text() -> Result, BackendError> { + let raw = send_string_message("GetSurroundingText", |message| message)?; + Ok(parse_surrounding_text(&raw)) +} + +/// 解析插件返回的 `valid\x1fprogram\x1fcursor\x1fanchor\x1ftext`。 +/// +/// 文本在最后一段,所以按 5 段切分;程序名与偏移都是纯量,不会包含分隔符。 +pub(crate) fn parse_surrounding_text(raw: &str) -> Option { + const SEP: char = '\u{1f}'; + let mut parts = raw.splitn(5, SEP); + let valid = parts.next()? == "1"; + let program = parts.next()?.to_string(); + let cursor = parts.next()?.parse::().unwrap_or(0); + let anchor = parts.next()?.parse::().unwrap_or(0); + let text = parts.next().unwrap_or_default().to_string(); + valid.then_some(SurroundingText { + program, + text, + cursor, + anchor, + }) +} + +/// 非 Linux 构建里 surrounding text 不可用(宿主只在 Linux 上跑)。 +#[cfg(not(target_os = "linux"))] +pub fn surrounding_text() -> Result, BackendError> { + Ok(None) +} + #[cfg(target_os = "linux")] pub fn set_hotkeys(keys: Vec) -> Result<(), BackendError> { send_message("SetHotkey", |message| message.append1(keys)) diff --git a/openless-all/app/linux-egui/src/host_context.rs b/openless-all/app/linux-egui/src/host_context.rs new file mode 100644 index 000000000..2521f7145 --- /dev/null +++ b/openless-all/app/linux-egui/src/host_context.rs @@ -0,0 +1,458 @@ +//! Linux 的光标上下文与「自动学词」的观察器。 +//! +//! 两者共用同一个底座:焦点输入上下文自报的 **surrounding text**(见 +//! [`crate::fcitx5::surrounding_text`])。宿主本来就在这条输入链路里提交文字, +//! 应用自报的光标位置比遍历无障碍树(AT-SPI)可靠,也不需要额外权限。 +//! +//! - 光标上下文:听写/润色开始时读一次「光标前后各一段文字」,交给 Core 拼进 +//! system prompt(`prompts::cursor_context_input`),模型就能接着用户正在写的 +//! 内容润色,而不是凭空猜。 +//! - 自动学词:插入完成后观察同一段文字,若用户在观察窗口内改了刚插入的文本, +//! 把「原文 → 改后」这一对交给 Core(`EditObservationSink::publish`),由 Core +//! 决定要不要进词库。 +//! +//! 隐私:密码管理器与终端一律不读。这条门禁放在**读取之前**,和 Tauri 的 +//! `BLOCKED_PROCESS_NAME_FRAGMENTS` 同一思路 —— 只要有一条路径没设门禁,门禁就 +//! 等于不存在。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use futures_util::future::BoxFuture; +use openless_core::host_document::{edit_is_within_typed_text, minimal_edit, window_from_split}; +use openless_core::ports::{EditObservationAdapter, EditObservationSink, HostContextAdapter}; +use openless_core::{BackendError, BackendErrorCode}; + +use crate::fcitx5::SurroundingText; + +/// 光标上下文读取预算(字符)。与 Tauri 的 `DEFAULT_BUDGET_CHARS` 保持一致, +/// 两侧给模型的上下文量相同,提示词行为不会因平台而变。 +pub const CURSOR_CONTEXT_BUDGET_CHARS: usize = 600; + +/// 自动学词的采样间隔。250 ms 足够捕捉用户改字,又不会把 DBus 打满。 +const OBSERVE_INTERVAL: Duration = Duration::from_millis(250); + +/// 敏感应用(小写子串匹配)。命中时既不读光标上下文也不观察编辑。 +/// +/// 名单只做「明显是密码/终端」的判断:误伤一个编辑器可以靠用户关掉开关解决, +/// 但漏掉一个密码管理器就是把主密码写进日志/请求。 +const SENSITIVE_PROGRAMS: &[&str] = &[ + "keepass", + "1password", + "bitwarden", + "lastpass", + "dashlane", + "terminal", + "konsole", + "alacritty", + "kitty", + "wezterm", + "xterm", + "terminator", + "tilix", + "guake", + "yakuake", + "putty", + "tmux", + "screen", +]; + +/// `program` 是否是敏感应用(大小写不敏感的子串匹配)。 +pub fn is_sensitive_program(program: &str) -> bool { + let lowered = program.to_lowercase(); + SENSITIVE_PROGRAMS + .iter() + .any(|blocked| lowered.contains(blocked)) +} + +/// 读取 surrounding text 的抽象。生产实现走 fcitx5;测试用假实现。 +pub trait SurroundingReader: Send + Sync { + fn read(&self) -> Result, BackendError>; +} + +/// 生产实现:走 fcitx5 插件的 `GetSurroundingText`。 +#[derive(Default)] +pub struct FcitxSurrounding; + +impl SurroundingReader for FcitxSurrounding { + fn read(&self) -> Result, BackendError> { + crate::fcitx5::surrounding_text() + } +} + +/// 把 surrounding text 折成 prompt 里的光标上下文。 +/// +/// 光标之后的部分先截断到预算,再交给 Core 的 `window_from_split`(它负责 +/// 「两侧都空就不给上下文」的判定)。应用把光标偏移报成**字符**数;若报得比 +/// 文本还长(少见但对不齐时会致命),夹到文本长度,绝不让切片 panic。 +pub fn cursor_context_from(surrounding: &SurroundingText) -> Option { + let chars: Vec = surrounding.text.chars().collect(); + let cursor = surrounding.cursor.min(chars.len()); + let before: String = chars[..cursor].iter().collect(); + let after: String = chars[cursor..].iter().collect(); + let window = window_from_split(&before, &after, CURSOR_CONTEXT_BUDGET_CHARS)?; + let split = window.cursor.min(window.text.chars().count()); + let before: String = window.text.chars().take(split).collect(); + let after: String = window.text.chars().skip(split).collect(); + Some(openless_core::prompts::cursor_context_input(&before, &after)) +} + +/// 注入式宿主上下文适配器。 +pub struct LinuxHostContextAdapter { + reader: Arc, +} + +impl Default for LinuxHostContextAdapter { + fn default() -> Self { + Self { + reader: Arc::new(FcitxSurrounding), + } + } +} + +impl LinuxHostContextAdapter { + pub fn new(reader: Arc) -> Self { + Self { reader } + } +} + +impl HostContextAdapter for LinuxHostContextAdapter { + fn capture( + &self, + include_cursor: bool, + ) -> BoxFuture<'static, Result> { + let reader = Arc::clone(&self.reader); + Box::pin(async move { + if !include_cursor { + // 明确不读文档:连 surrounding text 都不问。 + return Ok(openless_core::HostContextCapture::default()); + } + // DBus 调用是阻塞的,放到阻塞线程池,别占着 tokio worker。 + let surrounding = tokio::task::spawn_blocking(move || reader.read()) + .await + .map_err(|error| { + BackendError::new( + BackendErrorCode::Platform, + format!("cursor context task failed: {error}"), + ) + })??; + let Some(surrounding) = surrounding else { + log::debug!("[cursor-context] status=none (application reports no surrounding text)"); + return Ok(openless_core::HostContextCapture::default()); + }; + if is_sensitive_program(&surrounding.program) { + log::info!( + "[cursor-context] status=blocked program={} (sensitive application)", + surrounding.program + ); + return Ok(openless_core::HostContextCapture { + front_app: Some(surrounding.program.clone()), + cursor_context: None, + }); + } + let context = cursor_context_from(&surrounding); + match &context { + Some(text) => log::info!( + "[cursor-context] status=ok program={} chars={}", + surrounding.program, + text.chars().count() + ), + None => log::info!( + "[cursor-context] status=empty program={}", + surrounding.program + ), + } + Ok(openless_core::HostContextCapture { + front_app: Some(surrounding.program), + cursor_context: context, + }) + }) + } +} + +/// 自动学词的观察适配器。 +/// +/// `arm` 记下当前 surrounding text 作为基线,起一个后台线程每 [`OBSERVE_INTERVAL`] +/// 采样一次:内容变了就求最小编辑对,只有「改动落在刚插入的文本里」才上报。 +pub struct LinuxEditObservationAdapter { + reader: Arc, + armed: Mutex>>, +} + +impl Default for LinuxEditObservationAdapter { + fn default() -> Self { + Self { + reader: Arc::new(FcitxSurrounding), + armed: Mutex::new(None), + } + } +} + +impl LinuxEditObservationAdapter { + pub fn new(reader: Arc) -> Self { + Self { + reader, + armed: Mutex::new(None), + } + } +} + +impl EditObservationAdapter for LinuxEditObservationAdapter { + fn arm( + &self, + typed_text: String, + sink: Arc, + ) -> Result<(), BackendError> { + self.disarm(); + if typed_text.trim().is_empty() { + return Ok(()); + } + let reader = Arc::clone(&self.reader); + let stop = Arc::new(AtomicBool::new(false)); + let stop_for_thread = Arc::clone(&stop); + let lifetime = sink.observation_duration(); + let spawned = std::thread::Builder::new() + .name("openless-vocab-observer".into()) + .spawn(move || { + observe_loop(reader, typed_text, sink, lifetime, stop_for_thread); + }); + if let Err(error) = spawned { + return Err(BackendError::new( + BackendErrorCode::Platform, + format!("failed to start vocabulary observer: {error}"), + )); + } + *self.armed.lock().expect("observation slot poisoned") = Some(stop); + Ok(()) + } + + fn disarm(&self) { + let previous = self + .armed + .lock() + .expect("observation slot poisoned") + .take(); + if let Some(stop) = previous { + stop.store(true, Ordering::Release); + } + } +} + +fn observe_loop( + reader: Arc, + typed_text: String, + sink: Arc, + lifetime: Duration, + stop: Arc, +) { + let started = Instant::now(); + let mut baseline = match reader.read() { + Ok(Some(surrounding)) if !is_sensitive_program(&surrounding.program) => surrounding.text, + Ok(_) => return, + Err(error) => { + log::warn!("[vocab] observer could not read the baseline: {error}"); + return; + } + }; + while !stop.load(Ordering::Acquire) && started.elapsed() < lifetime { + std::thread::sleep(OBSERVE_INTERVAL); + if stop.load(Ordering::Acquire) { + return; + } + let current = match reader.read() { + Ok(Some(surrounding)) if !is_sensitive_program(&surrounding.program) => surrounding.text, + Ok(_) => return, + Err(error) => { + log::debug!("[vocab] observer stopped reading: {error}"); + return; + } + }; + if current == baseline { + continue; + } + let Some(edit) = minimal_edit(&baseline, ¤t) else { + baseline = current; + continue; + }; + if !edit_is_within_typed_text(&edit, &typed_text) { + // 用户在别处编辑:推进基线,不算「改了刚插入的话」。 + baseline = current; + continue; + } + let accepted = sink.publish(edit); + if accepted { + baseline = current; + } else { + // Core 拒绝(过期代次 / 去重 / 不安全):停止观察,别再读文档。 + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openless_core::host_document::EditPair; + + fn surrounding(text: &str, cursor: usize) -> SurroundingText { + SurroundingText { + program: "gedit".into(), + text: text.into(), + cursor, + anchor: cursor, + } + } + + #[test] + fn sensitive_applications_are_blocked_case_insensitively() { + assert!(is_sensitive_program("KeePassXC")); + assert!(is_sensitive_program("org.gnome.Terminal")); + assert!(is_sensitive_program("Alacritty")); + assert!(!is_sensitive_program("gedit")); + assert!(!is_sensitive_program("firefox")); + } + + #[test] + fn cursor_context_splits_at_the_reported_character_offset() { + let text = "before|after"; + let cursor = text.chars().position(|c| c == '|').unwrap(); + let context = cursor_context_from(&surrounding(text, cursor)).expect("context"); + assert!(context.starts_with("before")); + assert!(context.ends_with("after")); + } + + #[test] + fn blank_documents_produce_no_context() { + assert!(cursor_context_from(&surrounding(" ", 0)).is_none()); + } + + #[test] + fn an_out_of_range_cursor_is_clamped_instead_of_panicking() { + // 应用偶尔把字节数当字符数报上来;必须夹住,不能 panic。 + let context = cursor_context_from(&surrounding("short text", 9_999)).expect("context"); + assert!(context.starts_with("short text"), "{context}"); + } + + #[test] + fn budget_limits_how_much_text_reaches_the_model() { + let text = "x".repeat(5_000); + let context = cursor_context_from(&surrounding(&text, 2_500)).expect("context"); + let stripped: String = context + .chars() + .filter(|c| !openless_core::prompts::CURSOR_MARKER.contains(*c)) + .collect(); + assert!( + stripped.chars().count() <= CURSOR_CONTEXT_BUDGET_CHARS + 8, + "the budget must bound the characters, got {}", + stripped.chars().count() + ); + } + + /// 记录调用、按脚本返回的假 reader。 + struct ScriptedReader { + samples: Mutex>, + } + + impl ScriptedReader { + fn new(samples: &[&str]) -> Self { + Self { + samples: Mutex::new(samples.iter().map(|s| s.to_string()).collect()), + } + } + } + + impl SurroundingReader for ScriptedReader { + fn read(&self) -> Result, BackendError> { + let mut samples = self.samples.lock().unwrap(); + if samples.is_empty() { + return Ok(None); + } + let text = samples.remove(0); + let cursor = text.chars().count(); + Ok(Some(SurroundingText { + program: "gedit".into(), + text, + cursor, + anchor: cursor, + })) + } + } + + struct RecordingSink { + published: Mutex>, + accept: bool, + } + + impl EditObservationSink for RecordingSink { + fn observation_duration(&self) -> Duration { + Duration::from_secs(5) + } + + fn publish(&self, edit: EditPair) -> bool { + self.published.lock().unwrap().push(edit); + self.accept + } + } + + #[test] + fn an_edit_inside_the_inserted_text_is_published_once() { + let reader = Arc::new(ScriptedReader::new(&[ + "the weather is nice today", + "the weather is lovely today", + "the weather is lovely today", + ])); + let sink = Arc::new(RecordingSink { + published: Mutex::new(Vec::new()), + accept: true, + }); + observe_loop( + reader as Arc, + "weather is nice".into(), + sink.clone() as Arc, + Duration::from_secs(3), + Arc::new(AtomicBool::new(false)), + ); + let published = sink.published.lock().unwrap(); + assert_eq!(published.len(), 1, "one edit should be reported once"); + assert_eq!(published[0].source, "nice"); + assert_eq!(published[0].target, "lovely"); + } + + #[test] + fn edits_outside_the_inserted_text_are_ignored() { + let reader = Arc::new(ScriptedReader::new(&["start A end", "start B end"])); + let sink = Arc::new(RecordingSink { + published: Mutex::new(Vec::new()), + accept: true, + }); + observe_loop( + reader as Arc, + "inserted sentence".into(), + sink.clone() as Arc, + Duration::from_secs(1), + Arc::new(AtomicBool::new(false)), + ); + assert!( + sink.published.lock().unwrap().is_empty(), + "an edit outside the inserted text is not vocabulary" + ); + } + + #[test] + fn sensitive_application_stops_the_observer_before_reading() { + let reader = Arc::new(ScriptedReader::new(&["password 123456"])); + let sink = Arc::new(RecordingSink { + published: Mutex::new(Vec::new()), + accept: true, + }); + observe_loop( + reader as Arc, + "password".into(), + sink.clone() as Arc, + Duration::from_secs(1), + Arc::new(AtomicBool::new(false)), + ); + assert!(sink.published.lock().unwrap().is_empty()); + } +} diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 82dcb4219..f78b29a57 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -7913,6 +7913,220 @@ pub const CATALOG: &[Msg] = &[ text: row("隐藏", "隱藏", "Hide", "隠す", "숨기기"), }, + Msg { + key: "settings.about.beta_channel_desc", + text: row( + "开启后,后台自动更新将跟随 Beta 渠道;关闭则回到正式版。下方按钮可随时手动检查 Beta 更新。", + "開啟後,背景自動更新將跟隨 Beta 渠道;關閉則回到正式版。下方按鈕可隨時手動檢查 Beta 更新。", + "When on, background auto-update follows Beta; when off, it uses stable. Use the button below to manually check Beta anytime.", + "オンにするとバックグラウンド自動更新が Beta に従います。オフで正式版に戻ります。下のボタンでいつでも Beta を手動確認できます。", + "켜면 백그라운드 자동 업데이트가 Beta를 따릅니다. 끄면 정식판으로 돌아갑니다. 아래 버튼으로 언제든 Beta를 수동 확인할 수 있습니다.", + ), + }, + Msg { + key: "settings.about.beta_channel_label", + text: row( + "加入 Beta 渠道", + "加入 Beta 渠道", + "Join Beta channel", + "Beta チャンネルに参加", + "Beta 채널 참여", + ), + }, + Msg { + key: "settings.about.beta_channel_toggle_label", + text: row( + "启用 Beta 渠道", + "啟用 Beta 渠道", + "Enable Beta channel", + "Beta チャンネルを有効化", + "Beta 채널 사용", + ), + }, + Msg { + key: "settings.about.checking_update", + text: row("检查中…", "檢查中…", "Checking…", "確認中…", "확인 중…"), + }, + Msg { + key: "settings.about.open_releases", + text: row( + "打开 Releases", + "開啟 Releases", + "Open Releases", + "Releases を開く", + "Releases 열기", + ), + }, + Msg { + key: "settings.about.up_to_date", + text: row( + "当前已是最新版本。", + "當前已是最新版本。", + "You are already on the latest version.", + "現在最新バージョンです。", + "현재 최신 버전입니다.", + ), + }, + Msg { + key: "settings.about.update_available", + text: row( + "有新版本 v{}", + "有新版本 v{}", + "Version v{} is available", + "新しいバージョン v{} があります", + "새 버전 v{}이(가) 있습니다", + ), + }, + Msg { + key: "settings.about.update_error", + text: row( + "检查或更新失败,请稍后重试。", + "檢查或更新失敗,請稍後重試。", + "Update check or install failed. Please try again later.", + "確認またはアップデートに失敗しました。後で再試行してください。", + "확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.", + ), + }, + Msg { + key: "settings.about.check_beta_update_btn", + text: row( + "检查 Beta 更新", + "檢查 Beta 更新", + "Check Beta update", + "Beta を確認", + "Beta 확인", + ), + }, + Msg { + key: "settings.about.check_stable_update_btn", + text: row( + "检查正式版更新", + "檢查正式版更新", + "Check stable update", + "正式版を確認", + "정식판 확인", + ), + }, + Msg { + key: "settings.data_storage.cursor_context_desc", + text: row( + "润色时把光标附近的一小段文字发给模型作参考,用于专有词、同音词和指代的判断(macOS / Windows / Android)。听写、速记和云笔记都会参考,但上下文本身不会写入笔记、历史或日志;使用云端模型时会随本次润色请求发送给该模型服务。此开关与本地手改学词独立;排除密码框、终端及已知敏感应用。", + "潤色時把游標附近的一小段文字傳給模型作參考,用於專有詞、同音詞和指代的判斷(macOS / Windows / Android)。聽寫、速記和雲筆記都會參考,但上下文本身不會寫入筆記、歷史或日誌;使用雲端模型時會隨本次潤色請求傳送給該模型服務。此開關與本機手改學詞獨立;排除密碼欄位、終端機及已知敏感應用程式。", + "Send a short stretch of text around the cursor with polish requests as reference for names, homophones and pronouns (macOS / Windows / Android). Dictation, quick notes and cloud notes all use it, but the context itself is never written to notes, history or logs; with a cloud model it is sent to that provider with the polish request. This switch is separate from local vocabulary learning. Password fields, terminals and known sensitive apps are excluded.", + "推敲時にカーソル付近の短い文章を参考情報としてモデルへ送信し、固有名詞・同音語・指示語の判断に使います(macOS / Windows / Android)。音声入力、クイックメモ、クラウドメモのいずれも参照しますが、コンテキスト自体がメモ・履歴・ログに書き込まれることはありません。クラウドモデル使用時は、推敲リクエストとともにそのプロバイダーへ送信されます。端末内の単語学習とは独立した設定です。パスワード欄、ターミナル、既知の機密アプリは除外します。", + "다듬기 요청 시 커서 주변의 짧은 텍스트를 참고 자료로 모델에 보내 고유명사, 동음이의어, 지시어 판단에 사용합니다(macOS / Windows / Android). 받아쓰기, 빠른 메모, 클라우드 메모 모두 참고하지만 컨텍스트 자체는 메모, 기록, 로그에 저장되지 않습니다. 클라우드 모델을 사용하면 다듬기 요청과 함께 해당 제공업체로 전송됩니다. 기기 내 단어 학습과 별도 설정입니다. 비밀번호 입력란, 터미널, 알려진 민감한 앱은 제외됩니다.", + ), + }, + Msg { + key: "settings.data_storage.cursor_context_label", + text: row( + "光标上下文(实验)", + "遊標上下文(實驗)", + "Cursor context (experimental)", + "カーソル文脈(実験的)", + "커서 문맥 (실험적)", + ), + }, + Msg { + key: "settings.vocabulary_learning.description", + text: row( + "插入后在本机观察当前输入框内的修改,最长 60 秒,确认后才加入词典。观察文本不会发给模型;排除密码框和已知敏感应用。Windows、Android 的支持取决于编辑器;Android 需开启无障碍服务。关闭后停止观察。", + "插入後在本機觀察目前輸入框內的修改,最長 60 秒,確認後才加入詞典。觀察文字不會傳給模型;排除密碼欄位和已知敏感應用程式。Windows、Android 支援取決於編輯器;Android 需開啟無障礙服務。關閉後停止觀察。", + "After insertion, observe edits locally for up to 60 seconds and ask before saving a word. Observation text is not sent to a model. Password fields and known sensitive apps are excluded. Windows and Android support depends on the editor; Android requires accessibility. Turning this off stops observation.", + "挿入後、入力欄の修正を端末内で最大60秒間検出し、確認後に辞書へ追加します。検出した文章はモデルに送信しません。パスワード欄と既知の機密アプリは除外します。Windows・Androidではエディターによって対応が異なり、Androidはユーザー補助が必要です。オフにすると検出を停止します。", + "입력 후 최대 60초 동안 기기에서 해당 입력란의 수정을 감지하고 확인 후 사전에 추가합니다. 관찰한 텍스트는 모델에 보내지 않습니다. 비밀번호 입력란과 알려진 민감한 앱은 제외됩니다. Windows 및 Android는 편집기에 따라 지원이 다르며 Android는 접근성 서비스가 필요합니다. 끄면 관찰을 중지합니다.", + ), + }, + Msg { + key: "settings.vocabulary_learning.enabled", + text: row( + "启用手改学词", + "啟用手改學詞", + "Enable learning from corrections", + "修正からの学習を有効にする", + "수정 학습 사용", + ), + }, + Msg { + key: "settings.vocabulary_learning.max_phrase_chars", + text: row( + "最大词长", + "最大詞長", + "Maximum phrase length", + "最大文字数", + "최대 글자 수", + ), + }, + Msg { + key: "settings.vocabulary_learning.max_phrase_chars_hint", + text: row( + "自动建议中原词和替换词的长度上限,2–32 个字符;不影响手动加词。", + "自動建議中原詞和替換詞的長度上限,2–32 個字元;不影響手動加詞。", + "Limit both original and replacement phrases in automatic suggestions to 2–32 characters. Manual additions are unaffected.", + "自動候補の元の語と置換後の語を 2~32 文字に制限します。手動追加には影響しません。", + "자동 제안의 원문과 대체 문구를 2~32자로 제한합니다. 수동 추가에는 영향을 주지 않습니다.", + ), + }, + Msg { + key: "settings.vocabulary_learning.observation_seconds", + text: row( + "观察时长", + "觀察時長", + "Observation duration", + "監視時間", + "관찰 시간", + ), + }, + Msg { + key: "settings.vocabulary_learning.observation_seconds_hint", + text: row( + "听写落字后观察当前输入框的最长时间,10–60 秒。", + "聽寫輸入後觀察目前輸入框的最長時間,10–60 秒。", + "Maximum time to observe the current field after dictation: 10–60 seconds.", + "音声入力後、現在の入力欄を監視する最大時間(10~60 秒)。", + "음성 입력 후 현재 입력란을 관찰하는 최대 시간입니다(10~60초).", + ), + }, + Msg { + key: "settings.vocabulary_learning.reset", + text: row( + "恢复默认参数", + "恢復預設參數", + "Reset parameters", + "設定値をリセット", + "매개변수 초기화", + ), + }, + Msg { + key: "settings.vocabulary_learning.suggestion_seconds", + text: row( + "建议保留时长", + "建議保留時長", + "Suggestion duration", + "候補の表示時間", + "제안 유지 시간", + ), + }, + Msg { + key: "settings.vocabulary_learning.suggestion_seconds_hint", + text: row( + "未确认的建议会在 5–60 秒后消失,不加入词典。", + "未確認的建議會在 5–60 秒後消失,不加入詞典。", + "Unconfirmed suggestions expire after 5–60 seconds without being added to the vocabulary.", + "未確認の候補は 5~60 秒後に消え、辞書には追加されません。", + "확인하지 않은 제안은 5~60초 후 사전에 추가되지 않고 사라집니다.", + ), + }, + Msg { + key: "settings.vocabulary_learning.title", + text: row( + "手改学词", + "手改學詞", + "Learn from corrections", + "修正から単語を学習", + "수정으로 단어 학습", + ), + }, ]; fn lang_index(lang: Lang) -> usize { diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 4a9e3aa89..3af41fb33 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -18,12 +18,14 @@ mod dictation_feedback; mod fcitx5; pub mod frame_stats; mod host_actions; +pub mod host_context; mod hotkeys; mod i18n; pub mod linux_access; pub mod local_hotkeys; mod logging; pub mod omni_settings; +pub mod update_check; mod popup; mod popup_layer; mod popup_window; @@ -73,7 +75,8 @@ pub use fcitx5::{ copy_to_clipboard as fcitx5_copy_to_clipboard, ensure_plugin_installed as ensure_fcitx5_plugin_installed, reload_fcitx5_if_plugin_updated, reload_running_fcitx5, selection_text as fcitx5_selection_text, - set_hotkeys as set_fcitx5_hotkeys, Fcitx5TextInserter, FcitxPluginInstallPlan, + set_hotkeys as set_fcitx5_hotkeys, surrounding_text as fcitx5_surrounding_text, + Fcitx5TextInserter, FcitxPluginInstallPlan, SurroundingText, FcitxPluginStatus, }; pub use host_actions::LinuxHostActions; diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 726e29828..d36dbb654 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -170,6 +170,24 @@ impl OpenLessEguiApp { frontend::view_model::SettingsField::AutoUpdate => { preferences.auto_update_check = !preferences.auto_update_check; } + frontend::view_model::SettingsField::BetaUpdateChannel => { + let following_beta = + preferences.update_channel != openless_core::shared_types::UpdateChannel::Beta; + preferences.update_channel = if following_beta { + openless_core::shared_types::UpdateChannel::Beta + } else { + openless_core::shared_types::UpdateChannel::Stable + }; + preferences.update_channel_explicit = true; + + self.frontend_vm.settings.update_beta_channel = following_beta; + } + frontend::view_model::SettingsField::CursorContext => { + preferences.cursor_context_enabled = !preferences.cursor_context_enabled; + } + frontend::view_model::SettingsField::VocabularyLearning => { + preferences.vocabulary_learning_enabled = !preferences.vocabulary_learning_enabled; + } frontend::view_model::SettingsField::RemoteInput => { preferences.remote_input_enabled = !preferences.remote_input_enabled; } @@ -659,6 +677,24 @@ impl OpenLessEguiApp { self.frontend_vm.settings.selection_voice_system_prompt = text; } + frontend::view_model::SettingsTextField::VocabObservationSeconds => { + if let Ok(value) = text.trim().parse::() { + preferences.vocabulary_learning_settings.observation_seconds = value; + } + self.frontend_vm.settings.vocab_observation_seconds = text; + } + frontend::view_model::SettingsTextField::VocabSuggestionSeconds => { + if let Ok(value) = text.trim().parse::() { + preferences.vocabulary_learning_settings.suggestion_seconds = value; + } + self.frontend_vm.settings.vocab_suggestion_seconds = text; + } + frontend::view_model::SettingsTextField::VocabMaxPhraseChars => { + if let Ok(value) = text.trim().parse::() { + preferences.vocabulary_learning_settings.max_phrase_chars = value; + } + self.frontend_vm.settings.vocab_max_phrase_chars = text; + } frontend::view_model::SettingsTextField::HistoryMaxEntries => { preferences.history_max_entries = text .trim() @@ -784,6 +820,25 @@ impl OpenLessEguiApp { frontend::view_model::SettingsActionField::OpenFeedback => { let _ = open_external(ISSUES_URL); } + frontend::view_model::SettingsActionField::CheckUpdate => { + self.check_updates(); + } + frontend::view_model::SettingsActionField::ResetVocabularyLearning => { + if let Some(preferences) = self.preferences.as_mut() { + preferences.vocabulary_learning_settings = + openless_core::shared_types::VocabularyLearningSettings::default(); + } + self.frontend_vm.settings.vocab_observation_seconds = "60".into(); + self.frontend_vm.settings.vocab_suggestion_seconds = "10".into(); + self.frontend_vm.settings.vocab_max_phrase_chars = "12".into(); + } + frontend::view_model::SettingsActionField::OpenUpdatePage => { + let url = match &self.frontend_vm.settings.update_state { + frontend::view_model::UpdateState::Available { url, .. } => url.clone(), + _ => openless_linux_egui::update_check::RELEASES_URL.to_string(), + }; + let _ = open_external(&url); + } frontend::view_model::SettingsActionField::CopyQQ => { match fcitx5_copy_to_clipboard("1078960553") { Ok(()) => { diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ee4bf1a70..72dd80784 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -65,6 +65,12 @@ mod linux_app { result: Result, }, HotkeyError(BackendError), + /// 关于页的更新检测结果(Linux 只检测 + 跳转,不安装)。 + Update { + /// 后台自动检查(找到新版本时给一条桌面通知,手动检查不打扰)。 + auto: bool, + result: Result, String>, + }, ProviderSaved { editor: Box, result: Result<(), String>, @@ -581,6 +587,8 @@ mod linux_app { settings_channels_request: u64, settings_channels_refresh_requested: bool, linux_access_last_check: Option, + /// 上次自动检查更新的时间(`auto_update_check` 打开时每 60 分钟一次)。 + last_update_check: Option, /// 语言模型 / 语音识别是否各自有启用的渠道(AI 服务页的状态点)。 service_configured: [bool; 2], /// 文本型设置行(端口/条数/路径…)只在偏好刚载入或外部变更时回灌, @@ -839,6 +847,7 @@ mod linux_app { settings_channels_request: 0, settings_channels_refresh_requested: false, linux_access_last_check: None, + last_update_check: None, service_configured: [false; 2], hydrate_text_fields: true, overview: OverviewState::Loading, @@ -955,6 +964,7 @@ mod linux_app { settings_channels_request: 0, settings_channels_refresh_requested: false, linux_access_last_check: None, + last_update_check: None, service_configured: [false; 2], overview: OverviewState::Loading, microphones: Vec::new(), @@ -4583,6 +4593,44 @@ mod linux_app { ); } } + UiResult::Update { auto, result } => { + self.last_update_check = Some(std::time::Instant::now()); + if auto { + if let Ok(Some(check)) = &result { + if check.newer { + let message = openless_linux_egui::fmt_l10n( + self.lang, + "settings.about.update_available", + &[&check.latest.version], + ); + self.status = message.clone(); + std::thread::spawn(move || { + if let Err(error) = notify(Notification { + summary: "OpenLess", + body: &message, + icon: "openless", + timeout_ms: 0, + }) { + log::warn!( + "OpenLess update notification failed: {error}" + ); + } + }); + } + } + } + self.frontend_vm.settings.update_state = match result { + Ok(Some(check)) if check.newer => { + frontend::view_model::UpdateState::Available { + version: check.latest.version.clone(), + url: check.latest.url.clone(), + } + } + Ok(Some(_)) => frontend::view_model::UpdateState::UpToDate, + Ok(None) => frontend::view_model::UpdateState::UpToDate, + Err(message) => frontend::view_model::UpdateState::Failed { message }, + }; + } UiResult::Remote(Ok(remote)) => self.remote_access = Some(remote), UiResult::Remote(Err(error)) => self.status = error, UiResult::Providers { @@ -5328,6 +5376,33 @@ mod linux_app { // ── Frontend bridge ───────────────────────────────────────────────── + /// 检查更新:Linux 只判断「有没有更新的发布」,不下载安装 + /// (deb/rpm 无法自替换,安装交给用户点发布页)。 + fn check_updates(&mut self) { + self.check_updates_with(false); + } + + /// `auto = true`:后台定时检查,发现新版本时额外发一条桌面通知。 + fn check_updates_with(&mut self, auto: bool) { + let channel = match self.backend() { + Some(backend) => { + let prefs = backend.get_preferences(); + if prefs.update_channel == openless_core::shared_types::UpdateChannel::Beta { + openless_linux_egui::update_check::UpdateChannel::Beta + } else { + openless_linux_egui::update_check::UpdateChannel::Stable + } + } + None => openless_linux_egui::update_check::UpdateChannel::Stable, + }; + self.frontend_vm.settings.update_state = frontend::view_model::UpdateState::Checking; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = openless_linux_egui::update_check::check(channel).await; + let _ = tx.send(UiResult::Update { auto, result }); + }); + } + /// Sync backend state into the frontend view model each frame before /// rendering. Only fields that have real data sources are populated; /// unwired fields remain in their default empty / loading state. @@ -5343,6 +5418,22 @@ mod linux_app { { self.refresh_linux_access(); } + // 自动检查更新:开关打开时启动后查一次、之后每 60 分钟一次。 + // 只在空闲态触发,避免与手动检查并发。 + if matches!( + self.frontend_vm.settings.update_state, + frontend::view_model::UpdateState::Idle + ) { + if let Some(backend) = self.backend() { + if backend.get_preferences().auto_update_check + && self + .last_update_check + .is_none_or(|checked| checked.elapsed() >= Duration::from_secs(3600)) + { + self.check_updates_with(true); + } + } + } self.frontend_vm.settings_saving = self.settings_save.in_flight.is_some(); self.frontend_vm.settings_save_error = self.settings_save.error.clone(); // Capture overview error before taking a mutable borrow on frontend_vm. @@ -5531,6 +5622,16 @@ mod linux_app { s.stable_transcription = prefs.stable_transcription_enabled; s.start_minimized = prefs.start_minimized; s.auto_update = prefs.auto_update_check; + s.update_beta_channel = + prefs.update_channel == openless_core::shared_types::UpdateChannel::Beta; + s.cursor_context = prefs.cursor_context_enabled; + s.vocabulary_learning = prefs.vocabulary_learning_enabled; + let vocab = prefs.vocabulary_learning_settings.normalized(); + if hydrate_text { + s.vocab_observation_seconds = vocab.observation_seconds.to_string(); + s.vocab_suggestion_seconds = vocab.suggestion_seconds.to_string(); + s.vocab_max_phrase_chars = vocab.max_phrase_chars.to_string(); + } s.remote_input = prefs.remote_input_enabled; if hydrate_text { s.remote_port = prefs.remote_input_port.to_string(); diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 479b8ebb3..eea0c5bce 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -8,7 +8,7 @@ use super::view_model::{ FrontendAction, FrontendViewModel, SettingsActionField, SettingsChannelProvider, SettingsComboField, SettingsField, SettingsProviderAuth, SettingsProviderEditor, SettingsProviderField, SettingsSection, SettingsTextField, ShortcutField, StylePack, - StylePackHotkeyRow, + StylePackHotkeyRow, UpdateState, }; const RAIL_WIDTH: f32 = 214.0; @@ -827,6 +827,17 @@ fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec {} + UpdateState::UpToDate => { + hint_line(ui, tr_l10n(lang, "settings.about.up_to_date")); + } + UpdateState::Available { version, .. } => { + hint_line( + ui, + &fmt_l10n(lang, "settings.about.update_available", &[version]), + ); + } + UpdateState::Failed { message } => { + hint_line(ui, tr_l10n(lang, "settings.about.update_error")); + ui.label(egui::RichText::new(message).size(11.0).color(theme::ERR)); + } + } + }); // The Tauri link card groups documentation links above a divider. ui.add_space(-8.0); card(ui, tr_l10n(lang, "settings.about.links_title"), "", |ui| { diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index fba9ca68a..b752195b6 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -386,6 +386,25 @@ pub enum ShortcutField { StyleDraft, } +/// 关于页「检查更新」的状态。Linux 只做检测与跳转,不做自安装(deb/rpm 无法自替换)。 +#[derive( + Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, +)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum UpdateState { + #[default] + Idle, + Checking, + UpToDate, + Available { + version: String, + url: String, + }, + Failed { + message: String, + }, +} + /// One credential channel shown in the AI-services settings tab. #[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] pub struct SettingsChannel { @@ -539,6 +558,12 @@ pub enum SettingsField { StartMinimized, LaunchAtLogin, AutoUpdate, + /// 跟随 Beta 频道(写 `update_channel` + `update_channel_explicit`)。 + BetaUpdateChannel, + /// 光标上下文(读焦点应用光标前后的文字,给润色当参考)。 + CursorContext, + /// 自动学词(观察用户是否改了刚插入的文本)。 + VocabularyLearning, RemoteInput, SilenceAutoStop, AudioCue, @@ -594,6 +619,9 @@ pub enum SettingsComboField { pub enum SettingsTextField { RemotePort, HistoryMaxEntries, + VocabObservationSeconds, + VocabSuggestionSeconds, + VocabMaxPhraseChars, /// 历史保留天数(0 = 永久)。 RetentionDays, /// 润色上下文窗口(分钟,0 = 关闭)。 @@ -623,6 +651,12 @@ pub enum SettingsActionField { OpenReleaseNotes, OpenFeedback, CopyQQ, + /// 检查更新(Linux:只检测版本并给出下载页)。 + CheckUpdate, + /// 打开「发现新版本」对应的发布页。 + OpenUpdatePage, + /// 把自动学词的三个参数恢复出厂值。 + ResetVocabularyLearning, } // ── Vocab types ───────────────────────────────────────────────────────────── @@ -1179,6 +1213,16 @@ pub struct SettingsFields { pub start_minimized: bool, pub launch_at_login: bool, pub auto_update: bool, + /// 关于页的更新状态与所选频道(跟随 Tauri 的 `update_channel`)。 + pub update_state: UpdateState, + pub update_beta_channel: bool, + /// 光标上下文开关(Core 偏好 `cursor_context_enabled`)。 + pub cursor_context: bool, + /// 自动学词开关与三个参数(秒 / 秒 / 字符)。 + pub vocabulary_learning: bool, + pub vocab_observation_seconds: String, + pub vocab_suggestion_seconds: String, + pub vocab_max_phrase_chars: String, pub remote_input: bool, pub remote_default_mode: usize, pub silence_auto_stop: bool, @@ -1252,6 +1296,13 @@ impl Default for SettingsFields { start_minimized: false, launch_at_login: false, auto_update: false, + update_state: UpdateState::Idle, + update_beta_channel: false, + cursor_context: false, + vocabulary_learning: false, + vocab_observation_seconds: "60".into(), + vocab_suggestion_seconds: "10".into(), + vocab_max_phrase_chars: "12".into(), remote_input: false, remote_default_mode: 0, silence_auto_stop: false, diff --git a/openless-all/app/linux-egui/src/update_check.rs b/openless-all/app/linux-egui/src/update_check.rs new file mode 100644 index 000000000..915a313f5 --- /dev/null +++ b/openless-all/app/linux-egui/src/update_check.rs @@ -0,0 +1,312 @@ +//! Linux 桌面包的更新检测。 +//! +//! Tauri 版用 `tauri-plugin-updater` 直接下载安装(macOS 的 `.app.tar.gz`、Windows 的 +//! NSIS、Android 的 APK)。Linux 只发 deb/rpm,没有可自替换的封装,所以这里只做到 +//! **发现新版本 + 给出下载页**:判断依据是 GitHub Releases 的公开 atom 源(Tauri 的 +//! Beta 检测用的是同一个源,CDN 缓存、没有 REST 的 60 次/小时限流)。 +//! +//! 标签约定:`v<版本>-tauri`(例如 `v2.0.2-Beta.3-tauri`)。Linux 包挂在同一个 release +//! 上,所以不需要另开清单。 + + +/// 发布页(与 About 页的「发布说明」同一个链接)。 +pub const RELEASES_URL: &str = "https://github.com/Open-Less/openless/releases"; +const RELEASES_ATOM: &str = "https://github.com/Open-Less/openless/releases.atom"; +const CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +/// 跟哪个频道:稳定版只看正式发布,测试版也接受 `-Beta.N`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateChannel { + Stable, + Beta, +} + +impl UpdateChannel { + pub fn is_beta(self) -> bool { + matches!(self, Self::Beta) + } +} + +/// 一条发布记录。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Release { + pub tag: String, + /// 去掉 `v` 前缀与 `-tauri` 后缀后的版本号,例如 `2.0.2-Beta.3`。 + pub version: String, + pub url: String, +} + +/// 检查结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateCheck { + /// 当前版本(来自构建期注入的 `OPENLESS_APP_VERSION`)。 + pub current: String, + /// 频道里最新的那条发布。 + pub latest: Release, + /// 最新版本是否比当前版本新。 + pub newer: bool, +} + + +/// 从 atom 里取发布列表(保持源顺序,GitHub 是新的在前)。 +/// +/// 标签从 `tag:github.com,2008:Repository//v2.0.2-Beta.3-tauri` 里取 +/// —— `` 是 `OpenLess v2.0.2-Beta.3-tauri`,带产品名前缀,不能直接当标签用。 +pub fn parse_releases(atom: &str) -> Vec<Release> { + let mut releases = Vec::new(); + for entry in atom.split("<entry>").skip(1) { + let entry = entry.split("</entry>").next().unwrap_or(entry); + let Some(tag) = text_of(entry, "id") + .and_then(|id| id.rsplit('/').next().map(str::to_string)) + .filter(|tag| tag.starts_with('v')) + else { + continue; + }; + let Some(url) = tag_link_href(entry) else { + continue; + }; + let Some(version) = release_version(&tag) else { + continue; + }; + releases.push(Release { + tag, + version, + url, + }); + } + releases +} + +/// `v2.0.2-Beta.3-tauri` → `2.0.2-Beta.3`;不符合约定的标签返回 `None`。 +pub fn release_version(tag: &str) -> Option<String> { + let tag = tag.trim(); + let tag = tag.strip_prefix('v')?; + let tag = tag.strip_suffix("-tauri")?; + (!tag.is_empty() && tag.starts_with(|c: char| c.is_ascii_digit())).then(|| tag.to_string()) +} + +/// 频道里最新的那条发布:稳定版跳过任何带预发布标记的标签。 +pub fn select(channel: UpdateChannel, releases: &[Release]) -> Option<&Release> { + releases + .iter() + .find(|release| channel.is_beta() || !is_prerelease(&release.version)) +} + +fn is_prerelease(version: &str) -> bool { + version.contains('-') +} + +/// 版本号比较:`2.0.2-Beta.3-149` 这类构建后缀不参与比较。 +/// +/// 规则与语义保持一致:数字段逐位比较;同一基线上正式版 > 预发布版; +/// 预发布之间按名字与序号比较(`Beta.10` > `Beta.9`)。 +pub fn is_newer(current: &str, candidate: &str) -> bool { + match (parse_version(current), parse_version(candidate)) { + (Some(current), Some(candidate)) => candidate > current, + // 解析不出来时退回字符串比较,至少不会把「看起来一样」当成更新。 + _ => trimmed(current) != trimmed(candidate), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedVersion(Vec<u64>, Option<(String, u64)>); + +impl Ord for ParsedVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + use std::cmp::Ordering; + // 数字段按位比较,短的一方补 0(`2.0` 与 `2.0.0` 视为同一版本)。 + let digits = self.0.len().max(other.0.len()); + for index in 0..digits { + let left = self.0.get(index).copied().unwrap_or(0); + let right = other.0.get(index).copied().unwrap_or(0); + match left.cmp(&right) { + Ordering::Equal => {} + order => return order, + } + } + // 同一基线上正式版高于任何预发布版。 + match (&self.1, &other.1) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(left), Some(right)) => left.cmp(right), + } + } +} + +impl PartialOrd for ParsedVersion { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + +fn parse_version(raw: &str) -> Option<ParsedVersion> { + let trimmed = trimmed(raw); + let (base, pre) = match trimmed.split_once('-') { + Some((base, pre)) => (base, Some(pre)), + None => (trimmed.as_str(), None), + }; + let numbers = base + .split('.') + .map(|part| part.parse::<u64>().ok()) + .collect::<Option<Vec<u64>>>()?; + let pre = pre.and_then(|pre| { + let (name, index) = pre.rsplit_once('.')?; + let index = index.parse::<u64>().ok()?; + Some((name.to_ascii_lowercase(), index)) + }); + Some(ParsedVersion(numbers, pre)) +} + +/// 去掉构建期追加的 `-<数字>`(`2.0.2-Beta.3-149` → `2.0.2-Beta.3`)。 +/// 只有最后一段是纯数字且前面还有内容时才截断,`Beta.3` 这类序号不受影响。 +fn trimmed(raw: &str) -> String { + let raw = raw.trim(); + if let Some((head, tail)) = raw.rsplit_once('-') { + if !head.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) { + return head.to_string(); + } + } + raw.to_string() +} + +/// 网络检查。`Ok(None)` = 频道里还没有任何发布。 +pub async fn check(channel: UpdateChannel) -> Result<Option<UpdateCheck>, String> { + let response = openless_core::net::send_with_retry(|| { + openless_core::net::http() + .get(RELEASES_ATOM) + .timeout(CHECK_TIMEOUT) + }) + .await + .map_err(|error| error.to_string())?; + if !response.status().is_success() { + return Err(format!("releases.atom: HTTP {}", response.status())); + } + let body = response.text().await.map_err(|error| error.to_string())?; + let releases = parse_releases(&body); + let Some(latest) = select(channel, &releases).cloned() else { + return Ok(None); + }; + let current = env!("OPENLESS_APP_VERSION").to_string(); + Ok(Some(UpdateCheck { + newer: is_newer(¤t, &latest.version), + current, + latest, + })) +} + +fn text_of(entry: &str, tag: &str) -> Option<String> { + let start = entry.find(&format!("<{tag}>"))? + tag.len() + 2; + let end = entry[start..].find(&format!("</{tag}>"))? + start; + let value = entry[start..end].trim(); + (!value.is_empty()).then(|| unescape(value)) +} + +/// 取指向 release 页的那条链接(`rel="alternate"`),忽略 feed 级链接。 +fn tag_link_href(entry: &str) -> Option<String> { + let mut rest = entry; + while let Some(start) = rest.find("<link") { + rest = &rest[start..]; + let tag_end = rest.find('>')?; + let element = &rest[..tag_end]; + if let Some(href_start) = element.find("href=\"") { + let href = &element[href_start + "href=\"".len()..]; + if let Some(href_end) = href.find('"') { + let value = &href[..href_end]; + if value.contains("/releases/tag/") { + return Some(value.to_string()); + } + } + } + rest = &rest[tag_end..]; + } + None +} + +fn unescape(value: &str) -> String { + value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + const ATOM: &str = r#"<?xml version="1.0" encoding="UTF-8"?> +<feed xmlns="http://www.w3.org/2005/Atom"> + <entry> + <id>tag:github.com,2008:Repository/1222252809/v2.0.2-Beta.4-tauri</id> + <link rel="alternate" type="text/html" href="https://github.com/Open-Less/openless/releases/tag/v2.0.2-Beta.4-tauri"/> + <title>OpenLess v2.0.2-Beta.4-tauri + + + tag:github.com,2008:Repository/1222252809/v2.0.2-Beta.3-tauri + + OpenLess v2.0.2-Beta.3-tauri + + + tag:github.com,2008:Repository/1222252809/v2.0.1-tauri + + OpenLess v2.0.1-tauri + + + tag:github.com,2008:Repository/1222252809/nightly-scratch + + nightly-scratch + +"#; + + #[test] + fn releases_parse_in_feed_order_and_skip_unrelated_tags() { + let releases = parse_releases(ATOM); + let versions: Vec<_> = releases.iter().map(|r| r.version.as_str()).collect(); + assert_eq!(versions, ["2.0.2-Beta.4", "2.0.2-Beta.3", "2.0.1"]); + assert_eq!( + releases[0].url, + "https://github.com/Open-Less/openless/releases/tag/v2.0.2-Beta.4-tauri" + ); + } + + #[test] + fn stable_channel_skips_beta_tags() { + let releases = parse_releases(ATOM); + let stable = select(UpdateChannel::Stable, &releases).expect("stable release"); + assert_eq!(stable.version, "2.0.1"); + let beta = select(UpdateChannel::Beta, &releases).expect("beta release"); + assert_eq!(beta.version, "2.0.2-Beta.4"); + } + + #[test] + fn version_comparison_ignores_the_build_revision() { + // 构建后缀 `-149` 不参与比较:同一个 Beta.3 不算更新。 + assert!(!is_newer("2.0.2-Beta.3-149", "2.0.2-Beta.3")); + assert!(is_newer("2.0.2-Beta.3-149", "2.0.2-Beta.4")); + assert!(is_newer("2.0.2-Beta.3-149", "2.0.2")); + assert!(!is_newer("2.0.2-Beta.3-149", "2.0.1")); + // 序号按数字比较,不是字符串。 + assert!(is_newer("2.0.2-Beta.9", "2.0.2-Beta.10")); + assert!(!is_newer("2.0.2", "2.0.2-Beta.4")); + } + + /// 真源演练:需要网络,所以默认 `--ignored`。用来确认 GitHub 的 atom 结构 + /// 没变(标题带产品名前缀、标签在 `` 里)。 + #[tokio::test] + #[ignore = "requires network access to github.com"] + async fn live_atom_yields_a_comparable_release() { + let check = check(UpdateChannel::Beta).await.expect("live check"); + let check = check.expect("at least one published release"); + assert!(check.latest.version.starts_with("2."), "{check:?}"); + assert!(check.latest.url.contains("/releases/tag/"), "{check:?}"); + assert_eq!(check.current, env!("OPENLESS_APP_VERSION")); + } + + #[test] + fn missing_channel_release_is_not_an_error() { + let empty = parse_releases(""); + assert!(select(UpdateChannel::Stable, &empty).is_none()); + } +} diff --git a/openless-all/app/linux-egui/tests/fcitx5_contract.rs b/openless-all/app/linux-egui/tests/fcitx5_contract.rs index 45d2f038c..bb1a5961b 100644 --- a/openless-all/app/linux-egui/tests/fcitx5_contract.rs +++ b/openless-all/app/linux-egui/tests/fcitx5_contract.rs @@ -2,8 +2,8 @@ use openless_core::BackendErrorCode; use openless_linux_egui::{ - fcitx5_available, fcitx5_commit_text, fcitx5_selection_text, set_fcitx5_hotkeys, - Fcitx5HotkeyListener, + fcitx5_available, fcitx5_commit_text, fcitx5_selection_text, fcitx5_surrounding_text, + set_fcitx5_hotkeys, Fcitx5HotkeyListener, }; /// Exercise the real fcitx5 DBus object and listener lifecycle. Key event @@ -61,6 +61,19 @@ fn fcitx5_dbus_methods_and_listener_have_stable_platform_semantics() { )); drop(listener); + // 光标上下文:方法必须存在且能回答。没有焦点输入上下文时返回 + // `Ok(None)`(应用不支持 / 没有焦点),不应报错 —— 这是宿主每次听写都会 + // 走的一条路径,报错会污染日志并且让「不支持」看起来像故障。 + match fcitx5_surrounding_text() { + Ok(Some(surrounding)) => { + assert!( + surrounding.cursor <= surrounding.text.chars().count(), + "cursor offset must be inside the reported text" + ); + } + Ok(None) => {} + Err(error) => panic!("surrounding text must not fail without focus: {error:?}"), + } match fcitx5_selection_text() { Ok(_) => {} Err(error) => assert!( diff --git a/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp index f37a5ae2e..762729491 100644 --- a/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp @@ -19,6 +19,14 @@ * GetSelectionText() -> s — 读取**当前** PRIMARY 选区文本:探针可用时以合成器为准, * 当前选区没有 text mime 时返回空,而不是 clipboard * addon 里那份可能过期的缓存(见 primary_selection.h) + * GetSurroundingText() -> s — 焦点输入上下文的光标上下文: + * text=应用自报的全文,cursor/anchor=字符偏移, + * valid=应用是否支持 surrounding text, + * program=应用程序名(用于敏感应用门禁)。 + * Linux 的光标上下文与自动学词都以它为准, + * 不支持 surrounding text 的应用返回 valid=0。 + * 编码:`valid\x1fprogram\x1fcursor\x1fanchor\x1ftext` + * (偏移是字符数,text 在最后一段,可含任意字符)。 * SetClipboardText(s: text) -> b — 通过 clipboard addon 写入 CLIPBOARD * CaptureSelectionTarget(s: ticket) -> s — 捕获选区和原输入上下文 * ApplySelectionTarget(sss: ticket, source, replacement) -> b — 校验后替换 @@ -706,6 +714,36 @@ class OpenLess final : public AddonInstance, return selectionCacheReader_(); } + /// 焦点输入上下文的 surrounding text。应用不支持时 `valid=false`。 + /// + /// 为什么走 IME 而不是 AT-SPI:我们本来就在输入链路里(提交文字用的就是这个 + /// input context),应用自报的光标位置比遍历无障碍树可靠得多,也不需要额外权限。 + std::string getSurroundingText() { + auto *ic = savedIc_; + if (!ic) { + auto &mgr = instance_->inputContextManager(); + mgr.foreachFocused([&](InputContext *focusedIc) { + ic = focusedIc; + return false; + }); + } + constexpr char kSep = '\x1f'; + if (!ic) { + return std::string("0") + kSep + std::string() + kSep + "0" + kSep + "0" + kSep; + } + const std::string program = ic->program(); + const auto &surrounding = ic->surroundingText(); + const bool valid = surrounding.isValid(); + if (!valid) { + FCITX_LOGC(openless, Debug) + << "GetSurroundingText: invalid for program=" << program; + return std::string("0") + kSep + program + kSep + "0" + kSep + "0" + kSep; + } + return std::string("1") + kSep + program + kSep + + std::to_string(surrounding.cursor()) + kSep + + std::to_string(surrounding.anchor()) + kSep + surrounding.text(); + } + bool setClipboardText(const std::string &text) { auto *clipboard = instance_->addonManager().addon("clipboard"); if (!clipboard) { @@ -737,6 +775,7 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_METHOD(setOpenAppHotkeyRaw, "SetOpenAppHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setStylePackHotkeys, "SetStylePackHotkeys", "a(suu)", ""); FCITX_OBJECT_VTABLE_METHOD(getSelectionText, "GetSelectionText", "", "s"); + FCITX_OBJECT_VTABLE_METHOD(getSurroundingText, "GetSurroundingText", "", "s"); FCITX_OBJECT_VTABLE_METHOD(setClipboardText, "SetClipboardText", "s", "b"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyCombined, "DictationKeyCombined", "uub"); diff --git a/openless-all/app/scripts/sync-egui-i18n.mjs b/openless-all/app/scripts/sync-egui-i18n.mjs index 5ae0c4d8f..73d47381f 100644 --- a/openless-all/app/scripts/sync-egui-i18n.mjs +++ b/openless-all/app/scripts/sync-egui-i18n.mjs @@ -47,6 +47,8 @@ const KEEP_EGUI = new Set([ 'history.retranscribe', // Linux 已删除 Less Computer,代理说明里不能再提它(Tauri 仍保留该功能)。 'settings.network.use_system_proxy_desc', + // Linux 的更新检测只到「发现新版本 + 跳下载页」,Tauri 没有同句文案。 + 'settings.about.update_available', ]); function placeholderCount(text) { @@ -299,7 +301,7 @@ async function main() { const known = new Set(messages.map((message) => message.key)); const missing = []; for (const key of referencedKeys()) { - if (known.has(key)) continue; + if (known.has(key) || KEEP_EGUI.has(key)) continue; const tauri = shared.get(normalizeKey(key)); if (!tauri) missing.push(key); }