fix(windows): 修复剪贴板 sentinel 竞争导致划词追问与划词编辑无法抓取选区 (#1024) (#1025)

* fix(selection-voice): auto-hide capsule when begin fails without selection

Schedule the standard 2s capsule idle timer after no-selection begin errors so the Siri-style prompt does not stay on screen indefinitely.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(selection): log why selection capture missed for voice/polish

Distinguish clipboard sentinel unchanged, empty copy, shortcut failure, and prefetch vs live capture so user-exported logs can diagnose false no-selection reports.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(selection): fix capture diag logging compile errors

Format optional front_app in prefetch miss logs and gate the NoCapturePath fallback to non-desktop targets so macOS/Windows/Linux builds compile cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* debug(selection): add sentinel/post_copy DEBUG logs to diagnose Chrome capture

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(selection): Windows content-change check instead of sentinel for Ctrl+C validation

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(selection): settle clipboard after sentinel for Windows history race

Clipboard History async listeners can hold the clipboard after we write the
sentinel, so target-app Ctrl+C silently fails. Wait for settle, poll, and
optionally resend Ctrl+C. Bump to 1.3.18-Beta.10.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(selection): parenthesize duration cast comparisons for Rust parse

Bare `as u64 <` is parsed as a generic; cfg(windows) code still must parse on Linux CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(selection): handle GetOpenClipboardWindow Result and type key_down

windows 0.58 returns Result<HWND>; annotate VIRTUAL_KEY for GetAsyncKeyState.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(selection): 从 PR 中移除 fork 专用版本号与 release workflow

上游 PR 只保留选区捕获 / 剪贴板 sentinel 修复。版本号仍由上游发布流程管理。

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-09-07 08:24:36 +08:00
1 parent 4c45a938ce
commit 823a6a14cd
3 files changed
+475 -54

No files matched your search

+1
View File
@@ -153,6 +153,7 @@ windows = { version = "0.58", features = [
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Com",
"Win32_System_DataExchange",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
"Win32_System_Ole",
@@ -2,7 +2,7 @@
use std::sync::{Arc, Weak};
use super::{emit_capsule, schedule_capsule_idle, Coordinator, Inner};
use super::{emit_capsule, schedule_capsule_idle, Coordinator, Inner, CAPSULE_AUTO_HIDE_DELAY_MS};
use crate::coordinator_state::SessionId;
use crate::selection::SelectionInsertionTarget;
use crate::types::{CapsuleState, InsertStatus};
@@ -133,6 +133,11 @@ fn emit_selection_voice_begin_error(inner: &Arc<Inner>, error: &str) {
Some(selection_voice_user_message(error)),
None,
);
// 无选区等 begin 失败时胶囊会停在 Error;与听写 Done/Error 同口径,2s 后自动收回。
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
log::info!(
"[selection-voice] begin error capsule shown error={error} auto_hide_ms={CAPSULE_AUTO_HIDE_DELAY_MS}"
);
}
fn emit_selection_voice_end_error(inner: &Arc<Inner>, error: &str) {
@@ -292,9 +297,27 @@ pub(super) async fn handle_selection_voice_released(inner: &Arc<Inner>) {
}
async fn begin_selection_voice_session(inner: &Arc<Inner>) -> Result<(), String> {
let (selection_opt, insertion_target) = crate::selection::resolve_selection_workspace_capture();
let selection = selection_opt.ok_or_else(|| "selectionVoiceNoSelection".to_string())?;
let (selection_opt, insertion_target, capture_diag) =
crate::selection::resolve_selection_workspace_capture_with_diag();
log::info!(
"[selection-voice] begin capture diag={}",
capture_diag.summary()
);
let selection = match selection_opt {
Some(selection) => selection,
None => {
log::warn!(
"[selection-voice] begin failed: selectionVoiceNoSelection ({})",
capture_diag.summary()
);
return Err("selectionVoiceNoSelection".into());
}
};
if !crate::selection::selection_insertion_target_is_captured(&insertion_target) {
log::warn!(
"[selection-voice] begin failed: selectionVoiceTargetUnavailable ({})",
capture_diag.summary()
);
return Err("selectionVoiceTargetUnavailable".into());
}
+448 -51
View File
@@ -106,6 +106,65 @@ struct PrefetchedSelectionWorkspace {
insertion_target: SelectionInsertionTarget,
}
/// 选区抓取失败 / 命中原因,写入用户可导出的 openless.log,便于区分
/// 「真的没选区」vs「模拟复制未覆盖剪贴板」vs「剪贴板 API 失败」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SelectionCaptureMissReason {
Ok,
PrefetchHit,
PrefetchMissLiveOk,
ClipboardInitFailed,
ClipboardSentinelWriteFailed,
CopyShortcutFailed,
ClipboardUnchangedSentinel,
ClipboardEmptyAfterCopy,
ClipboardReadFailed,
EmptyTrimmed,
NoCapturePath,
}
impl SelectionCaptureMissReason {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::PrefetchHit => "prefetch_hit",
Self::PrefetchMissLiveOk => "prefetch_miss_live_ok",
Self::ClipboardInitFailed => "clipboard_init_failed",
Self::ClipboardSentinelWriteFailed => "clipboard_sentinel_write_failed",
Self::CopyShortcutFailed => "copy_shortcut_failed",
Self::ClipboardUnchangedSentinel => "clipboard_unchanged_sentinel",
Self::ClipboardEmptyAfterCopy => "clipboard_empty_after_copy",
Self::ClipboardReadFailed => "clipboard_read_failed",
Self::EmptyTrimmed => "empty_trimmed",
Self::NoCapturePath => "no_capture_path",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SelectionCaptureDiag {
pub reason: SelectionCaptureMissReason,
pub front_app: Option<String>,
pub used_prefetch: bool,
pub target_captured: bool,
pub char_count: Option<usize>,
}
impl SelectionCaptureDiag {
pub(crate) fn summary(&self) -> String {
format!(
"reason={} used_prefetch={} target_captured={} chars={} front_app={}",
self.reason.as_str(),
self.used_prefetch,
self.target_captured,
self.char_count
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into()),
self.front_app.as_deref().unwrap_or("-")
)
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
static PREFETCHED_SELECTION_WORKSPACE: std::sync::Mutex<Option<PrefetchedSelectionWorkspace>> =
std::sync::Mutex::new(None);
@@ -114,7 +173,7 @@ static PREFETCHED_SELECTION_WORKSPACE: std::sync::Mutex<Option<PrefetchedSelecti
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(crate) fn prefetch_selection_workspace_capture() {
let insertion_target = capture_selection_insertion_target();
let capture = capture_selection_with_status();
let (capture, reason) = capture_selection_with_status_diag();
let mut guard = PREFETCHED_SELECTION_WORKSPACE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
@@ -122,8 +181,10 @@ pub(crate) fn prefetch_selection_workspace_capture() {
Some(selection) => {
let chars = selection.text.chars().count();
log::info!(
"[selection] prefetched workspace selection ({} chars)",
chars
"[selection] prefetched workspace selection ({} chars) reason={} front_app={}",
chars,
reason.as_str(),
selection.source_app.as_deref().unwrap_or("-")
);
*guard = Some(PrefetchedSelectionWorkspace {
selection,
@@ -131,7 +192,12 @@ pub(crate) fn prefetch_selection_workspace_capture() {
});
}
None => {
log::info!("[selection] prefetch missed (no selection at hotkey edge)");
log::info!(
"[selection] prefetch missed (no selection at hotkey edge) reason={} front_app={} target_captured={}",
reason.as_str(),
capture_selection_source_app_hint().as_deref().unwrap_or("-"),
selection_insertion_target_is_captured(&insertion_target)
);
guard.take();
}
}
@@ -160,20 +226,92 @@ pub(crate) fn take_prefetched_selection_workspace(
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(crate) fn resolve_selection_workspace_capture(
) -> (Option<SelectionContext>, SelectionInsertionTarget) {
let (selection, target, _) = resolve_selection_workspace_capture_with_diag();
(selection, target)
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(crate) fn resolve_selection_workspace_capture_with_diag() -> (
Option<SelectionContext>,
SelectionInsertionTarget,
SelectionCaptureDiag,
) {
if let Some((selection, insertion_target)) = take_prefetched_selection_workspace() {
return (Some(selection), insertion_target);
let char_count = selection.text.chars().count();
let front_app = selection.source_app.clone();
let target_captured = selection_insertion_target_is_captured(&insertion_target);
let diag = SelectionCaptureDiag {
reason: SelectionCaptureMissReason::PrefetchHit,
front_app,
used_prefetch: true,
target_captured,
char_count: Some(char_count),
};
log::info!("[selection] resolve used prefetch {}", diag.summary());
return (Some(selection), insertion_target, diag);
}
let insertion_target = capture_selection_insertion_target();
let capture = capture_selection_with_status();
(capture.selection, insertion_target)
let (capture, reason) = capture_selection_with_status_diag();
let target_captured = selection_insertion_target_is_captured(&insertion_target);
let (front_app, char_count) = match &capture.selection {
Some(selection) => (
selection.source_app.clone(),
Some(selection.text.chars().count()),
),
None => (capture_selection_source_app_hint(), None),
};
let reason = if capture.selection.is_some() && reason == SelectionCaptureMissReason::Ok {
SelectionCaptureMissReason::PrefetchMissLiveOk
} else {
reason
};
let diag = SelectionCaptureDiag {
reason,
front_app,
used_prefetch: false,
target_captured,
char_count,
};
log::info!("[selection] resolve live capture {}", diag.summary());
(capture.selection, insertion_target, diag)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) fn resolve_selection_workspace_capture(
) -> (Option<SelectionContext>, SelectionInsertionTarget) {
let (selection, target, _) = resolve_selection_workspace_capture_with_diag();
(selection, target)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) fn resolve_selection_workspace_capture_with_diag() -> (
Option<SelectionContext>,
SelectionInsertionTarget,
SelectionCaptureDiag,
) {
let insertion_target = capture_selection_insertion_target();
let capture = capture_selection_with_status();
(capture.selection, insertion_target)
let (capture, reason) = capture_selection_with_status_diag();
let target_captured = selection_insertion_target_is_captured(&insertion_target);
let (front_app, char_count) = match &capture.selection {
Some(selection) => (
selection.source_app.clone(),
Some(selection.text.chars().count()),
),
None => (capture_selection_source_app_hint(), None),
};
let diag = SelectionCaptureDiag {
reason,
front_app,
used_prefetch: false,
target_captured,
char_count,
};
log::info!("[selection] resolve live capture {}", diag.summary());
(capture.selection, insertion_target, diag)
}
fn capture_selection_source_app_hint() -> Option<String> {
current_front_app()
}
/// Snapshot the insertion target before starting an asynchronous Selection
@@ -405,6 +543,10 @@ fn activate_app_by_pid(pid: i32) {
/// 捕获选区。Linux 只通过 fcitx5 DBus 读取 PRIMARY 选区,失败统一视为无选区。
pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
capture_selection_with_status_diag().0
}
fn capture_selection_with_status_diag() -> (SelectionCaptureOutcome, SelectionCaptureMissReason) {
let source_app = current_front_app();
// 1. macOS AX 直读
@@ -420,34 +562,73 @@ pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
};
return (
SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
},
SelectionCaptureMissReason::Ok,
);
}
log::info!(
"[selection] AX read returned empty/whitespace{}",
source_app
.as_deref()
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
}
// 2. 模拟复制 fallback(macOS / Windows)
#[cfg(any(target_os = "macos", target_os = "windows"))]
if let Some(text) = simulate_copy_and_read() {
let trimmed = text.trim();
if !trimmed.is_empty() {
log::info!(
"[selection] simulate-copy fallback OK ({} chars){}",
trimmed.chars().count(),
source_app
.as_deref()
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
};
{
match simulate_copy_and_read_diag() {
Ok(text) => {
let trimmed = text.trim();
if !trimmed.is_empty() {
log::info!(
"[selection] simulate-copy fallback OK ({} chars){}",
trimmed.chars().count(),
source_app
.as_deref()
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return (
SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
},
SelectionCaptureMissReason::Ok,
);
}
log::info!(
"[selection] simulate-copy returned whitespace-only{}",
source_app
.as_deref()
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return (
SelectionCaptureOutcome { selection: None },
SelectionCaptureMissReason::EmptyTrimmed,
);
}
Err(reason) => {
log::info!(
"[selection] simulate-copy miss reason={}{}",
reason.as_str(),
source_app
.as_deref()
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return (SelectionCaptureOutcome { selection: None }, reason);
}
}
}
@@ -464,17 +645,29 @@ pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
};
return (
SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
},
SelectionCaptureMissReason::Ok,
);
}
linux_selection::LinuxSelectionRead::NoSelection => {
return (
SelectionCaptureOutcome { selection: None },
SelectionCaptureMissReason::EmptyTrimmed,
);
}
linux_selection::LinuxSelectionRead::NoSelection => {}
}
SelectionCaptureOutcome { selection: None }
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
(
SelectionCaptureOutcome { selection: None },
SelectionCaptureMissReason::NoCapturePath,
)
}
/// 长度截断到首 + 尾 + 标记。
@@ -493,12 +686,17 @@ fn truncate_selection(text: &str) -> String {
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn simulate_copy_and_read() -> Option<String> {
simulate_copy_and_read_diag().ok()
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn simulate_copy_and_read_diag() -> Result<String, SelectionCaptureMissReason> {
// a) snapshot 当前剪贴板(用作还原原状态的备份)
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
log::warn!("[selection] clipboard init failed: {e}");
return None;
return Err(SelectionCaptureMissReason::ClipboardInitFailed);
}
};
let original = match clipboard.get_text() {
@@ -514,24 +712,40 @@ fn simulate_copy_and_read() -> Option<String> {
let sentinel = format!("__openless_qa_sentinel_{}__", uuid_like_token());
if let Err(e) = clipboard.set_text(sentinel.clone()) {
log::warn!("[selection] clipboard set_text(sentinel) failed: {e}");
// 即使设置 sentinel 失败,也尝试发 Cmd+C 看能不能直接拿到东西
return Err(SelectionCaptureMissReason::ClipboardSentinelWriteFailed);
}
// Windows + Clipboard History: writing the sentinel wakes an async listener that may
// briefly own/open the clipboard. Target-app Ctrl+C then fails silently and leaves
// the sentinel in place (user A/B: history OFF → OK, history ON → fail). Settle first.
#[cfg(target_os = "windows")]
windows_wait_clipboard_settle_after_write();
// c) 模拟 Cmd+C / Ctrl+C
let post_ok = post_copy_shortcut();
let mut post_ok = post_copy_shortcut();
log::info!(
"[selection] post_copy: post_ok={} original_was_some={}",
post_ok,
original.is_some()
);
if !post_ok {
log::warn!("[selection] post_copy_shortcut failed");
// 不立刻 return:剪贴板可能已经被某些路径污染,按下方还原流程恢复。
}
// d) 等剪贴板更新(macOS / Windows 都需要少量时间让目标 app 把数据 put 进去)
std::thread::sleep(Duration::from_millis(80));
// d) 等剪贴板更新;Windows 在开启剪贴板历史时需要轮询 + 必要时重发 Ctrl+C。
#[cfg(not(target_os = "windows"))]
{
std::thread::sleep(Duration::from_millis(80));
}
// e) 读新值
#[cfg(target_os = "windows")]
let captured = windows_poll_clipboard_after_copy(&mut clipboard, &sentinel, &mut post_ok);
#[cfg(not(target_os = "windows"))]
let captured = clipboard.get_text().ok();
// f) 还原原剪贴板
if let Some(prev) = original {
if let Some(ref prev) = original {
if let Err(e) = clipboard.set_text(prev) {
log::warn!("[selection] clipboard restore failed: {e}");
}
@@ -542,11 +756,25 @@ fn simulate_copy_and_read() -> Option<String> {
}
}
let captured = captured?;
if captured == sentinel || captured.is_empty() {
return None;
classify_simulated_copy_result(post_ok, captured, &sentinel)
}
fn classify_simulated_copy_result(
post_ok: bool,
captured: Option<String>,
sentinel: &str,
) -> Result<String, SelectionCaptureMissReason> {
if !post_ok {
return Err(SelectionCaptureMissReason::CopyShortcutFailed);
}
Some(captured)
let captured = captured.ok_or(SelectionCaptureMissReason::ClipboardReadFailed)?;
if captured == sentinel {
return Err(SelectionCaptureMissReason::ClipboardUnchangedSentinel);
}
if captured.is_empty() {
return Err(SelectionCaptureMissReason::ClipboardEmptyAfterCopy);
}
Ok(captured)
}
/// Read the current selection in the same normalized/truncated form stored by
@@ -908,6 +1136,110 @@ mod macos_paste {
// ─────────────────────────── Windows Ctrl+C send ───────────────────────────
/// After we write a sentinel, Clipboard History (and other listeners) may briefly open
/// the clipboard. Wait until nobody holds it and the sequence number stops moving.
#[cfg(target_os = "windows")]
fn windows_wait_clipboard_settle_after_write() {
use windows::Win32::System::DataExchange::{
GetClipboardSequenceNumber, GetOpenClipboardWindow,
};
const MAX_MS: u64 = 250;
const STEP_MS: u64 = 10;
let start = std::time::Instant::now();
let mut last_seq = unsafe { GetClipboardSequenceNumber() };
let mut stable_rounds = 0u32;
let mut open_cleared = false;
let mut seq_stable = false;
while (start.elapsed().as_millis() as u64) < MAX_MS {
let open_is_free = match unsafe { GetOpenClipboardWindow() } {
Ok(hwnd) => hwnd.0.is_null(),
Err(_) => true,
};
let seq = unsafe { GetClipboardSequenceNumber() };
if open_is_free {
open_cleared = true;
if seq == last_seq {
stable_rounds += 1;
// ~30ms of unchanged seq with clipboard free → history likely caught up.
if stable_rounds >= 3 {
seq_stable = true;
break;
}
} else {
stable_rounds = 0;
last_seq = seq;
}
} else {
open_cleared = false;
stable_rounds = 0;
last_seq = seq;
}
std::thread::sleep(Duration::from_millis(STEP_MS));
}
let waited_ms = start.elapsed().as_millis() as u64;
log::info!(
"[selection] clipboard_settle: waited_ms={} open_cleared={} seq_stable={}",
waited_ms,
open_cleared,
seq_stable
);
}
/// Poll until clipboard leaves the sentinel (selection copy landed). One Ctrl+C resend
/// mid-window covers the case where the first chord hit a still-busy clipboard.
#[cfg(target_os = "windows")]
fn windows_poll_clipboard_after_copy(
clipboard: &mut arboard::Clipboard,
sentinel: &str,
post_ok: &mut bool,
) -> Option<String> {
const MAX_MS: u64 = 450;
const STEP_MS: u64 = 25;
let start = std::time::Instant::now();
let mut attempts = 0u32;
let mut resent_ctrl_c = false;
let mut captured = None;
while (start.elapsed().as_millis() as u64) < MAX_MS {
attempts += 1;
match clipboard.get_text() {
Ok(text) if text != sentinel => {
captured = Some(text);
break;
}
Ok(text) => {
captured = Some(text);
}
Err(_) => {
captured = None;
}
}
// Halfway through: if still on sentinel, resend Ctrl+C once.
if !resent_ctrl_c && (start.elapsed().as_millis() as u64) >= 150 {
if post_copy_shortcut() {
*post_ok = true;
resent_ctrl_c = true;
log::info!("[selection] clipboard_poll: resent Ctrl+C after settle miss");
}
}
std::thread::sleep(Duration::from_millis(STEP_MS));
}
let waited_ms = start.elapsed().as_millis() as u64;
log::info!(
"[selection] clipboard_poll: waited_ms={} attempts={} resent={} still_sentinel={}",
waited_ms,
attempts,
resent_ctrl_c,
captured.as_deref() == Some(sentinel)
);
captured
}
#[cfg(target_os = "windows")]
mod windows_paste {
use windows::Win32::UI::Input::KeyboardAndMouse::{
@@ -1142,6 +1474,71 @@ mod tests {
);
}
#[test]
fn simulated_copy_result_classifies_failures_and_multibyte_text() {
let sentinel = "__openless_test_sentinel__";
for (post_ok, captured, expected) in [
(
false,
Some("stale clipboard".to_string()),
Err(SelectionCaptureMissReason::CopyShortcutFailed),
),
(
true,
None,
Err(SelectionCaptureMissReason::ClipboardReadFailed),
),
(
true,
Some(sentinel.to_string()),
Err(SelectionCaptureMissReason::ClipboardUnchangedSentinel),
),
(
true,
Some(String::new()),
Err(SelectionCaptureMissReason::ClipboardEmptyAfterCopy),
),
(
true,
Some("selected".to_string()),
Ok("selected".to_string()),
),
(
true,
Some("中文选区".to_string()),
Ok("中文选区".to_string()),
),
] {
assert_eq!(
classify_simulated_copy_result(post_ok, captured, sentinel),
expected
);
}
assert_eq!(
SelectionCaptureMissReason::ClipboardSentinelWriteFailed.as_str(),
"clipboard_sentinel_write_failed"
);
}
#[test]
fn capture_diag_summary_contains_metadata_only() {
let selected_text = "不得写入日志的中文选区";
let summary = SelectionCaptureDiag {
reason: SelectionCaptureMissReason::PrefetchHit,
front_app: Some("Editor".to_string()),
used_prefetch: true,
target_captured: true,
char_count: Some(selected_text.chars().count()),
}
.summary();
assert_eq!(
summary,
"reason=prefetch_hit used_prefetch=true target_captured=true chars=11 front_app=Editor"
);
assert!(!summary.contains(selected_text));
}
#[cfg(target_os = "windows")]
fn windows_target(seed: usize) -> WindowsSelectionTarget {
WindowsSelectionTarget {