fix(capsule): 入场帧强制重注册 Space 贴附——胶囊不再被 macOS 26 钉死在单个桌面 (#875)
实测(2026-07-31):macOS 26 会在运行中把胶囊窗口从「全 Space 贴附」剥离, 之后它表现为普通受管窗口:只在某一个桌面可见,其它桌面上听写全程无任何 UI 指示(后端 30Hz orderFrontRegardless 全部无效,窗口 onscreen=0,最长观测 到 78 秒无提示录音)。用户侧症状即「微信里不弹胶囊、Claude 里又有」—— 实际按桌面分,不按 App 分。 CGS 取证 + 体外复现(CGSRemoveWindowsFromSpaces 人工制造同款坏状态)跑遍 四种救法,结论: · 重写同值 273:无效(AppKit 同值写入是 no-op,这正是坏状态救不回的原因) · 隐藏时翻转 0→273 再显示:无效(隐藏期间的值变化不触发重注册) · 同一 tick 连写 0、273:无效(被合并) · 窗口可见时发生 CanJoinAllSpaces 位 0→1 的转变:有效,完整恢复贴附 · 跨显示器移动:有效(与野外观察到的「外接屏上意外自愈」一致) 修法:入场帧(隐藏→可见)先以 272(保留 Stationary|FullScreenAuxiliary,仅去 CanJoinAllSpaces 位)上屏,30ms 后的下一个主线程 tick 写回 273——可见状态下 的位翻转强制 WindowServer 重新注册贴附。30Hz level 帧不参与。写前读当前值, 发现漂移打 warn 留证据。glow 窗口同款写法一并加固。 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95edf8943b
commit
f0ffa8a873
3 files changed
+85
-10
No files matched your search
@@ -158,10 +158,11 @@ fn capsule_state_log_name(state: CapsuleState) -> &'static str {
|
||||
fn show_capsule_window_for_recording<R: tauri::Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
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 可能
|
||||
|
||||
@@ -245,6 +245,7 @@ pub(super) fn capture_ime_submit_target() -> Option<ImeSubmitTarget> {
|
||||
pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
_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<R: tauri::Runtime>(
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
app: &AppHandle<R>,
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
reassert_spaces: bool,
|
||||
) -> bool {
|
||||
use objc2::msg_send;
|
||||
use objc2::runtime::AnyObject;
|
||||
@@ -314,15 +316,54 @@ pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
|
||||
// 外加 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<R: tauri::Runtime>(
|
||||
pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
_window: &tauri::WebviewWindow<R>,
|
||||
_reassert_spaces: bool,
|
||||
) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -338,6 +380,7 @@ pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
|
||||
pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
|
||||
_app: &AppHandle<R>,
|
||||
_window: &tauri::WebviewWindow<R>,
|
||||
_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")]
|
||||
|
||||
@@ -2464,6 +2464,7 @@ pub(crate) fn show_less_computer_glow<R: tauri::Runtime>(app: &AppHandle<R>) {
|
||||
// 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<R: tauri::Runtime>(app: &AppHandle<R>) {
|
||||
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(_) => {
|
||||
|
||||
Reference in new issue
Block a user