diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 2b7617cb..6d6f930e 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -149,6 +149,7 @@ windows = { version = "0.58", features = [ "Win32_Media_Audio_Endpoints", "Win32_Storage_FileSystem", "Win32_System_Com", + "Win32_System_DataExchange", "Win32_System_Ole", "Win32_System_Registry", "Win32_System_Threading", diff --git a/openless-all/app/src-tauri/src/coordinator/selection_polish.rs b/openless-all/app/src-tauri/src/coordinator/selection_polish.rs index df8bf1ec..80bc9d46 100644 --- a/openless-all/app/src-tauri/src/coordinator/selection_polish.rs +++ b/openless-all/app/src-tauri/src/coordinator/selection_polish.rs @@ -145,9 +145,14 @@ pub(super) async fn run_selection_polish(inner: &Arc) -> Result<(), Strin // final check below deliberately does *not* restore this target: a user who // changed windows made an intentional context switch, so the safe behavior // is to leave both apps untouched. - let (selection_opt, insertion_target) = crate::selection::resolve_selection_workspace_capture(); + let (selection_opt, insertion_target, capture_diag) = + crate::selection::resolve_selection_workspace_capture_with_diag(); if selection_polish_plan(selection_opt.as_ref()) == SelectionPolishPlan::NoSelection { let code = "selectionPolishNoSelection"; + log::warn!( + "[selection-polish] no selection ({})", + capture_diag.summary() + ); finish_selection_polish_capsule( inner, CapsuleState::Cancelled, diff --git a/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs b/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs index 4db36c85..43c15bbb 100644 --- a/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs @@ -14,6 +14,7 @@ use super::{ answer_qa_question_text, capture_external_focus_target, close_qa_panel, emit_capsule, open_qa_panel, polish_text, qa_event_target, qa_session, restore_focus_target_if_possible, schedule_capsule_idle, translate_text, CapsuleFeedback, Coordinator, Inner, QaPhase, + CAPSULE_AUTO_HIDE_DELAY_MS, }; use crate::coordinator_state::{initial_session_id, new_session_id, SessionId}; use crate::edit_plan::{apply_edit_plan, parse_edit_plan, EditOperation, EditPlan}; @@ -64,6 +65,11 @@ fn emit_selection_voice_begin_error(inner: &Arc, 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, error: &str) { @@ -388,9 +394,27 @@ async fn begin_selection_voice_session(inner: &Arc) -> Result<(), String> return Err("selectionVoiceBusy".into()); } - 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()); } diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 548e256e..46c423f2 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -106,6 +106,63 @@ struct PrefetchedSelectionWorkspace { insertion_target: SelectionInsertionTarget, } +/// 选区抓取失败 / 命中原因,写入用户可导出的 openless.log,便于区分 +/// 「真的没选区」vs「模拟复制未覆盖剪贴板」vs「剪贴板 API 失败」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SelectionCaptureMissReason { + Ok, + PrefetchHit, + PrefetchMissLiveOk, + ClipboardInitFailed, + 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::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, + pub used_prefetch: bool, + pub target_captured: bool, + pub char_count: Option, +} + +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> = std::sync::Mutex::new(None); @@ -114,7 +171,7 @@ static PREFETCHED_SELECTION_WORKSPACE: std::sync::Mutex { 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 +190,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 +224,94 @@ pub(crate) fn take_prefetched_selection_workspace( #[cfg(any(target_os = "macos", target_os = "windows"))] pub(crate) fn resolve_selection_workspace_capture( ) -> (Option, 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, + 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, 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, + 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 { + current_front_app() } /// Snapshot the insertion target before starting an asynchronous Selection @@ -396,6 +534,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 直读 @@ -411,34 +553,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); + } } } @@ -455,17 +636,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, + ) } /// 长度截断到首 + 尾 + 标记。 @@ -484,12 +677,17 @@ fn truncate_selection(text: &str) -> String { #[cfg(any(target_os = "macos", target_os = "windows"))] fn simulate_copy_and_read() -> Option { + simulate_copy_and_read_diag().ok() +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn simulate_copy_and_read_diag() -> Result { // 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() { @@ -508,21 +706,79 @@ fn simulate_copy_and_read() -> Option { // 即使设置 sentinel 失败,也尝试发 Cmd+C 看能不能直接拿到东西 } + // 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")] + let settle = windows_wait_clipboard_settle_after_write(); + // c) 模拟 Cmd+C / Ctrl+C - let post_ok = post_copy_shortcut(); + #[cfg(target_os = "windows")] + let pre_copy_env = windows_copy_env_snapshot("pre_send"); + let mut post_ok = post_copy_shortcut(); + log::info!( + "[selection] DEBUG 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, copy_poll) = { + let poll = windows_poll_clipboard_after_copy(&mut clipboard, &sentinel, &mut post_ok); + (poll.captured.clone(), poll) + }; + #[cfg(not(target_os = "windows"))] let captured = clipboard.get_text().ok(); + #[cfg(target_os = "windows")] + let post_wait_env = windows_copy_env_snapshot("post_wait"); + + // #region agent log + #[cfg(target_os = "windows")] + { + let seq_delta = post_wait_env + .clipboard_seq + .zip(pre_copy_env.clipboard_seq) + .map(|(after, before)| after.wrapping_sub(before)); + let payload = serde_json::json!({ + "sessionId": "ddfc8d", + "runId": "post-fix", + "hypothesisId": "A", + "location": "selection.rs:simulate_copy_and_read_diag", + "message": "windows copy after clipboard-history settle", + "data": { + "post_ok": post_ok, + "captured_is_sentinel": captured.as_ref() == Some(&sentinel), + "captured_len": captured.as_ref().map(|s| s.len()), + "seq_delta": seq_delta, + "settle_ms": settle.waited_ms, + "settle_open_cleared": settle.open_cleared, + "settle_seq_stable": settle.seq_stable, + "poll_ms": copy_poll.waited_ms, + "poll_attempts": copy_poll.attempts, + "resent_ctrl_c": copy_poll.resent_ctrl_c, + "pre": pre_copy_env, + "post_wait": post_wait_env, + }, + "timestamp": chrono::Utc::now().timestamp_millis(), + }); + log::info!("[selection] DEBUG agent_env {}", payload); + agent_debug_ndjson(&payload); + } + // #endregion + // 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}"); } @@ -533,11 +789,63 @@ fn simulate_copy_and_read() -> Option { } } - let captured = captured?; - if captured == sentinel || captured.is_empty() { - return None; + if !post_ok { + return Err(SelectionCaptureMissReason::CopyShortcutFailed); + } + let Some(captured) = captured else { + return Err(SelectionCaptureMissReason::ClipboardReadFailed); + }; + + #[cfg(target_os = "macos")] + { + // macOS:无法确认 Ctrl+C 是否真的生效(Sentinel 方案)。 + // captured == sentinel → 应用没写剪贴板(没选区 / Ctrl+C 未生效) + // captured != sentinel → 应用写了剪贴板 + if captured == sentinel { + log::info!( + "[selection] DEBUG sentinel_check: sentinel_eq_captured=true sentinel_prefix='{}' captured_len={} original_was_some={}", + &sentinel[..32.min(sentinel.len())], + captured.len(), + original.is_some() + ); + return Err(SelectionCaptureMissReason::ClipboardUnchangedSentinel); + } + log::info!( + "[selection] DEBUG sentinel_check: sentinel_eq_captured=false sentinel_prefix='{}' captured_len={} captured_preview='{}'", + &sentinel[..32.min(sentinel.len())], + captured.len(), + &captured[..captured.len().min(50)] + ); + } + + #[cfg(target_os = "windows")] + { + // Runtime evidence (Beta.9 / ChatGPT+Notepad+Chrome): when Ctrl+C is ignored, + // captured stays equal to sentinel. Content-vs-original checks do not recover that + // case; keep the same sentinel gate as macOS and rely on agent_env probes instead. + if captured == sentinel { + log::info!( + "[selection] DEBUG sentinel_check: sentinel_eq_captured=true original_was_some={}", + original.is_some() + ); + return Err(SelectionCaptureMissReason::ClipboardUnchangedSentinel); + } + log::info!( + "[selection] DEBUG sentinel_check: sentinel_eq_captured=false captured_len={}", + captured.len() + ); } - Some(captured) + + #[cfg(target_os = "linux")] + { + // Linux 不走此函数 + unreachable!(); + } + + if captured.is_empty() { + return Err(SelectionCaptureMissReason::ClipboardEmptyAfterCopy); + } + Ok(captured) } /// Read the current selection in the same normalized/truncated form stored by @@ -899,6 +1207,239 @@ mod macos_paste { // ─────────────────────────── Windows Ctrl+C send ─────────────────────────── +#[cfg(target_os = "windows")] +struct WindowsClipboardSettle { + waited_ms: u64, + open_cleared: bool, + seq_stable: bool, +} + +#[cfg(target_os = "windows")] +struct WindowsCopyPollResult { + captured: Option, + waited_ms: u64, + attempts: u32, + resent_ctrl_c: bool, +} + +/// 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() -> WindowsClipboardSettle { + 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] DEBUG clipboard_settle: waited_ms={} open_cleared={} seq_stable={}", + waited_ms, + open_cleared, + seq_stable + ); + WindowsClipboardSettle { + 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, +) -> WindowsCopyPollResult { + 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] DEBUG 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] DEBUG clipboard_poll: waited_ms={} attempts={} resent={} still_sentinel={}", + waited_ms, + attempts, + resent_ctrl_c, + captured.as_deref() == Some(sentinel) + ); + WindowsCopyPollResult { + captured, + waited_ms, + attempts, + resent_ctrl_c, + } +} + +#[cfg(target_os = "windows")] +#[derive(Debug, serde::Serialize)] +struct WindowsCopyEnvSnapshot { + phase: &'static str, + ctrl_down: bool, + alt_down: bool, + shift_down: bool, + win_down: bool, + fg_hwnd: usize, + focus_hwnd: usize, + fg_class: String, + focus_class: String, + clipboard_seq: Option, + open_clipboard_hwnd: usize, +} + +#[cfg(target_os = "windows")] +fn windows_copy_env_snapshot(phase: &'static str) -> WindowsCopyEnvSnapshot { + use windows::Win32::System::DataExchange::{GetClipboardSequenceNumber, GetOpenClipboardWindow}; + use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetAsyncKeyState, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN, VK_SHIFT, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + GetClassNameW, GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, + GUITHREADINFO, + }; + + unsafe { + use windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY; + let key_down = |vk: VIRTUAL_KEY| ((GetAsyncKeyState(vk.0 as i32) as u16) & 0x8000) != 0; + let ctrl_down = key_down(VK_CONTROL); + let alt_down = key_down(VK_MENU); + let shift_down = key_down(VK_SHIFT); + let win_down = key_down(VK_LWIN) || key_down(VK_RWIN); + + let foreground = GetForegroundWindow(); + let mut gui_info = GUITHREADINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let focus = if !foreground.0.is_null() { + let tid = GetWindowThreadProcessId(foreground, None); + if tid != 0 && GetGUIThreadInfo(tid, &mut gui_info).is_ok() && !gui_info.hwndFocus.0.is_null() + { + gui_info.hwndFocus + } else { + foreground + } + } else { + foreground + }; + + let class_of = |hwnd: windows::Win32::Foundation::HWND| -> String { + if hwnd.0.is_null() { + return String::new(); + } + let mut buf = [0u16; 256]; + let n = GetClassNameW(hwnd, &mut buf); + if n <= 0 { + return String::new(); + } + String::from_utf16_lossy(&buf[..n as usize]) + }; + + let open_clipboard_hwnd = GetOpenClipboardWindow() + .ok() + .map(|hwnd| hwnd.0 as usize) + .unwrap_or(0); + + WindowsCopyEnvSnapshot { + phase, + ctrl_down, + alt_down, + shift_down, + win_down, + fg_hwnd: foreground.0 as usize, + focus_hwnd: focus.0 as usize, + fg_class: class_of(foreground), + focus_class: class_of(focus), + clipboard_seq: Some(GetClipboardSequenceNumber()), + open_clipboard_hwnd, + } + } +} + +// #region agent log +#[cfg(target_os = "windows")] +fn agent_debug_ndjson(payload: &serde_json::Value) { + use std::io::Write; + let line = payload.to_string(); + for path in [ + std::path::Path::new("debug-ddfc8d.log"), + std::path::Path::new(r"f:\编程\openless-agent-eval-960\debug-ddfc8d.log"), + ] { + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(f, "{line}"); + break; + } + } +} +// #endregion + #[cfg(target_os = "windows")] mod windows_paste { use windows::Win32::UI::Input::KeyboardAndMouse::{