diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index ba4eae9f..43f3d8d2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -158,10 +158,11 @@ fn capsule_state_log_name(state: CapsuleState) -> &'static str { fn show_capsule_window_for_recording( app: &AppHandle, window: &tauri::WebviewWindow, + reassert_spaces: bool, ) { let mut needs_fallback = true; if capsule_show_strategy_for_platform() == CapsuleShowStrategy::NoActivate { - needs_fallback = !show_capsule_window_no_activate(app, window); + needs_fallback = !show_capsule_window_no_activate(app, window, reassert_spaces); if needs_fallback && !CAPSULE_NO_ACTIVATE_FALLBACK_WARNED.swap(true, Ordering::SeqCst) { // 产品取舍:no-activate 是 macOS/AeroSpace 的主路径;但如果 ns_window // 暂不可用,仍优先保住录音反馈,不让用户以为听写没启动。fallback 可能 diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index 595ee1e2..37ded825 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -245,6 +245,7 @@ pub(super) fn capture_ime_submit_target() -> Option { pub(super) fn show_capsule_window_no_activate( _app: &AppHandle, window: &tauri::WebviewWindow, + _reassert_spaces: bool, ) -> bool { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use windows::Win32::Foundation::HWND; @@ -284,8 +285,9 @@ pub(super) fn show_capsule_window_no_activate( #[cfg(target_os = "macos")] pub(super) fn show_capsule_window_no_activate( - _app: &AppHandle, + app: &AppHandle, window: &tauri::WebviewWindow, + reassert_spaces: bool, ) -> bool { use objc2::msg_send; use objc2::runtime::AnyObject; @@ -314,15 +316,54 @@ pub(super) fn show_capsule_window_no_activate( // 外加 setLevel(25):光有 FULL_SCREEN_AUXILIARY 只是「被允许」进全屏 Space,但窗口层级 // 若停在 alwaysOnTop 的浮动层(~3) 仍会被全屏 app 的窗口盖住而看不见;抬到菜单栏(24)之上 // 的 25(与 show_less_computer_glow 同款)才能真正叠在全屏之上。 + const CAN_JOIN_ALL_SPACES: usize = 1 << 0; + const STATIONARY: usize = 1 << 4; + const FULL_SCREEN_AUXILIARY: usize = 1 << 8; + const BEHAVIOR: usize = CAN_JOIN_ALL_SPACES | STATIONARY | FULL_SCREEN_AUXILIARY; unsafe { - const CAN_JOIN_ALL_SPACES: usize = 1 << 0; - const STATIONARY: usize = 1 << 4; - const FULL_SCREEN_AUXILIARY: usize = 1 << 8; - let behavior = CAN_JOIN_ALL_SPACES | STATIONARY | FULL_SCREEN_AUXILIARY; let _: () = msg_send![ns_window, setLevel: 25i64]; - let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; + if reassert_spaces { + // 值若被外部改动过,留一条证据 —— 用于分辨「值被改」与「值没变但 + // WindowServer 侧注册失效」(2026-07-31 事故属于后者:值一直是 273, + // 注册却缺了桌面,窗口被钉死在单个 Space)。 + let current: usize = msg_send![ns_window, collectionBehavior]; + if current != BEHAVIOR { + log::warn!( + "[capsule] collectionBehavior drifted to {current} (expected {BEHAVIOR}); re-registering" + ); + } + // 入场帧先以「无 CanJoinAllSpaces 位」的低值上屏(保留 Stationary/ + // FullScreenAuxiliary,全屏叠加不受影响)。体外实验(macOS 26)证明: + // 只有「窗口可见时 CanJoinAllSpaces 位发生 0→1 转变」才触发 WindowServer + // 重新注册贴附;隐藏时改值、或同一个 runloop tick 里连写两个值(被合并) + // 都是 no-op。所以 273 必须等 orderFront 之后的下一个 tick 再写(见下方)。 + let low = STATIONARY | FULL_SCREEN_AUXILIARY; + let _: () = msg_send![ns_window, setCollectionBehavior: low]; + } else { + let _: () = msg_send![ns_window, setCollectionBehavior: BEHAVIOR]; + } let _: () = msg_send![ns_window, orderFrontRegardless]; } + if reassert_spaces { + // 换线程再回主线程,保证落在 orderFront 之后的另一个 runloop tick—— + // run_on_main_thread 在主线程上会内联执行,起不到隔 tick 的作用。 + // 30ms 间隙里窗口以低值可见于当前桌面(用户正看着的那个),无可感知差异。 + let app = app.clone(); + let window = window.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(30)); + let _ = app.run_on_main_thread(move || { + let Ok(handle) = window.ns_window() else { return }; + let ns_window = handle as *mut AnyObject; + if ns_window.is_null() { + return; + } + unsafe { + let _: () = msg_send![ns_window, setCollectionBehavior: BEHAVIOR]; + } + }); + }); + } true } @@ -330,6 +371,7 @@ pub(super) fn show_capsule_window_no_activate( pub(super) fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, + _reassert_spaces: bool, ) -> bool { true } @@ -338,6 +380,7 @@ pub(super) fn show_capsule_window_no_activate( pub(super) fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, + _reassert_spaces: bool, ) -> bool { false } @@ -585,7 +628,16 @@ pub(super) fn emit_capsule( capsule_state_log_name(state) ); } - show_capsule_window_for_recording(&app_for_main, &window); + // 入场帧(隐藏→可见)强制重注册 Space 贴附:macOS 26 观测到系统会在运行中 + // 把窗口从「全 Space 贴附」剥离(2026-07-31 实测:胶囊被钉死单个 Space, + // 其它桌面上听写全程不可见),而 setCollectionBehavior 写入相同值是 no-op, + // 之后每帧重写 273 都救不回来。只在入场帧做一次 0→273 的翻转即可恢复注册, + // 30Hz 的 level 帧不做(每帧翻转会让 WindowServer 反复重排窗口)。 + show_capsule_window_for_recording( + &app_for_main, + &window, + payload_for_deferred_emit.is_some(), + ); // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。 // 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。 #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 18f61a3f..a79509db 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -2464,6 +2464,7 @@ pub(crate) fn show_less_computer_glow(app: &AppHandle) { // issue #470:通知 glow 前端「可见」,恢复发光动画(隐藏时会 emit(false) 卸载发光层以释放 GPU)。 let _ = window.emit("less-computer-glow:active", true); let window_clone = window.clone(); + let app_for_reassert = app.clone(); let _ = app.run_on_main_thread(move || { use objc2::msg_send; use objc2::runtime::AnyObject; @@ -2476,11 +2477,32 @@ pub(crate) fn show_less_computer_glow(app: &AppHandle) { unsafe { // 抬到菜单栏(24)/Dock 之上,让描边能真正贴到屏幕最外缘(含顶部菜单栏区域)。 let _: () = msg_send![ns, setLevel: 25i64]; - // 所有 Space 都显示、不参与窗口循环、全屏 app 上也叠加。 - let _: () = msg_send![ns, setCollectionBehavior: 273u64]; + // 所有 Space 都显示、不参与窗口循环、全屏 app 上也叠加(273 = + // CanJoinAllSpaces|Stationary|FullScreenAuxiliary)。macOS 26 会在 + // 运行中把窗口从「全 Space 贴附」剥离且同值写入救不回(详见 + // show_capsule_window_no_activate 的重注册注释);glow show 频率低, + // 每次都走重注册序列:先以去掉 CanJoinAllSpaces 位的 272 上屏, + // 下一个 tick 再写 273 —— 可见状态下的位翻转才触发重新注册。 + let _: () = msg_send![ns, setCollectionBehavior: 272u64]; let _: () = msg_send![ns, setIgnoresMouseEvents: true]; let _: () = msg_send![ns, orderFrontRegardless]; } + let window_for_reassert = window_clone.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(30)); + let _ = app_for_reassert.run_on_main_thread(move || { + let Ok(handle) = window_for_reassert.ns_window() else { + return; + }; + let ns = handle as *mut AnyObject; + if ns.is_null() { + return; + } + unsafe { + let _: () = msg_send![ns, setCollectionBehavior: 273u64]; + } + }); + }); } } Err(_) => {