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:
bigsongandClaude Fable 5 authored and GitHub committed 2026-08-02 11:53:17 +08:00
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")]
+24 -2
View File
@@ -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(_) => {