diff --git a/openless-all/app/crates/openless-core/src/prompts.rs b/openless-all/app/crates/openless-core/src/prompts.rs index 6a04abb6..0d2ba1f0 100644 --- a/openless-all/app/crates/openless-core/src/prompts.rs +++ b/openless-all/app/crates/openless-core/src/prompts.rs @@ -128,6 +128,22 @@ pub fn polish_injection_defense() -> &'static str { /// /// The instruction is executable user intent, but it cannot redefine the /// system contract or turn the selected text into another instruction source. +/// 圈選潤色的 user message:選區專用框架(`` 信封)。 +/// +/// 蜘蛛故事事故(2026-09-11/12):圈選路徑曾複用 `user_prompt`(語音輸入框架—— +/// 「语音输入的原始转写 / 当前 mode 的任务描述 / 插入到光标位置」),小模型把整套 +/// 語音脚手架照抄進輸出。選區沒有「語音輸入」「mode」「游標」,必須用選區框架。 +pub fn selection_user_prompt(selected_text: &str) -> String { + let escaped = sanitize_for_xml_envelope(selected_text, "selected_text"); + format!( + "下面是用户选中的文本。请按 system prompt 中的任务要求处理这段文本,\ + 输出处理后的正文,它会被原样替换选区。\n\n\ + \n{}\n\n\n\ + 只输出处理后的文本正文。", + escaped + ) +} + pub fn selection_instruction_block(instruction: &str) -> Option { let instruction = instruction.trim(); if instruction.is_empty() { diff --git a/openless-all/app/crates/openless-core/src/selection_service.rs b/openless-all/app/crates/openless-core/src/selection_service.rs index afe48c29..cf5d86ab 100644 --- a/openless-all/app/crates/openless-core/src/selection_service.rs +++ b/openless-all/app/crates/openless-core/src/selection_service.rs @@ -479,8 +479,16 @@ impl SelectionServiceInner { fn fail_if_active(&self, session_id: SessionId) -> bool { let mut state = self.state.write().expect("selection state lock poisoned"); + // 只对「还在进行中」的 session 结算:Cancelled 是用户主动结束, + // Completed 是已粘贴成功(race:complete 与 fail 判断之间的窄窗口, + // 若误标 Failed 会把成功状态覆盖掉)。 if state.snapshot.session_id == Some(session_id) - && !matches!(state.snapshot.phase, SelectionPhase::Cancelled) + && matches!( + state.snapshot.phase, + SelectionPhase::Capturing + | SelectionPhase::Preview + | SelectionPhase::Applying + ) { state.snapshot.phase = SelectionPhase::Failed; let snapshot = state.snapshot.clone(); @@ -495,6 +503,38 @@ impl SelectionServiceInner { } } + /// confirm 失败结算:session 已失效(stale / 目标变更 / 并发占用)结算为 + /// Failed 并隐藏预览;瞬时的平台错误(焦点恢复 / 目标复核抖动)回退到 + /// Preview 保持可重试——直接失败掉会让预览窗被隐藏、编辑内容丢失, + /// 用户看到的只是「点确认没反应」。 + fn settle_confirm_failure(&self, session_id: SessionId, error: &BackendError) -> bool { + let settled = matches!( + error.code, + BackendErrorCode::Cancelled + | BackendErrorCode::InvalidState + | BackendErrorCode::InvalidArgument + | BackendErrorCode::Busy + ); + let mut state = self.state.write().expect("selection state lock poisoned"); + let active = state.snapshot.session_id == Some(session_id) + && !matches!(state.snapshot.phase, SelectionPhase::Cancelled); + if !active { + return false; + } + state.snapshot.phase = if settled { + SelectionPhase::Failed + } else { + SelectionPhase::Preview + }; + let snapshot = state.snapshot.clone(); + drop(state); + self.events.publish( + Some(session_id), + BackendEventKind::SelectionStateChanged(snapshot), + ); + settled + } + fn begin_revert(&self, session_id: SessionId) -> Result<(), BackendError> { let mut state = self.state.write().expect("selection state lock poisoned"); if state.snapshot.session_id != Some(session_id) { @@ -575,7 +615,13 @@ impl SelectionApi for SelectionService { inner.set_context(session_id, Arc::clone(&context))?; let (output, polish_ms) = if uses_llm { let polish_started = std::time::Instant::now(); - let output = inner + // C 案:圈選潤色此前漏接簡繁偏好(語音輸入路徑在 finish 時已套用 + // apply_chinese_script_preference)。這裡對齊——LLM 輸出依用戶 + // 設定做確定性簡繁轉換,與 prompt 無關,避免小模型簡體漂移直接 + // 進預覽/替換。非 LLM 分支只回顯原始選區,不轉換。 + // `context` 稍後被 move 進 polish(),先把 Copy 的偏好抓成局部。 + let script_pref = context.polish.chinese_script_preference; + let mut output = inner .polisher .polish( session_id, @@ -584,6 +630,35 @@ impl SelectionApi for SelectionService { Arc::new(DiscardTextStreamSink), ) .await?; + // 脚手架剥离(2026-09-11 蜘蛛故事事故):小模型间歇性把 user + // message 的模板句与 信封连同正文一起回显。 + // prompt 层禁令对 35B 小模型只有部分效果,这里做确定性后处理 + // (模型无关):活标签必然来自回显——用户正文进 LLM 前标签已被 + // sanitize 中和,正规输出不可能含活标签,取标签内正文零误伤。 + let before_strip = output.text.clone(); + let stripped = + crate::streaming_insert::strip_echoed_scaffolding(&output.text); + if stripped != before_strip { + log::info!( + "[selection-polish] stripped echoed scaffolding: {} -> {} chars", + before_strip.chars().count(), + stripped.chars().count() + ); + output.text = stripped; + } + let before = output.text.clone(); + output.text = crate::streaming_insert::apply_chinese_script_preference( + &output.text, + script_pref, + ); + if output.text != before { + log::info!( + "[selection-polish] script preference applied: {:?} {} -> {} chars", + script_pref, + before.chars().count(), + output.text.chars().count(), + ); + } ( output, Some( @@ -650,7 +725,10 @@ impl SelectionApi for SelectionService { Ok(()) } Err(error) => { - if inner.fail_if_active(session_id) { + // 分流:session 已失效(stale / 并发 confirm)必须结算;瞬时的 + // 平台错误(焦点恢复 / 目标复核抖动)保持 preview 可重试—— + // 否则窗口被隐藏、busy 卡死,表现为「点确认没反应」。 + if inner.settle_confirm_failure(session_id, &error) { let _ = inner.polisher.cancel(session_id).await; let _ = inner.runtime.cancel(session_id).await; inner.hide_preview(); diff --git a/openless-all/app/crates/openless-core/src/streaming_insert.rs b/openless-all/app/crates/openless-core/src/streaming_insert.rs index c6979b12..11fc17bb 100644 --- a/openless-all/app/crates/openless-core/src/streaming_insert.rs +++ b/openless-all/app/crates/openless-core/src/streaming_insert.rs @@ -135,6 +135,69 @@ pub fn apply_chinese_script_preference(text: &str, preference: ChineseScriptPref .map_or_else(|| text.to_string(), |converter| converter.convert(text)) } +/// 剝離模型完整回顯的 user-message 腳手架。 +/// +/// 以真正的 prompt builder 產生 canonical 模板,再比對標籤前後的完整內容;不手抄 +/// 導語,避免 prompt 改字後判定漂移。另接受整套模板被簡繁轉換後的版本。只有完整 +/// 模板命中才取信封正文;單純 XML、文件範例或前後另有正文一律保留原輸出。 +fn strip_complete_template(text: &str, template: &str, open: &str, close: &str) -> Option { + let template_open = template.find(open)?; + let template_inner_start = template_open + open.len(); + let template_close = template[template_inner_start..].find(close)? + template_inner_start; + let expected_before = normalize_scaffold_prose(template[..template_open].trim()); + let expected_after = normalize_scaffold_prose(template[template_close + close.len()..].trim()); + + let trimmed = text.trim(); + let open_pos = trimmed.find(open)?; + let inner_start = open_pos + open.len(); + let inner_end = trimmed[inner_start..].find(close)? + inner_start; + let before = normalize_scaffold_prose(trimmed[..open_pos].trim()); + let after = normalize_scaffold_prose(trimmed[inner_end + close.len()..].trim()); + if before != expected_before || after != expected_after { + return None; + } + let inner = trimmed[inner_start..inner_end].trim(); + (!inner.is_empty()).then(|| inner.to_string()) +} + +fn normalize_scaffold_prose(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +pub fn strip_echoed_scaffolding(text: &str) -> String { + const MARKER: &str = "__OPENLESS_SCAFFOLD_BODY__"; + let raw = crate::prompts::user_prompt(MARKER); + let selection = crate::prompts::selection_user_prompt(MARKER); + let raw_traditional = apply_chinese_script_preference( + &raw, + ChineseScriptPreference::Traditional, + ); + let selection_traditional = apply_chinese_script_preference( + &selection, + ChineseScriptPreference::Traditional, + ); + + let stripped = [raw.as_str(), raw_traditional.as_str()] + .into_iter() + .find_map(|template| { + strip_complete_template(text, template, "", "") + }) + .or_else(|| { + [selection.as_str(), selection_traditional.as_str()] + .into_iter() + .find_map(|template| { + strip_complete_template( + text, + template, + "", + "", + ) + }) + }) + .unwrap_or_else(|| text.to_string()); + stripped +} + pub fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> usize { let prefix: String = delta.chars().take(typed_chars).collect(); let count = prefix.chars().count(); @@ -155,6 +218,66 @@ pub fn streaming_insert_eligible( #[cfg(test)] mod tests { + #[test] + fn strip_echoed_scaffolding_none_passes_through() { + assert_eq!(strip_echoed_scaffolding("純正文,沒有標籤。"), "純正文,沒有標籤。"); + } + + #[test] + fn strip_echoed_scaffolding_unescaped_user_content_not_touched() { + // 用戶正文裡的標籤經 sanitize 後是 <raw_transcript,不是活標籤——不得剝。 + let text = "文中提到 <raw_transcript 的用法。"; + assert_eq!(strip_echoed_scaffolding(text), text); + } + + #[test] + fn strip_echoed_scaffolding_drops_echoed_template_and_tags() { + // 蜘蛛故事事故形態:模型照抄整套 raw user prompt,並被轉成繁體。 + let text = crate::prompts::user_prompt("從前,有一隻蜘蛛。"); + let text = apply_chinese_script_preference(&text, ChineseScriptPreference::Traditional); + assert_eq!(strip_echoed_scaffolding(&text), "從前,有一隻蜘蛛。"); + } + + #[test] + fn strip_echoed_scaffolding_empty_inner_keeps_original() { + // 標籤內為空(模型只回顯了空信封)——保守不剝,避免把正文吃掉。 + let text = "脚手架\n\n\n正文在這裡。"; + assert_eq!(strip_echoed_scaffolding(text), text); + } + + #[test] + fn strip_echoed_scaffolding_missing_close_keeps_original() { + // 流被截斷、只回顯了開標籤——保守不剝。 + let text = "\n只有開標籤,流斷了。"; + assert_eq!(strip_echoed_scaffolding(text), text); + } + + #[test] + fn strip_echoed_scaffolding_legitimate_xml_is_not_touched() { + let text = "請輸出以下 XML 範例:保留我,並補充說明。"; + assert_eq!(strip_echoed_scaffolding(text), text); + } + + #[test] + fn strip_echoed_scaffolding_selected_text_echo_stripped() { + // 圈選路徑:模型完整回顯真實 selection user prompt。 + let text = crate::prompts::selection_user_prompt("从前,有一只蜘蛛。"); + assert_eq!(strip_echoed_scaffolding(&text), "从前,有一只蜘蛛。"); + } + + #[test] + fn strip_echoed_scaffolding_selected_text_empty_inner_keeps_original() { + let text = "脚手架\n\n\n正文在这里。"; + assert_eq!(strip_echoed_scaffolding(text), text); + } + + #[test] + fn strip_echoed_scaffolding_escaped_selected_text_not_touched() { + // 用戶正文裡的 經 sanitize 後是 <selected_text,不是活標籤。 + let text = "文中提到 <selected_text 的用法。"; + assert_eq!(strip_echoed_scaffolding(text), text); + } + use super::*; use crate::shared_types::MacosNewlineMode; diff --git a/openless-all/app/crates/openless-core/tests/selection_contract.rs b/openless-all/app/crates/openless-core/tests/selection_contract.rs index 0ab021b8..c0834e5b 100644 --- a/openless-all/app/crates/openless-core/tests/selection_contract.rs +++ b/openless-all/app/crates/openless-core/tests/selection_contract.rs @@ -26,7 +26,7 @@ struct RecordingSelectionRuntime { capture: SelectionCapture, applied: Arc>>, apply_outcome: InsertOutcome, - apply_error: Option, + apply_error: Arc>>, apply_gate: Option<(Arc, Arc)>, reverted: Arc>>, revert_outcome: Option, @@ -42,7 +42,7 @@ impl RecordingSelectionRuntime { }, applied: Arc::new(Mutex::new(Vec::new())), apply_outcome: InsertOutcome::Inserted, - apply_error: None, + apply_error: Arc::new(Mutex::new(None)), apply_gate: None, reverted: Arc::new(Mutex::new(Vec::new())), revert_outcome: None, @@ -50,11 +50,15 @@ impl RecordingSelectionRuntime { } } - fn with_apply_error(mut self, error: BackendError) -> Self { - self.apply_error = Some(error); + fn with_apply_error(self, error: BackendError) -> Self { + *self.apply_error.lock().expect("apply error lock poisoned") = Some(error); self } + fn release_apply_error(&self) { + *self.apply_error.lock().expect("apply error lock poisoned") = None; + } + fn with_revert_outcome(mut self, outcome: InsertOutcome) -> Self { self.revert_outcome = Some(outcome); self @@ -96,10 +100,14 @@ impl SelectionRuntimeAdapter for RecordingSelectionRuntime { ) -> BoxFuture<'static, Result> { let applied = Arc::clone(&self.applied); let outcome = self.apply_outcome; - let error = self.apply_error.clone(); + let error_slot = Arc::clone(&self.apply_error); let gate = self.apply_gate.clone(); Box::pin(async move { - if let Some(error) = error { + if let Some(error) = error_slot + .lock() + .expect("apply error lock poisoned") + .clone() + { return Err(error); } applied.lock().expect("runtime lock poisoned").push(( @@ -633,7 +641,7 @@ async fn shutdown_cancels_an_active_selection_and_hides_its_preview() { } #[tokio::test] -async fn failed_preview_apply_hides_the_preview_and_releases_the_target() { +async fn transient_platform_failure_keeps_the_preview_retryable() { let runtime = RecordingSelectionRuntime::new("source text").with_apply_error( BackendError::new(BackendErrorCode::Platform, "fixture apply failed"), ); @@ -668,8 +676,87 @@ async fn failed_preview_apply_hides_the_preview_and_releases_the_target() { .await .expect_err("platform failure must be returned"); + // 瞬时平台错误(焦点恢复/目标复核抖动):错误返回、预览窗保持、 + // session 回到 Preview 可直接重试——不能隐藏窗口把用户晾在「点了没反应」。 assert_eq!(error.code, BackendErrorCode::Platform); - assert_eq!(runtime.cancel_count(), 1); + assert_eq!(runtime.cancel_count(), 0); + assert_eq!(host.actions(), vec![HostAction::ShowSelectionPreview]); + assert_eq!( + backend + .services() + .selection + .snapshot() + .await + .expect("selection snapshot should remain readable") + .phase, + SelectionPhase::Preview + ); + + // 目标重新可用时重试应成功完成。 + runtime.release_apply_error(); + backend + .services() + .selection + .confirm(session_id, None) + .await + .expect("retry after a transient platform failure should apply"); + assert_eq!( + backend + .services() + .selection + .snapshot() + .await + .expect("selection snapshot should remain readable") + .phase, + SelectionPhase::Completed + ); + + backend.shutdown().await.expect("backend should stop"); + let _ = std::fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn stale_preview_apply_settles_the_session_and_hides_the_preview() { + // apply 报「目标已失效」类错误(Cancelled 语义)时 session 必须结算, + // 预览隐藏、不允许无限重试一个已经不存在的目标。 + let runtime = RecordingSelectionRuntime::new("source text").with_apply_error( + BackendError::new( + BackendErrorCode::Cancelled, + "selection target is no longer active", + ), + ); + let host = openless_core::testing::RecordingHostActions::default(); + let (backend, data_dir) = backend_with_selection_parts_and_host( + runtime.clone(), + Arc::new(openless_core::testing::FixtureTextPolisher::successful( + "polished preview", + )), + Arc::new(UnsupportedCredentialStore), + Arc::new(host.clone()), + ); + backend.start().await.expect("backend should start"); + let mut preferences = backend.get_preferences(); + preferences.selection_polish_output_mode = SelectionPolishOutputMode::PreviewConfirm; + write_preferences(&backend, preferences); + let session_id = backend + .services() + .selection + .begin_polish(SelectionPolishRequest { + selected_text: None, + mode: PolishMode::Light, + instruction: None, + }) + .await + .expect("selection polish should produce a preview"); + + let error = backend + .services() + .selection + .confirm(session_id, None) + .await + .expect_err("stale target must be returned"); + + assert_eq!(error.code, BackendErrorCode::Cancelled); assert_eq!( host.actions(), vec![ diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 7c17e814..9972ef01 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -1055,6 +1055,18 @@ impl SelectionPlatformBridge for NativeSelectionPlatformBridge { replacement_text: &str, reactivate: bool, ) -> Result { + #[cfg(target_os = "macos")] + if reactivate { + let app = self.app.lock().clone().ok_or_else(|| { + BackendError::new(BackendErrorCode::InvalidState, "Tauri AppHandle is not bound yet") + })?; + if !crate::resign_selection_polish_preview_key_for_apply(&app) { + return Err(BackendError::new( + BackendErrorCode::Platform, + "selectionPolishTargetUnavailable", + )); + } + } if reactivate && !crate::selection::reactivate_selection_insertion_target(target) { return Err(BackendError::new( BackendErrorCode::Platform, @@ -1075,6 +1087,16 @@ impl SelectionPlatformBridge for NativeSelectionPlatformBridge { }; return Err(BackendError::new(error_code, code)); } + // 贴上前一刻的最终防线:validate 的 simulate_copy 兜底期间前台焦点 + // 可能跳走(对方恰好暴露相同文本时文本比对会放行),这里再核一次 + // 捕获时的前台应用是否仍是前台,不是就拒绝。 + #[cfg(target_os = "macos")] + if !crate::selection::selection_target_still_front(target) { + return Err(BackendError::new( + BackendErrorCode::Cancelled, + "selectionPolishTargetChanged", + )); + } let preferences = self.preferences()?; map_insert_status(crate::insertion::TextInserter::new().insert( replacement_text, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 55f88f3f..26d5c799 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -2813,58 +2813,414 @@ pub(crate) fn hide_qa_window(app: &AppHandle) { /// 选区润色预览是独立、可编辑的小窗:模型结果不会直接覆盖,用户确认后才回到原选区粘贴。 #[cfg(not(any(target_os = "android", target_os = "ios")))] fn ensure_selection_polish_preview_window( - app: &AppHandle, + app: &tauri::AppHandle, ) -> Option> { if let Some(window) = app.get_webview_window("selection-polish-preview") { return Some(window); } - WebviewWindowBuilder::new( + let built = WebviewWindowBuilder::new( app, "selection-polish-preview", WebviewUrl::App("index.html?window=selection-polish-preview".into()), ) - .title("OpenLess 选区润色预览") + .title("OpenLess 選區潤色預覽") .inner_size(640.0, 440.0) .min_inner_size(480.0, 320.0) .resizable(true) .always_on_top(true) + .skip_taskbar(true) + .focused(false) .visible(false) - .build() - .map(Some) - .unwrap_or_else(|error| { - log::warn!("[selection-polish] create preview window failed: {error}"); - None - }) + // Nonactivating NSPanel 的 WebKit 內容不可靠地接受 first mouse;native flag + // 仍保留作輔助,HTML 第一擊由下方 local NSEvent monitor 保證。 + .accept_first_mouse(true) + .build(); + match built { + Ok(window) => { + // macOS:转「非激活 NSPanel」(胶囊/QA 同手法)。预览窗展示期间 LLM 可能 + // 还在等待、用户也可能切回原 app 继续工作——普通窗口的 show + set_focus + // 会把 OpenLess 整个激活成 frontmost,原 app 失去前台后很多编辑器的选区 + // 直接消失,之后 confirm 的 reactivate/validate 就「有时」失败。 + // 转成 NonactivatingPanel 后窗口可见、可编辑,但 app 保持后台。 + // 必须在主线程执行(NSWindow class 切换是 AppKit 操作,worker 线程 + // 调用可能触发 NSException 直接 abort)。 + #[cfg(target_os = "macos")] + { + let window_clone = window.clone(); + let _ = app.run_on_main_thread(move || { + make_selection_polish_preview_panel_macos(&window_clone); + }); + } + Some(window) + } + Err(error) => { + log::warn!("[selection-polish] create preview window failed: {error}"); + None + } + } } -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub(crate) fn show_selection_polish_preview(app: &AppHandle) { - let Some(window) = ensure_selection_polish_preview_window(app) else { +/// 选区润色预览窗转「非激活 NSPanel」(macOS,胶囊/QA 同手法)。 +/// +/// `set_style_mask` 是全量替换而非 OR——只设 NSPanel 位会丢掉 titled/resizable, +/// 所以先读当前 mask 再叠加 NonactivatingPanel 位(NSWindowStyleMaskNonactivatingPanel +/// = 1 << 7)。面板保留标题栏(用户仍可拖动定位、点 X 关闭),只是不再 +/// 激活整个 app。 +#[cfg(target_os = "macos")] +fn make_selection_polish_preview_panel_macos(window: &tauri::WebviewWindow) { + use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; + use tauri_nspanel::WebviewWindowExt; + match window.to_panel() { + Ok(panel) => { + // style mask 要先读再 OR(set_style_mask 是全量替换)。RawNSPanel 实现 + // 的是 objc (v0) 的 Message trait,与 objc2::msg_send 不兼容,所以按 + // QA 的手法对原生指针直接发消息(ZST 包装指针即 ObjC 对象指针)。 + use objc2::msg_send; + use objc2::runtime::AnyObject; + let raw = &*panel as *const _ as *mut AnyObject; + if !raw.is_null() { + unsafe { + let current: i32 = msg_send![raw, styleMask]; + const NS_NONACTIVATING_PANEL_MASK: i32 = 1 << 7; + let _: () = msg_send![raw, setStyleMask: current | NS_NONACTIVATING_PANEL_MASK]; + log::info!( + "[selection-polish] preview converted to nonactivating NSPanel (mask {current:#x} -> {:#x})", + current | NS_NONACTIVATING_PANEL_MASK + ); + } + } + // 浮层级别(NSFloatingWindowLevel):盖普通窗口,不盖菜单栏/胶囊(25)。 + // to_panel 类切换后显式重设一次,与 QA 同配置。 + panel.set_level(3); + // 划词常发生在全屏 app 里:CanJoinAllSpaces + FullScreenAuxiliary + // 让面板能叠到全屏空间上(QA 同配置)。 + panel.set_collection_behaviour( + NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary + | NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces, + ); + install_selection_preview_first_click_guard(raw); + } + Err(e) => log::warn!("[selection-polish] preview to_panel failed: {e:?}"), + } +} + +/// 圈選預覽窗第一擊護欄(macOS)。 +/// +/// local monitor 在 AppKit 派發前,若左鍵事件屬於目前的選區預覽窗且該窗不是 key, +/// 先 makeKeyWindow,再原樣放行事件。監聽器只永久保存 windowNumber,不保存 NSPanel +/// 裸指標;預覽窗重建時更新 windowNumber,避免 stale pointer。 +#[cfg(target_os = "macos")] +fn install_selection_preview_first_click_guard(panel: *mut objc2::runtime::AnyObject) { + use block2::RcBlock; + use objc2::msg_send; + use objc2::runtime::{AnyObject, Bool}; + use std::sync::atomic::{AtomicI64, AtomicPtr, Ordering}; + + static TARGET_WINDOW_NUMBER: AtomicI64 = AtomicI64::new(-1); + static MONITOR: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + + if panel.is_null() { return; + } + let window_number = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { + objc2::exception::catch(std::panic::AssertUnwindSafe(|| { + let number: isize = msg_send![panel, windowNumber]; + number as i64 + })) + })) { + Ok(Ok(number)) if number >= 0 => number, + Ok(Ok(_)) => { + log::warn!("[selection-polish] first-click guard: invalid windowNumber"); + return; + } + Ok(Err(error)) => { + log::warn!("[selection-polish] first-click guard: windowNumber raised: {error:?}"); + return; + } + Err(_) => { + log::error!("[selection-polish] first-click guard: Rust panic reading windowNumber"); + return; + } }; - if let Err(error) = window.show() { - log::warn!("[selection-polish] show preview failed: {error}"); + TARGET_WINDOW_NUMBER.store(window_number, Ordering::SeqCst); + + if !MONITOR.load(Ordering::SeqCst).is_null() { + log::info!( + "[selection-polish] first-click guard target updated window_number={window_number}" + ); return; } - if let Err(error) = window.set_focus() { - log::warn!("[selection-polish] focus preview failed: {error}"); + + let block = RcBlock::new(move |event: *mut AnyObject| -> *mut AnyObject { + let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { + objc2::exception::catch(std::panic::AssertUnwindSafe(|| { + if event.is_null() { + return false; + } + let win: *mut AnyObject = msg_send![event, window]; + if win.is_null() { + return false; + } + let event_window_number: isize = msg_send![win, windowNumber]; + if event_window_number as i64 != TARGET_WINDOW_NUMBER.load(Ordering::SeqCst) { + return false; + } + // windowNumber 可能在視窗銷毀後被 AppKit 重用;再用固定 title 驗證 + // 事件確實來自選區預覽窗,避免誤把其他 OpenLess 視窗扶成 key。 + let title: *mut AnyObject = msg_send![win, title]; + if title.is_null() { + return false; + } + let expected: *mut AnyObject = msg_send![ + objc2::runtime::AnyClass::get("NSString").expect("NSString class"), + stringWithUTF8String: c"OpenLess 選區潤色預覽".as_ptr() + ]; + if expected.is_null() { + return false; + } + let title_matches: Bool = msg_send![title, isEqualToString: expected]; + if !title_matches.as_bool() { + return false; + } + let is_key: Bool = msg_send![win, isKeyWindow]; + if !is_key.as_bool() { + let _: () = msg_send![win, makeKeyWindow]; + return true; + } + false + })) + })); + match guarded { + Ok(Ok(true)) => log::info!( + "[selection-polish] first-click guard: panel made key before first mouse-down" + ), + Ok(Ok(false)) => {} + Ok(Err(error)) => log::warn!( + "[selection-polish] first-click guard: ObjC exception caught; event passed through: {error:?}" + ), + Err(_) => log::error!( + "[selection-polish] first-click guard: Rust panic caught; event passed through" + ), + } + event + }); + + let registration = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { + objc2::exception::catch(std::panic::AssertUnwindSafe(|| { + let Some(cls) = objc2::runtime::AnyClass::get("NSEvent") else { + return std::ptr::null_mut(); + }; + const MASK_LEFT_MOUSE_DOWN: u64 = 1 << 1; + let monitor: *mut AnyObject = msg_send![ + cls, + addLocalMonitorForEventsMatchingMask: MASK_LEFT_MOUSE_DOWN, + handler: &*block + ]; + if !monitor.is_null() { + let _: *mut AnyObject = msg_send![monitor, retain]; + } + monitor + })) + })); + match registration { + Ok(Ok(monitor)) if !monitor.is_null() => { + MONITOR.store(monitor, Ordering::SeqCst); + log::info!( + "[selection-polish] first-click guard installed window_number={window_number}" + ); + } + Ok(Ok(_)) => log::warn!( + "[selection-polish] first-click guard: monitor registration unavailable; will retry" + ), + Ok(Err(error)) => log::warn!( + "[selection-polish] first-click guard: registration raised; will retry: {error:?}" + ), + Err(_) => log::error!( + "[selection-polish] first-click guard: Rust panic during registration; will retry" + ), } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) fn show_selection_polish_preview(app: &AppHandle) { + let Some(window) = ensure_selection_polish_preview_window(app) else { + return; + }; let _ = app.emit_to( "selection-polish-preview", "selection-polish-preview:shown", (), ); + #[cfg(target_os = "macos")] + { + // 不用 window.show()/set_focus():tao 的 show 走 makeKeyAndOrderFront + + // NSApp.activate(已核对 tao 源码)——都会把 OpenLess 推成 frontmost,原 + // app 丢前台后选区被清,之后的 confirm reactivate/validate 就「有时」失败。 + // 改走 QA 同手法:主线程 orderFrontRegardless(可见但不抢前台、不成为 key + // window);面板已是 NonactivatingPanel(ensure 阶段转换,同一主线程队列, + // 顺序有保证),textarea 的 autofocus 在点击/聚焦时自行 makeKey,而 + // nonactivating 面板的 makeKey 不会激活 app。 + let window_clone = window.clone(); + let _ = app.run_on_main_thread(move || { + use objc2::msg_send; + use objc2::runtime::AnyObject; + match window_clone.ns_window() { + Ok(handle) => { + let ns = handle as *mut AnyObject; + if ns.is_null() { + log::warn!("[selection-polish] ns_window null; falling back to show()"); + let _ = window_clone.show(); + } else { + // 每次 show 都刷新 target;若初次 monitor 註冊失敗,這裡也會重試。 + install_selection_preview_first_click_guard(ns); + unsafe { + let _: () = msg_send![ns, orderFrontRegardless]; + } + } + } + Err(e) => { + log::warn!("[selection-polish] ns_window unavailable: {e}; falling back to show()"); + let _ = window_clone.show(); + } + } + }); + } + #[cfg(not(target_os = "macos"))] + { + if let Err(error) = window.show() { + log::warn!("[selection-polish] show preview failed: {error}"); + return; + } + if let Err(error) = window.set_focus() { + log::warn!("[selection-polish] focus preview failed: {error}"); + } + } } #[cfg(any(target_os = "android", target_os = "ios"))] pub(crate) fn show_selection_polish_preview(_app: &AppHandle) {} pub(crate) fn hide_selection_polish_preview(app: &AppHandle) { - if let Some(window) = app.get_webview_window("selection-polish-preview") { + let Some(window) = app.get_webview_window("selection-polish-preview") else { + return; + }; + // macOS:转换后的 NSPanel 不能从 worker 线程操作(AppKit 硬约束,resize/hide + // 都可能让进程 abort),统一 dispatch 回主线程;其他平台 hide 走 Tauri 内部 + // 主线程调度即可。 + #[cfg(target_os = "macos")] + { + let window_clone = window.clone(); + let _ = app.run_on_main_thread(move || { + let _ = window_clone.hide(); + }); + } + #[cfg(not(target_os = "macos"))] + { let _ = window.hide(); } } +/// Confirm 前同步撤掉選區預覽 NSPanel 的 key-window 狀態。 +/// +/// NonactivatingPanel 可以在來源 app 已是 frontmost 時仍保有 key window;若不先 +/// resign,validate 的全域 Cmd+C 可能仍送進預覽 WebView,讀到空剪貼簿後誤判 +/// SelectionChanged。只 resign、不 hide:失敗時預覽仍可見並可重試;成功後沿用 +/// selection service 原本的 hide 流程。 +#[cfg(target_os = "macos")] +fn resign_selection_polish_preview_key_macos( + window: &tauri::WebviewWindow, +) -> Result { + use objc2::msg_send; + use objc2::runtime::{AnyObject, Bool}; + + let handle = window + .ns_window() + .map_err(|error| format!("ns_window unavailable: {error}"))?; + let ns = handle as *mut AnyObject; + if ns.is_null() { + return Err("ns_window returned null".to_string()); + } + let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { + objc2::exception::catch(std::panic::AssertUnwindSafe(|| { + let was_key: Bool = msg_send![ns, isKeyWindow]; + if was_key.as_bool() { + let _: () = msg_send![ns, resignKeyWindow]; + } + was_key.as_bool() + })) + })); + match caught { + Ok(Ok(was_key)) => Ok(was_key), + Ok(Err(error)) => Err(format!("resignKeyWindow raised: {error:?}")), + Err(_) => Err("Rust panic while resigning preview key window".to_string()), + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn resign_selection_polish_preview_key_for_apply( + app: &AppHandle, +) -> bool { + use objc2::msg_send; + use objc2::runtime::{AnyClass, Bool}; + + let Some(window) = app.get_webview_window("selection-polish-preview") else { + log::warn!("[selection-polish] apply: preview window missing before focus handoff"); + return false; + }; + + let on_main_thread = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { + objc2::exception::catch(std::panic::AssertUnwindSafe(|| { + AnyClass::get("NSThread").is_some_and(|class| { + let is_main: Bool = msg_send![class, isMainThread]; + is_main.as_bool() + }) + })) + })) { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + log::warn!("[selection-polish] apply: NSThread lookup raised: {error:?}"); + return false; + } + Err(_) => { + log::error!("[selection-polish] apply: Rust panic checking main thread"); + return false; + } + }; + let result = if on_main_thread { + resign_selection_polish_preview_key_macos(&window) + } else { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let window_clone = window.clone(); + if let Err(error) = app.run_on_main_thread(move || { + let result = resign_selection_polish_preview_key_macos(&window_clone); + let _ = tx.send(result); + }) { + log::warn!( + "[selection-polish] apply: main-thread focus handoff dispatch failed: {error}" + ); + return false; + } + // 呼叫端是 blocking apply;事件已排入主執行緒後等待唯一結果。主執行緒路徑 + // 已在上方直接執行,不會 self-deadlock;不設 timeout,避免回報失敗後延遲 + // resign 又在別的互動中生效。 + rx.recv() + .unwrap_or_else(|error| Err(format!("preview resign channel closed: {error}"))) + }; + + match result { + Ok(was_key) => { + log::info!( + "[selection-polish] apply: preview resigned key before reactivate was_key={was_key}" + ); + true + } + Err(error) => { + log::warn!("[selection-polish] apply: preview resign failed: {error}"); + false + } + } +} + /// 选区语音:说完后由用户选择提问或编辑。 #[cfg(all(not(mobile), target_os = "windows"))] fn ensure_selection_voice_intent_prompt_window( diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 1fd08db2..d8106db7 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -507,11 +507,21 @@ pub(crate) fn reactivate_selection_insertion_target(target: &SelectionInsertionT return false; }; // 预览窗是 OpenLess 自己的窗口,确认后需要把焦点交还原应用再粘贴。 - activate_app_by_pid(pid); - std::thread::sleep(Duration::from_millis(120)); - // NSRunningApplication 激活也是 best-effort;必须复核 pid,失败就明确走 - // copied/error,不能向此刻偶然持有焦点的应用盲写。 - return current_front_app_pid() == Some(pid); + // NSRunningApplication activate 是 best-effort,且部分 app(Electron、 + // 自绘窗口)恢复 key window 需要 >120ms——固定 sleep 一次就核 pid 会 + // 偶发把「还在恢复中」误判为「恢复失败」。改成短轮询:pid 一稳定立刻 + // 返回,最多等 ~320ms。 + for _attempt in 0..4 { + // 每轮都补一次 activate:NSRunningApplication activate 对「前台被 + // 其他 app 抢走」的情况可能不生效,重复调用是幂等的。 + activate_app_by_pid(pid); + std::thread::sleep(Duration::from_millis(80)); + if current_front_app_pid() == Some(pid) { + return true; + } + } + // 仍未成为前台:必须明确失败,不能向此刻偶然持有焦点的应用盲写。 + false } #[cfg(not(any(target_os = "windows", target_os = "macos")))] @@ -541,6 +551,30 @@ fn activate_app_by_pid(pid: i32) { } } +/// macOS 专用:贴上前一刻的最终防线。`validate_selection_insertion_target` +/// 的 simulate_copy 兜底最长含 200ms 重试,期间前台焦点可能跳到别的窗口或 +/// 应用(而对方恰好暴露相同选区文本时,仅靠文本比对会放行)。这里在 +/// `insert()` 之前立即重读前台应用 pid+name 并与捕获时比对,任何变化都拒绝 +/// 粘贴——宁可替换失败,不能写错目标。 +#[cfg(target_os = "macos")] +pub(crate) fn selection_target_still_front(target: &SelectionInsertionTarget) -> bool { + let Some(captured) = target.macos.as_ref() else { + return false; + }; + let Some(pid) = captured.front_app_pid else { + return false; + }; + if current_front_app_pid() != Some(pid) { + return false; + } + if let Some(name) = captured.front_app.as_deref() { + if current_front_app().as_deref() != Some(name) { + return false; + } + } + true +} + /// 捕获选区。Linux 只通过 fcitx5 DBus 读取 PRIMARY 选区,失败统一视为无选区。 pub fn capture_selection_with_status() -> SelectionCaptureOutcome { capture_selection_with_status_diag().0 diff --git a/openless-all/app/src/pages/SelectionPolishPreview.tsx b/openless-all/app/src/pages/SelectionPolishPreview.tsx index 471ee434..6036b192 100644 --- a/openless-all/app/src/pages/SelectionPolishPreview.tsx +++ b/openless-all/app/src/pages/SelectionPolishPreview.tsx @@ -28,6 +28,11 @@ export function SelectionPolishPreview() { void load(); void import('@tauri-apps/api/event').then(({ listen }) => listen('selection-polish-preview:shown', () => { + // 预览窗是复用的:上一轮 confirm/cancel 成功后窗口 hide,但组件不卸载, + // busy 会停留在 true → 下一轮两个按钮全 disabled(表现为「点确认没反应」)。 + // 每次重新 show 必须复位交互状态。 + setBusy(false); + setError(null); void load(); }).then((handle) => { if (cancelled) handle(); @@ -60,6 +65,8 @@ export function SelectionPolishPreview() { style={{ display: 'flex', flexDirection: 'column', + flex: '1 1 auto', + width: '100%', height: '100%', boxSizing: 'border-box', padding: 18,