From 4916241703a73fcb0827e04ebdcba5f98e81dcce Mon Sep 17 00:00:00 2001 From: leonoxo Date: Wed, 9 Sep 2026 13:14:38 +0800 Subject: [PATCH] fix(selection-polish): make preview confirm reliable on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 圈選潤色的「確認並替換」偶爾沒反應,根因鏈有四個,分別處理: 1. 预览窗 show 抢焦点:show_selection_polish_preview 走 window.show() + set_focus()(tao 层 makeKeyAndOrderFront + NSApp.activate),把 OpenLess 推成 frontmost;原 app 丢前台后编辑器的选区被清,之后 confirm 的 reactivate/validate 就「有时」失败。预览窗改成与胶囊/QA 同手法的 Nonactivating NSPanel(to_panel + styleMask 先读再 OR NonactivatingPanel 位——set_style_mask 是全量替换;level 3 + FullScreenAuxiliary/CanJoinAllSpaces 叠全屏 app),show 走主线程 orderFrontRegardless,不 makeKey、不激活 app。 2. 失败即杀 session:confirm 任何 Err 都 fail_if_active → Failed + 隐藏预览, 用户刚编辑的内容直接丢,只剩「点确认没反应」。分流:session 失效 (Cancelled/InvalidState/InvalidArgument/Busy)才结算失败并隐藏; 瞬时平台错误(焦点恢复/目标复核抖动)回退 Preview 保持可重试。 fail_if_active 加相位保护:只结算 Capturing/Preview/Applying,已 Completed 的不再被误标 Failed(complete 与 fail 之间的窄窗口)。 3. reactivate 固定 120ms:部分 app(Electron、自绘窗口)恢复 key window 需要更久,固定 sleep 一次就核 pid 会偶发误判「恢复失败」。改成 80ms x 4 轮询,每轮补一次 activate(幂等),pid 稳定立刻返回。 4. 前端 busy 卡死:预览窗复用(hide 不卸载组件),上一轮 confirm/cancel 后 busy 停留 true,下一轮两个按钮全 disabled。「shown」事件到达时 复位 busy/error。 验证:cargo check/test openless-core(含契约测试 transient_platform_failure_keeps_the_preview_retryable)全绿; src-tauri 本机因缺 Metal 工具链(qwen3-asr-rs 需要完整 Xcode)无法整树 check,新窗口代码已用同版本依赖(tauri 2.11.5 / tauri-nspanel v2 / objc2 0.5.2)的独立 crate 编译验证通过;前端 tsc + 71 个前端测试全绿。 macOS 实机行为(预览不抢焦点、confirm 重试)需要 CI(submodules: recursive) 或本机装完整 Xcode 后验证。 --- .../openless-core/src/selection_service.rs | 47 +++++- .../openless-core/tests/selection_contract.rs | 103 ++++++++++++- openless-all/app/src-tauri/src/lib.rs | 144 ++++++++++++++++-- openless-all/app/src-tauri/src/selection.rs | 20 ++- .../app/src/pages/SelectionPolishPreview.tsx | 9 +- 5 files changed, 291 insertions(+), 32 deletions(-) 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 afe48c297..2e3419d09 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) { @@ -650,7 +690,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/tests/selection_contract.rs b/openless-all/app/crates/openless-core/tests/selection_contract.rs index 0ab021b8e..c0834e5b5 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/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8b450cc70..2b346a666 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -2746,12 +2746,12 @@ 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()), @@ -2761,13 +2761,76 @@ fn ensure_selection_polish_preview_window( .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 - }) + .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 + } + } +} + +/// 选区润色预览窗转「非激活 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, + ); + } + Err(e) => log::warn!("[selection-polish] preview to_panel failed: {e:?}"), + } } #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -2775,25 +2838,74 @@ pub(crate) fn show_selection_polish_preview(app: &AppHandle { + 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 { + 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(); } } diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 7eb784034..c0067a2f2 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")))] diff --git a/openless-all/app/src/pages/SelectionPolishPreview.tsx b/openless-all/app/src/pages/SelectionPolishPreview.tsx index 5cc8ac974..e62b333b9 100644 --- a/openless-all/app/src/pages/SelectionPolishPreview.tsx +++ b/openless-all/app/src/pages/SelectionPolishPreview.tsx @@ -27,7 +27,14 @@ export function SelectionPolishPreview() { }; void load(); void import('@tauri-apps/api/event').then(({ listen }) => - listen('selection-polish-preview:shown', () => { void load(); }).then(handle => { + listen('selection-polish-preview:shown', () => { + // 预览窗是复用的:上一轮 confirm/cancel 成功后窗口 hide,但组件不卸载, + // busy 会停留在 true → 下一轮两个按钮全 disabled(表现为「点确认没反应」)。 + // 每次重新 show 必须复位交互状态。 + setBusy(false); + setError(null); + void load(); + }).then(handle => { if (cancelled) handle(); else unlisten = handle; }), );