Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,9 @@ fn handle_selection_workspace_hotkey_pressed(inner: &Arc<Inner>) {
"selectionPolishNoSelection" | "selected text must not be empty" => {
"未选中内容"
}
"selectionPolishSelectionTooLong" => {
"选区超过 4000 字,未替换,以免删掉中间没有读到的原文"
}
"selectionPolishTargetUnavailable" => "目标输入框不可用,请重新选择",
"selectionPolishTargetChanged" | "selectionPolishSelectionChanged" => {
"选区已变化,未替换"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ fn selection_voice_user_message(error: &str) -> String {
match error {
"dictationActive" => "正在听写,请先结束录音".into(),
"selectionVoiceNoSelection" => "请先选中文字,或将光标放在可输入的文本框中".into(),
"selectionVoiceSelectionTooLong" => {
"选区超过 4000 字,已停止,以免用截断结果覆盖并删掉中间原文".into()
}
"selectionVoiceTargetUnavailable" => "无法定位输入目标,请先点击文本框后再试".into(),
"selectionVoiceBusy" => "选区语音会话进行中".into(),
other => other.into(),
Expand Down Expand Up @@ -377,9 +380,20 @@ async fn begin_selection_voice_session(inner: &Arc<Inner>) -> Result<(), String>
crate::selection::SelectionContext {
text: String::new(),
source_app: capture_diag.front_app.clone(),
omits_middle: false,
}
}
};
// The stored text is only a head/tail stand-in. Continuing would paste a
// rewrite of that stand-in over the whole live selection and delete the middle.
if selection.omits_middle {
log::warn!(
"[selection-voice] begin refused: selection longer than the replacement limit ({})",
capture_diag.summary()
);
release_selection_voice_capsule_claim(inner);
return Err("selectionVoiceSelectionTooLong".into());
}

let session_id = match inner
.backend
Expand Down Expand Up @@ -778,6 +792,9 @@ impl Coordinator {
if !crate::selection::reactivate_selection_insertion_target(&insertion_target) {
return Err("selectionVoiceTargetUnavailable".to_string());
}
if crate::selection::is_truncated_selection_stand_in(&ticket.source_text) {
return Err("selectionVoiceSelectionTooLong".to_string());
}
// Empty source_text = caret insert (Help me write). Skip Ctrl+C content
// re-validation that assumes a non-empty selection (#1014).
if !ticket.source_text.trim().is_empty() {
Expand Down
15 changes: 15 additions & 0 deletions openless-all/app/src-tauri/src/core_adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,15 @@ impl SelectionPlatformBridge for NativeSelectionPlatformBridge {
"selectionPolishNoSelection",
)
})?;
// A head/tail stand-in must never become the source of a paste. The
// final validation also compares against the full live selection, but
// refusing here avoids sending the omitted middle's absence to the model.
if selection.omits_middle {
return Err(BackendError::new(
BackendErrorCode::InvalidArgument,
"selectionPolishSelectionTooLong",
));
}
if !crate::selection::selection_insertion_target_is_captured(&target) {
return Err(BackendError::new(
BackendErrorCode::Platform,
Expand Down Expand Up @@ -1056,6 +1065,12 @@ impl SelectionPlatformBridge for NativeSelectionPlatformBridge {
"selectionPolishTargetUnavailable",
));
}
if crate::selection::is_truncated_selection_stand_in(source_text) {
return Err(BackendError::new(
BackendErrorCode::InvalidArgument,
"selectionPolishSelectionTooLong",
));
}
let validation = crate::selection::validate_selection_insertion_target(target, source_text);
if let Some(code) = validation.error_code() {
let error_code = match validation {
Expand Down
10 changes: 10 additions & 0 deletions openless-all/app/src-tauri/src/mobile_stubs/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const SELECTION_TRUNCATED_MARKER: &str = "\n[…truncated…]\n";
pub struct SelectionContext {
pub text: String,
pub source_app: Option<String>,
/// True when `text` dropped the middle of a selection longer than 4000 chars.
pub omits_middle: bool,
}

pub struct SelectionCaptureOutcome {
Expand Down Expand Up @@ -70,9 +72,11 @@ pub fn capture_selection() -> Option<SelectionContext> {
"[selection] Android accessibility read OK ({} chars)",
trimmed.chars().count()
);
let omits_middle = trimmed.chars().count() > SELECTION_MAX_CHARS;
Some(SelectionContext {
text: truncate_selection(trimmed),
source_app: Some("Android accessibility".to_string()),
omits_middle,
})
}

Expand All @@ -88,6 +92,12 @@ pub(crate) fn current_front_app_parts() -> (Option<String>, Option<String>) {
(None, None)
}

pub(crate) fn is_truncated_selection_stand_in(text: &str) -> bool {
let marker_len = SELECTION_TRUNCATED_MARKER.chars().count();
text.contains(SELECTION_TRUNCATED_MARKER)
&& text.chars().count() == SELECTION_TRUNCATE_HEAD + marker_len + SELECTION_TRUNCATE_TAIL
}

fn truncate_selection(text: &str) -> String {
let total: usize = text.chars().count();
if total <= SELECTION_MAX_CHARS {
Expand Down
90 changes: 75 additions & 15 deletions openless-all/app/src-tauri/src/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
//! 后读出新内容 → 还原原剪贴板。
//!
//! Truncation policy: selections over 4000 chars keep head 2000 + tail 2000 + the
//! `[…truncated…]` marker, avoiding an overly long LLM context.
//! `[…truncated…]` marker for QA context only. `omits_middle` is set so selection
//! polish and selection voice refuse to paste a rewrite of that stand-in over the
//! real selection (the omitted middle would otherwise be deleted).
//!
//! 模块依赖:`arboard`(跨平台剪贴板)+ libc + 平台 native 框架。
//! Linux 桌面已改由 egui 前端(`openless-all/app/linux-egui`)承担,Tauri 版不再提供
Expand All @@ -24,12 +26,16 @@ const SELECTION_TRUNCATE_TAIL: usize = 2000;
const SELECTION_TRUNCATED_MARKER: &str = "\n[…truncated…]\n";

/// Selection context read from the foreground app.
/// `text` is already truncated; `source_app` is a human-readable label for the foreground app
/// (optional).
/// `text` may be a head/tail stand-in when `omits_middle` is set. Replacement
/// flows must refuse those captures; QA may still use the stand-in as context.
/// `source_app` is a human-readable label for the foreground app (optional).
#[derive(Debug, Clone)]
pub struct SelectionContext {
pub text: String,
pub source_app: Option<String>,
/// True when `text` dropped the middle of a selection longer than
/// [`SELECTION_MAX_CHARS`]. Pasting a rewrite of `text` would delete that middle.
pub omits_middle: bool,
}

/// The target that was active when Selection Polish began. This deliberately
Expand Down Expand Up @@ -440,21 +446,22 @@ pub(crate) fn validate_selection_insertion_target(
}
}

/// macOS only: re-read the current selection in the same form as at capture time (trim +
/// truncate) for validate to compare against expected_selection. Degrades to simulated
/// Cmd+C + clipboard snapshot when AX is unauthorized or the direct read fails (same fallback as
/// `capture_selection_with_status`).
/// macOS only: re-read the full current selection (trimmed, not head/tail truncated)
/// for validate to compare against expected_selection. A truncated stand-in must not
/// compare equal to the live selection, or paste would delete the omitted middle.
/// Degrades to simulated Cmd+C + clipboard snapshot when AX is unauthorized or the
/// direct read fails (same fallback as `capture_selection_with_status`).
#[cfg(target_os = "macos")]
fn read_selection_for_validation() -> Option<String> {
if let Some(text) = macos_ax::read_selected_text() {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(truncate_selection(trimmed));
return Some(trimmed.to_string());
}
}
let text = simulate_copy_and_read()?;
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| truncate_selection(trimmed))
(!trimmed.is_empty()).then(|| trimmed.to_string())
}

/// Return focus to the original selection target after the preview is confirmed. The preview
Expand Down Expand Up @@ -594,11 +601,13 @@ fn capture_selection_with_status_diag() -> (SelectionCaptureOutcome, SelectionCa
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
let (text, omits_middle) = stored_selection_text(trimmed);
return (
SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
text,
source_app,
omits_middle,
}),
},
SelectionCaptureMissReason::Ok,
Expand Down Expand Up @@ -628,11 +637,13 @@ fn capture_selection_with_status_diag() -> (SelectionCaptureOutcome, SelectionCa
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
let (text, omits_middle) = stored_selection_text(trimmed);
return (
SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
text,
source_app,
omits_middle,
}),
},
SelectionCaptureMissReason::Ok,
Expand Down Expand Up @@ -671,6 +682,25 @@ fn capture_selection_with_status_diag() -> (SelectionCaptureOutcome, SelectionCa
)
}

/// True when `text` is the head/tail stand-in produced for an over-long selection.
/// The stand-in length is fixed, so ordinary text that merely mentions the marker
/// is not treated as a truncated capture.
pub(crate) fn is_truncated_selection_stand_in(text: &str) -> bool {
let marker_len = SELECTION_TRUNCATED_MARKER.chars().count();
text.contains(SELECTION_TRUNCATED_MARKER)
&& text.chars().count() == SELECTION_TRUNCATE_HEAD + marker_len + SELECTION_TRUNCATE_TAIL
}

/// Store a trimmed selection. Over-long text keeps a head/tail stand-in for QA
/// context and sets `omits_middle` so replacement flows can refuse to paste it.
fn stored_selection_text(trimmed: &str) -> (String, bool) {
if trimmed.chars().count() > SELECTION_MAX_CHARS {
(truncate_selection(trimmed), true)
} else {
(trimmed.to_string(), false)
}
}

/// Truncate lengthwise to head + tail + marker.
fn truncate_selection(text: &str) -> String {
let total: usize = text.chars().count();
Expand Down Expand Up @@ -782,14 +812,15 @@ fn classify_simulated_copy_result(
Ok(captured)
}

/// Read the current selection in the same normalized/truncated form stored by
/// [`SelectionContext`]. This is used only by the Windows final safety check;
/// the clipboard helper snapshots and restores the user's clipboard.
/// Read the full current selection (trimmed, not head/tail truncated). This is
/// used only by the Windows final safety check; the clipboard helper snapshots
/// and restores the user's clipboard. Comparing against the untruncated text
/// stops a head/tail stand-in from authorizing a paste over the omitted middle.
#[cfg(target_os = "windows")]
fn selected_text_for_validation() -> Option<String> {
let text = simulate_copy_and_read()?;
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| truncate_selection(trimmed))
(!trimmed.is_empty()).then(|| trimmed.to_string())
}

#[cfg(any(target_os = "windows", target_os = "macos", test))]
Expand Down Expand Up @@ -1404,6 +1435,35 @@ mod tests {
assert!(!out.contains(&"b".repeat(20)));
}

#[test]
fn overlong_selection_is_flagged_and_does_not_match_the_live_text() {
let head: String = "a".repeat(SELECTION_TRUNCATE_HEAD);
let middle: String = "b".repeat(2_000);
let tail: String = "c".repeat(SELECTION_TRUNCATE_TAIL);
let live = format!("{head}{middle}{tail}");
let (stored, omits_middle) = stored_selection_text(&live);
assert!(omits_middle);
assert!(stored.contains("[…truncated…]"));
assert!(!stored.contains(&"b".repeat(20)));
// Final paste validation compares the stored stand-in with the full
// live selection. They must not match, or the paste deletes `middle`.
assert!(!selection_text_matches(&stored, Some(&live)));
assert!(is_truncated_selection_stand_in(&stored));
assert!(!is_truncated_selection_stand_in(&live));
assert!(!is_truncated_selection_stand_in(
"keep this\n[…truncated…]\nnote"
));
}

#[test]
fn selection_at_the_replacement_limit_is_kept_whole() {
let live = "字".repeat(SELECTION_MAX_CHARS);
let (stored, omits_middle) = stored_selection_text(&live);
assert!(!omits_middle);
assert_eq!(stored, live);
assert!(selection_text_matches(&stored, Some(&live)));
}

#[test]
fn final_selection_check_requires_the_original_text() {
assert!(selection_text_matches("original", Some("original")));
Expand Down
Loading