diff --git a/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs b/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs index f83684b6..cf3ed591 100644 --- a/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs +++ b/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs @@ -9,30 +9,73 @@ function assertMatch(source, pattern, name) { const coordinatorRs = ( await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8') ).replace(/\r\n/g, '\n'); +const libRs = (await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8')).replace( + /\r\n/g, + '\n', +); const functionMatch = coordinatorRs.match( /#\[cfg\(target_os = "macos"\)\]\s*fn show_capsule_window_no_activate[\s\S]*?\n}\n\n#\[cfg\(target_os = "linux"\)\]/, ); +const behaviorHelperMatch = coordinatorRs.match( + /#\[cfg\(target_os = "macos"\)\]\s*fn macos_capsule_collection_behavior[\s\S]*?\n}\n\n#\[cfg\(target_os = "macos"\)\]\s*fn macos_capsule_window_level/, +); +const styleHelperMatch = coordinatorRs.match( + /#\[cfg\(target_os = "macos"\)\]\s*fn macos_capsule_style_mask[\s\S]*?\n}\n\n#\[cfg\(target_os = "macos"\)\]\s*fn configure_macos_capsule_window_for_overlay/, +); +const hideHelperMatch = coordinatorRs.match( + /#\[cfg\(target_os = "macos"\)\]\s*pub\(crate\) fn hide_capsule_window_preserving_space[\s\S]*?\n}\n\n#\[cfg\(target_os = "macos"\)\]\s*fn show_capsule_window_no_activate/, +); if (!functionMatch) { throw new Error('macOS capsule no-activate function not found'); } +if (!behaviorHelperMatch) { + throw new Error('macOS capsule collection behavior helper not found'); +} +if (!styleHelperMatch) { + throw new Error('macOS capsule style helper not found'); +} +if (!hideHelperMatch) { + throw new Error('macOS capsule hide helper not found'); +} const macosNoActivateFunction = functionMatch[0]; +const behaviorHelper = behaviorHelperMatch[0]; +const styleHelper = styleHelperMatch[0]; +const hideHelper = hideHelperMatch[0]; const executableMacosNoActivateFunction = macosNoActivateFunction.replace(/\/\/.*$/gm, ''); assertMatch( - macosNoActivateFunction, - /set_visible_on_all_workspaces\(true\)[\s\S]*?orderFrontRegardless/, - 'macOS capsule should join all Spaces before showing without activation', + behaviorHelper, + /!MOVE_TO_ACTIVE_SPACE[\s\S]*?CAN_JOIN_ALL_SPACES[\s\S]*?TRANSIENT[\s\S]*?IGNORES_CYCLE[\s\S]*?FULL_SCREEN_AUXILIARY/, + 'macOS capsule should clear MoveToActiveSpace and opt into the fullscreen HUD behaviors', +); + +assertMatch( + styleHelper, + /NONACTIVATING_PANEL[\s\S]*?1 << 7/, + 'macOS capsule should use the non-activating panel style bit', ); assertMatch( macosNoActivateFunction, - /FULL_SCREEN_AUXILIARY[\s\S]*?1 << 8[\s\S]*?setCollectionBehavior[\s\S]*?orderFrontRegardless/, - 'macOS capsule should join fullscreen Spaces as an auxiliary window before showing without activation', + /configure_macos_capsule_window_for_overlay\(window\)[\s\S]*?orderFrontRegardless/, + 'macOS capsule should configure overlay behavior before showing without activation', +); + +assertMatch( + hideHelper, + /orderOut/, + 'macOS capsule should hide with orderOut to preserve fullscreen Space association', +); + +assertMatch( + libRs, + /prepare_capsule_window_for_overlay\(&capsule\)[\s\S]*?hide_capsule_window_preserving_space\(&capsule\)/, + 'macOS startup should prepare and hide the capsule through the overlay-preserving path', ); -for (const forbidden of ['window.show()', 'set_focus', 'NSApp.activate', 'makeKeyAndOrderFront']) { +for (const forbidden of ['set_focus', 'NSApp.activate', 'makeKeyAndOrderFront']) { if (executableMacosNoActivateFunction.includes(forbidden)) { throw new Error(`macOS capsule no-activate path must not call ${forbidden}`); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f6e491a5..4a7ed4cb 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -4536,6 +4536,48 @@ mod tests { ); } + #[test] + #[cfg(target_os = "macos")] + fn macos_capsule_collection_behavior_joins_spaces_without_conflicts() { + let behavior = macos_capsule_collection_behavior(0); + + assert!( + behavior & (1 << 0) != 0, + "capsule HUD should join all Spaces" + ); + assert_eq!( + behavior & (1 << 1), + 0, + "MoveToActiveSpace keeps the HUD out of another app's fullscreen Space" + ); + assert!(behavior & (1 << 3) != 0, "must be transient overlay"); + assert_eq!( + behavior & (1 << 4), + 0, + "Stationary can conflict with independent display Spaces" + ); + assert!(behavior & (1 << 6) != 0, "must stay out of window cycling"); + assert!( + behavior & (1 << 8) != 0, + "must appear over fullscreen Spaces" + ); + assert!( + macos_capsule_window_level() >= 1500, + "capsule must use AssistiveTechHigh-level overlay stacking" + ); + } + + #[test] + #[cfg(target_os = "macos")] + fn macos_capsule_style_mask_is_nonactivating_panel_like() { + let style_mask = macos_capsule_style_mask(0); + + assert!( + style_mask & (1 << 7) != 0, + "capsule should stay non-activating" + ); + } + #[test] #[cfg(target_os = "windows")] fn prepared_windows_ime_slot_is_taken_only_for_matching_session() { @@ -5127,37 +5169,122 @@ fn show_capsule_window_no_activate( } #[cfg(target_os = "macos")] -fn show_capsule_window_no_activate( - _app: &AppHandle, +fn macos_capsule_collection_behavior(current: usize) -> usize { + const CAN_JOIN_ALL_SPACES: usize = 1 << 0; + const MOVE_TO_ACTIVE_SPACE: usize = 1 << 1; + const TRANSIENT: usize = 1 << 3; + const STATIONARY: usize = 1 << 4; + const IGNORES_CYCLE: usize = 1 << 6; + const FULL_SCREEN_AUXILIARY: usize = 1 << 8; + + // Mirror Electron's visibleOnAllWorkspaces/fullscreen auxiliary HUD path. + // `MoveToActiveSpace` keeps the ordinary Tauri NSWindow tied to the app's + // own Space instead of joining another app's native fullscreen Space. + let cleared = current & !MOVE_TO_ACTIVE_SPACE & !STATIONARY; + cleared | CAN_JOIN_ALL_SPACES | TRANSIENT | IGNORES_CYCLE | FULL_SCREEN_AUXILIARY +} + +#[cfg(target_os = "macos")] +fn macos_capsule_window_level() -> isize { + const K_CG_ASSISTIVE_TECH_HIGH_WINDOW_LEVEL_KEY: i32 = 20; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn CGWindowLevelForKey(key: i32) -> i32; + } + + // Status-level windows can still be composited behind another app's native + // fullscreen Space. AssistiveTechHigh is the system overlay level used by + // transient accessibility HUDs. + unsafe { CGWindowLevelForKey(K_CG_ASSISTIVE_TECH_HIGH_WINDOW_LEVEL_KEY) as isize } +} + +#[cfg(target_os = "macos")] +fn macos_capsule_style_mask(current: usize) -> usize { + const NONACTIVATING_PANEL: usize = 1 << 7; + + current | NONACTIVATING_PANEL +} + +#[cfg(target_os = "macos")] +fn configure_macos_capsule_window_for_overlay( window: &tauri::WebviewWindow, -) -> bool { +) -> Option<*mut objc2::runtime::AnyObject> { use objc2::msg_send; use objc2::runtime::AnyObject; let Ok(handle) = window.ns_window() else { - return false; + log::warn!("[capsule] macOS overlay configure failed: ns_window unavailable"); + return None; }; let ns_window = handle as *mut AnyObject; if ns_window.is_null() { - return false; + log::warn!("[capsule] macOS overlay configure failed: ns_window null"); + return None; + } + + unsafe { + let before_behavior: usize = msg_send![ns_window, collectionBehavior]; + let before_style_mask: usize = msg_send![ns_window, styleMask]; + let after_behavior = macos_capsule_collection_behavior(before_behavior); + let after_style_mask = macos_capsule_style_mask(before_style_mask); + let level = macos_capsule_window_level(); + let _: () = msg_send![ns_window, setStyleMask: after_style_mask]; + let _: () = msg_send![ns_window, setCollectionBehavior: after_behavior]; + let _: () = msg_send![ns_window, setLevel: level]; + let _: () = msg_send![ns_window, setCanHide: false]; + let _: () = msg_send![ns_window, setHidesOnDeactivate: false]; + let _: () = msg_send![ns_window, setReleasedWhenClosed: false]; + let _: () = msg_send![ns_window, setIgnoresMouseEvents: true]; + Some(ns_window) } +} - // emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能再调用 - // window.show()/set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回 - // OpenLess 主窗口所在空间。先让胶囊加入所有 Spaces,再用 - // orderFrontRegardless 做无激活展示。 - if let Err(e) = window.set_visible_on_all_workspaces(true) { - log::warn!("[capsule] set visible on all macOS Spaces failed: {e}"); +#[cfg(target_os = "macos")] +pub(crate) fn prepare_capsule_window_for_overlay( + window: &tauri::WebviewWindow, +) -> bool { + configure_macos_capsule_window_for_overlay(window).is_some() +} + +#[cfg(target_os = "macos")] +pub(crate) fn hide_capsule_window_preserving_space( + window: &tauri::WebviewWindow, +) -> bool { + use objc2::msg_send; + + let Some(ns_window) = configure_macos_capsule_window_for_overlay(window) else { + return false; + }; + unsafe { + let _: () = msg_send![ns_window, orderOut: std::ptr::null::()]; } + true +} + +#[cfg(target_os = "macos")] +fn show_capsule_window_no_activate( + _app: &AppHandle, + window: &tauri::WebviewWindow, +) -> bool { + use objc2::msg_send; + + // emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能调用 + // set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回 OpenLess + // 主窗口所在空间。胶囊是 HUD,不是主窗口:通过 all-Spaces + + // fullscreen auxiliary 行为挂到用户当前 fullscreen Space。 + let was_visible = window.is_visible().unwrap_or(false); + let Some(ns_window) = configure_macos_capsule_window_for_overlay(window) else { + return false; + }; unsafe { - const NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES: usize = 1 << 0; - const NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY: usize = 1 << 8; - let behavior: usize = msg_send![ns_window, collectionBehavior]; - let behavior = behavior - | NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES - | NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY; - let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; + if !was_visible { + if let Err(e) = window.show() { + log::warn!("[capsule] macOS no_activate pre-show failed: {e}"); + } + } + let _: () = msg_send![ns_window, orderFrontRegardless]; } true @@ -5396,6 +5523,13 @@ fn emit_capsule( ); } hide_capsule_window_if_present(); + #[cfg(target_os = "macos")] + { + if !hide_capsule_window_preserving_space(&window) { + let _ = window.hide(); + } + } + #[cfg(not(target_os = "macos"))] let _ = window.hide(); } } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 3f1d8d19..c36644f9 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -146,7 +146,15 @@ pub fn run() { if let Err(e) = position_capsule_bottom_center(&capsule, false) { log::warn!("[capsule] position failed: {e}"); } - let _ = capsule.hide(); + #[cfg(target_os = "macos")] + { + crate::coordinator::prepare_capsule_window_for_overlay(&capsule); + let _ = crate::coordinator::hide_capsule_window_preserving_space(&capsule); + } + #[cfg(not(target_os = "macos"))] + { + let _ = capsule.hide(); + } } // QA 浮窗(issue #118):紧贴胶囊上方 8pt、屏幕底部居中、380×440。