From 230f400ddc102cd79bdcaddbb6dd94361921ef8a Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:42:14 -0500 Subject: [PATCH] display: fill the area beside the camera housing in full screen A full screen VM on a Mac with a camera housing loses the strip beside it, which the guest could otherwise use. Two things keep a native full screen window out of that strip: AppKit insets the full screen frame and the window server covers the strip with an opaque menu bar. There is no public way around either, and a borderless window is not an option because macOS would no longer treat the VM as a full screen app. Add an opt-in preference (macOS 27+) and a window subclass that returns the whole screen as the full screen frame and makes the menu bar of its own full screen space transparent while it is not revealed. When the menu bar is revealed it looks and behaves as it always did. It works within the App Sandbox. All of it relies on private AppKit and SkyLight symbols. None of them are used or hooked until the option is enabled and a window enters full screen, so the window is unaffected by changes to them otherwise. They are looked up at runtime by name and checked against the signatures we expect, so no private symbol is linked or compiled into the binary and if any are missing or different the window behaves like any other. The window server reveals the menu bar as soon as the pointer is over the strip, which would make that area of the guest display unusable. It is instead only revealed when the pointer reaches the top edge of the screen: until then the space uses presentation options that hide the menu bar, which also hides the Dock. AppKit applies the options of the space again when it changes spaces, so they are kept up to date. The window server only lets a window cover the strip if it does so from the moment it enters the full screen space, which shapes the rest: - Every frame query in the transition is answered with the whole screen, and later requests to resize the window are ignored, because asking for any frame again, even the current one, moves it out of the strip. Our window controllers do so to fit the guest display, and AppKit does when the space becomes active again. - Once the strip has been left, such as when tiled in Split View, it is not asked for again until full screen is next entered. - The QEMU window is no longer resized to fit a display the guest just changed while the transition to full screen is still running. The flag the controller kept for that is only set once the transition is over, so the window ended up taller than the screen and lost the strip. - AppKit parks the auto hidden toolbar behind the menu bar, or over our content while the menu bar is hidden, so it is hidden along with it. The window stands down when tiled, on a screen without a camera housing, and when the menu bar is set to always be visible in full screen. Terminal windows do not opt in. Resolves #6183 Assisted-by: Claude:claude-fable-5-1 Assisted-by: Claude:claude-opus-5 --- .../Display/Base.lproj/VMDisplayWindow.xib | 2 +- ...MDisplayAppleDisplayWindowController.swift | 1 + .../VMDisplayQemuMetalWindowController.swift | 4 +- Platform/macOS/Display/VMDisplayWindow.swift | 465 ++++++++++++++++++ Platform/macOS/SettingsView.swift | 6 + UTM.xcodeproj/project.pbxproj | 4 + 6 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 Platform/macOS/Display/VMDisplayWindow.swift diff --git a/Platform/macOS/Display/Base.lproj/VMDisplayWindow.xib b/Platform/macOS/Display/Base.lproj/VMDisplayWindow.xib index fb581383..4bf80f37 100644 --- a/Platform/macOS/Display/Base.lproj/VMDisplayWindow.xib +++ b/Platform/macOS/Display/Base.lproj/VMDisplayWindow.xib @@ -31,7 +31,7 @@ - + diff --git a/Platform/macOS/Display/VMDisplayAppleDisplayWindowController.swift b/Platform/macOS/Display/VMDisplayAppleDisplayWindowController.swift index 7dba7ce9..12ebdbb4 100644 --- a/Platform/macOS/Display/VMDisplayAppleDisplayWindowController.swift +++ b/Platform/macOS/Display/VMDisplayAppleDisplayWindowController.swift @@ -53,6 +53,7 @@ class VMDisplayAppleDisplayWindowController: VMDisplayAppleWindowController { override func windowDidLoad() { mainView = VZVirtualMachineView() captureMouseToolbarButton.image = captureMouseToolbarButton.alternateImage // show capture keyboard image + (window as? VMDisplayWindow)?.isCameraHousingAreaAllowed = true screenChangedToken = NotificationCenter.default.addObserver(forName: NSWindow.didChangeScreenNotification, object: nil, queue: .main) { [weak self] _ in // update minSize when we change screens if let self = self, diff --git a/Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift b/Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift index 7b9237d7..7f8961c5 100644 --- a/Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift +++ b/Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift @@ -90,6 +90,7 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController { return } displayView.addSubview(metalView) + (window as? VMDisplayWindow)?.isCameraHousingAreaAllowed = true renderer = CSMetalRenderer.init(metalKitView: metalView) guard let renderer = self.renderer else { showErrorAlert(NSLocalizedString("Internal error.", comment: "VMDisplayMetalWindowController")) @@ -284,7 +285,8 @@ extension VMDisplayQemuMetalWindowController { return } self.displaySize = size - if self.isFullScreen { + // the flag is only set once the transition to full screen is over, until then the window must not be resized + if self.isFullScreen || window.styleMask.contains(.fullScreen) { _ = self.updateHostScaling(for: window, frameSize: window.frame.size) } else { self.updateHostFrame(forGuestResolution: size) diff --git a/Platform/macOS/Display/VMDisplayWindow.swift b/Platform/macOS/Display/VMDisplayWindow.swift new file mode 100644 index 00000000..d10dde30 --- /dev/null +++ b/Platform/macOS/Display/VMDisplayWindow.swift @@ -0,0 +1,465 @@ +// +// Copyright © 2026 Turing Software, LLC. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit + +/// VM window which can fill the area beside the camera housing when in full screen. +/// +/// A full screen window is normally kept below the camera housing by two separate mechanisms: AppKit insets the window +/// frame and the window server covers the inset area with an opaque menu bar. Both are undone here with private APIs, +/// all of which must be present or the window behaves like any other. None of them are used or hooked until the +/// option is enabled and a window enters full screen. +class VMDisplayWindow: NSWindow { + /// Set when the content of this window is a display which should fill the area beside the camera housing. + var isCameraHousingAreaAllowed: Bool = false + + /// Set from the start of the transition to full screen until the start of the transition out of it. + private var isFullScreenFrameKept: Bool = false + + /// Set between entering and exiting full screen, the style mask is not enough as it changes before the space does. + private var isInFullScreenSpace: Bool = false + + /// Set once we have been in the full screen space without covering the area beside the camera housing, such as when + /// tiled. The window server does not let a window return to it, so we stop asking until full screen is entered again. + private var isCameraHousingAreaLost: Bool = false + + /// Full screen space where the menu bar is currently hidden by us. + private var menuBarHiddenSpaceID: UInt64? + + /// Presentation options of our full screen space, which let the menu bar be revealed. + private var menuBarRevealablePresentationOptions: NSApplication.PresentationOptions? + + /// Set when the pointer has reached the top edge of the screen, until the menu bar it revealed is hidden again. + /// + /// The menu bar is otherwise revealed as soon as the pointer is over the area beside the camera housing, which would + /// make it impossible to use that area of the display. The window server decides that on its own, so we keep the + /// menu bar hidden with the presentation options until the pointer is at the very top. + private var isMenuBarRevealAllowed: Bool = false { + didSet { + updatePresentationOptions() + } + } + + private var topEdgeEventMonitor: Any? + + /// Set while the presentation options of the application are ours rather than the ones of the full screen space. + private var isPresentationOptionsForced: Bool = false + + /// AppKit applies the presentation options of the full screen space again when it changes spaces or menus. + private var presentationOptionsObservation: NSKeyValueObservation? + + @Setting("FullScreenUseCameraHousingArea") private var isCameraHousingAreaEnabled: Bool = false + + private var isCameraHousingAreaUsable: Bool { + guard #available(macOS 27, *) else { + return false + } + guard isCameraHousingAreaAllowed && isCameraHousingAreaEnabled else { + return false + } + // when the user wants the menu bar to always be visible, the area is never free + guard !UserDefaults.standard.bool(forKey: "AppleMenuBarVisibleInFullscreen") else { + return false + } + return FullScreenFrameHook.isInstalled && SkyLight.shared != nil && MenuBarReveal.isObserving + } + + /// AppKit parks the auto hidden toolbar behind the menu bar, so it has to be hidden along with it. + /// + /// The alpha of this window is updated by AppKit on every step of the reveal animation, so its views are hidden instead. + private var isFullScreenToolbarHidden: Bool = false { + didSet { + guard let contentView = fullScreenToolbarWindow?.contentView else { + return + } + (contentView.superview ?? contentView).isHidden = isFullScreenToolbarHidden + // while the menu bar cannot be revealed, AppKit parks the toolbar over our content + fullScreenToolbarWindow?.ignoresMouseEvents = isFullScreenToolbarHidden + } + } + + private var fullScreenToolbarWindow: NSWindow? { + guard let spaceID = menuBarHiddenSpaceID, let skyLight = SkyLight.shared else { + return nil + } + return NSApp.windows.first { window in + window !== self && window.className == "NSToolbarFullScreenWindow" && skyLight.spaceID(for: window) == spaceID + } + } + + override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { + super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) + let center = NotificationCenter.default + center.addObserver(self, selector: #selector(willEnterFullScreen), name: NSWindow.willEnterFullScreenNotification, object: self) + center.addObserver(self, selector: #selector(didEnterFullScreen), name: NSWindow.didEnterFullScreenNotification, object: self) + center.addObserver(self, selector: #selector(updateMenuBarHidden), name: NSWindow.didResizeNotification, object: self) + center.addObserver(self, selector: #selector(willLeaveFullScreen), name: NSWindow.willExitFullScreenNotification, object: self) + center.addObserver(self, selector: #selector(willLeaveFullScreen), name: NSWindow.willCloseNotification, object: self) + center.addObserver(self, selector: #selector(menuBarRevealDidChange), name: MenuBarReveal.didChangeNotification, object: nil) + center.addObserver(self, selector: #selector(otherWindowDidChangeOcclusionState), name: NSWindow.didChangeOcclusionStateNotification, object: nil) + center.addObserver(self, selector: #selector(updatePresentationOptions), name: NSWindow.didBecomeKeyNotification, object: self) + } + + override func toggleFullScreen(_ sender: Any?) { + if !styleMask.contains(.fullScreen) { + // installs our hooks before AppKit asks for the full screen frame + _ = isCameraHousingAreaUsable + } + super.toggleFullScreen(sender) + } + + /// Keeps the full screen frame once we have it. + /// + /// Our window controllers resize the window to fit the guest display, and AppKit constrains it to the safe area when + /// it is ordered front again. The window server does not let a window return to the area beside the camera housing + /// after it has left it, and even asking it for the frame we already have gets us moved out, so those are ignored. + override func setFrame(_ frameRect: NSRect, display flag: Bool) { + if isFullScreenFrameKept, styleMask.contains(.fullScreen), isCameraHousingAreaUsable, !isCameraHousingAreaLost, + let screen = screen, screen.safeAreaInsets.top > 0, frame == screen.frame, + fullScreenFrame(forAppKitFrame: FullScreenFrameHook.appKitFrame(for: self)) == screen.frame { + if flag { + display() + } + } else { + super.setFrame(frameRect, display: flag) + } + } + + /// Frame to use in full screen instead of the one AppKit would use. + /// + /// The window server only lets a window cover the area beside the camera housing if it does so from the moment it + /// enters the full screen space, so every call during the transition must return the same answer. + fileprivate func fullScreenFrame(forAppKitFrame frame: NSRect) -> NSRect { + guard isCameraHousingAreaUsable, !isCameraHousingAreaLost, let screen = screen, screen.safeAreaInsets.top > 0 else { + return frame + } + // a tiled window keeps its tile, only a window which has the screen to itself is extended + guard FullScreenFrameHook.tileFrame(for: self) == screen.frame else { + return frame + } + // the whole screen, which is the frame the window server accepts for a window that has it to itself + return screen.frame + } + + @objc private func willEnterFullScreen() { + isFullScreenFrameKept = true + _ = isCameraHousingAreaUsable + } + + @objc private func didEnterFullScreen() { + isInFullScreenSpace = true + updateMenuBarHidden() + } + + @objc private func willLeaveFullScreen() { + isFullScreenFrameKept = false + isInFullScreenSpace = false + isCameraHousingAreaLost = false + showMenuBar() + } + + /// Hides the menu bar of our full screen space if and only if we are covered by it. + @objc private func updateMenuBarHidden() { + if isInFullScreenSpace, let screen = screen, frame != screen.frame { + isCameraHousingAreaLost = true + } + guard isInFullScreenSpace, isCameraHousingAreaUsable, !isCameraHousingAreaLost, let screen = screen, + screen.safeAreaInsets.top > 0, frame == screen.frame, + let skyLight = SkyLight.shared, let spaceID = skyLight.fullScreenSpaceID(for: self) else { + showMenuBar() + return + } + guard menuBarHiddenSpaceID != spaceID else { + return + } + showMenuBar() + logger.debug("hiding menu bar in full screen space \(spaceID)") + menuBarHiddenSpaceID = spaceID + menuBarRevealablePresentationOptions = NSApp.presentationOptions + presentationOptionsObservation = NSApp.observe(\.currentSystemPresentationOptions) { [weak self] _, _ in + DispatchQueue.main.async { + self?.updatePresentationOptions() + } + } + topEdgeEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { [weak self] event in + self?.pointerDidMove() + return event + } + isMenuBarRevealAllowed = false + applyMenuBarReveal() + } + + @objc private func showMenuBar() { + guard let spaceID = menuBarHiddenSpaceID else { + return + } + if let topEdgeEventMonitor = topEdgeEventMonitor { + NSEvent.removeMonitor(topEdgeEventMonitor) + self.topEdgeEventMonitor = nil + } + presentationOptionsObservation = nil + // hands the presentation options of the space back to AppKit + isMenuBarRevealAllowed = true + menuBarRevealablePresentationOptions = nil + isFullScreenToolbarHidden = false + SkyLight.shared?.setMenuBarAlpha(1, inSpace: spaceID) + menuBarHiddenSpaceID = nil + } + + @objc private func updatePresentationOptions() { + guard let revealableOptions = menuBarRevealablePresentationOptions else { + return + } + // the menu bar is only kept hidden for the space we are in, but what we did must be undone in any case + let options: NSApplication.PresentationOptions? + if !isMenuBarRevealAllowed && isKeyWindow { + options = [.fullScreen, .hideMenuBar, .hideDock] + isPresentationOptionsForced = true + } else if isPresentationOptionsForced { + options = revealableOptions + isPresentationOptionsForced = false + } else { + options = nil + } + if let options = options, NSApp.presentationOptions != options { + NSApp.presentationOptions = options + } + } + + private func pointerDidMove() { + guard isKeyWindow, let spaceID = menuBarHiddenSpaceID, let screen = screen else { + return + } + let location = NSEvent.mouseLocation + if !isMenuBarRevealAllowed && location.y >= screen.frame.maxY - 1 { + isMenuBarRevealAllowed = true + } else if isMenuBarRevealAllowed && location.y < screen.frame.maxY - screen.safeAreaInsets.top && !MenuBarReveal.state(inSpace: spaceID).isMenuBarRevealed { + // the pointer left the top without the menu bar being revealed + isMenuBarRevealAllowed = false + } + } + + private func applyMenuBarReveal() { + guard let spaceID = menuBarHiddenSpaceID else { + return + } + let reveal = MenuBarReveal.state(inSpace: spaceID) + SkyLight.shared?.setMenuBarAlpha(reveal.isMenuBarRevealed ? 1 : 0, inSpace: spaceID) + isFullScreenToolbarHidden = !reveal.isToolbarRevealed + } + + /// AppKit shows the full screen toolbar window some time after we have entered full screen. + @objc private func otherWindowDidChangeOcclusionState(_ notification: Notification) { + if menuBarHiddenSpaceID != nil, let window = notification.object as? NSWindow, window === fullScreenToolbarWindow { + applyMenuBarReveal() + } + } + + @objc private func menuBarRevealDidChange(_ notification: Notification) { + guard let spaceID = menuBarHiddenSpaceID, + let userInfo = notification.userInfo, + userInfo[MenuBarReveal.spaceIDKey] as? UInt64 == spaceID, + let duration = userInfo[MenuBarReveal.durationKey] as? TimeInterval else { + return + } + let reveal = MenuBarReveal.state(inSpace: spaceID) + if reveal.isMenuBarRevealed || reveal.isToolbarRevealed { + applyMenuBarReveal() + } + if !reveal.isMenuBarRevealed || !reveal.isToolbarRevealed { + // they are animated out, do not show our content until they are gone + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + guard let self = self, self.menuBarHiddenSpaceID == spaceID else { + return + } + self.applyMenuBarReveal() + if !MenuBarReveal.state(inSpace: spaceID).isMenuBarRevealed { + self.isMenuBarRevealAllowed = false + } + } + } + } +} + +/// Replaces the frame AppKit uses for a window in full screen, installed the first time it is needed. +private enum FullScreenFrameHook { + private typealias FrameFunction = @convention(c) (NSWindow, Selector) -> NSRect + private typealias FrameBlock = @convention(block) (VMDisplayWindow) -> NSRect + + private static let frameTypeEncoding = "{CGRect={CGPoint=dd}{CGSize=dd}}16@0:8" + private static let frameForFullScreenModeSelector = NSSelectorFromString("_frameForFullScreenMode") + private static let tileFrameForFullScreenSelector = NSSelectorFromString("_tileFrameForFullScreen") + + private static var frameForFullScreenMode: FrameFunction? + private static var tileFrameForFullScreen: FrameFunction? + + static let isInstalled: Bool = { + func frameFunction(for selector: Selector) -> FrameFunction? { + guard let method = class_getInstanceMethod(NSWindow.self, selector), + let types = method_getTypeEncoding(method), String(cString: types) == frameTypeEncoding else { + logger.debug("cannot use \(NSStringFromSelector(selector))") + return nil + } + return unsafeBitCast(method_getImplementation(method), to: FrameFunction.self) + } + guard let appKitFrameForFullScreenMode = frameFunction(for: frameForFullScreenModeSelector), + let appKitTileFrameForFullScreen = frameFunction(for: tileFrameForFullScreenSelector) else { + return false + } + let selector = frameForFullScreenModeSelector + let block: FrameBlock = { window in + window.fullScreenFrame(forAppKitFrame: appKitFrameForFullScreenMode(window, selector)) + } + guard class_addMethod(VMDisplayWindow.self, selector, imp_implementationWithBlock(block), frameTypeEncoding) else { + return false + } + frameForFullScreenMode = appKitFrameForFullScreenMode + tileFrameForFullScreen = appKitTileFrameForFullScreen + return true + }() + + static func appKitFrame(for window: NSWindow) -> NSRect { + frameForFullScreenMode?(window, frameForFullScreenModeSelector) ?? window.frame + } + + static func tileFrame(for window: NSWindow) -> NSRect? { + tileFrameForFullScreen?(window, tileFrameForFullScreenSelector) + } +} + +/// Private window server functions, resolved at runtime. +private struct SkyLight { + private typealias MainConnectionIDFunction = @convention(c) () -> Int32 + private typealias CopySpacesForWindowsFunction = @convention(c) (Int32, Int32, CFArray) -> Unmanaged? + private typealias SpaceGetTypeFunction = @convention(c) (Int32, UInt64) -> Int32 + private typealias TransactionCreateFunction = @convention(c) (Int32) -> Unmanaged? + private typealias TransactionSetMenuBarSystemOverrideAlphaFunction = @convention(c) (CFTypeRef, UInt64, Float) -> Void + private typealias TransactionCommitFunction = @convention(c) (CFTypeRef, Int32) -> Int32 + + private let mainConnectionID: MainConnectionIDFunction + private let copySpacesForWindows: CopySpacesForWindowsFunction + private let spaceGetType: SpaceGetTypeFunction + private let transactionCreate: TransactionCreateFunction + private let transactionSetMenuBarSystemOverrideAlpha: TransactionSetMenuBarSystemOverrideAlphaFunction + private let transactionCommit: TransactionCommitFunction + + static let shared: SkyLight? = { + guard let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY) else { + logger.debug("SkyLight is not available") + return nil + } + return SkyLight(handle: handle) + }() + + private init?(handle: UnsafeMutableRawPointer) { + func symbol(_ name: String) -> T? { + guard let symbol = dlsym(handle, name) else { + logger.debug("SkyLight is missing \(name)") + return nil + } + return unsafeBitCast(symbol, to: T.self) + } + guard let mainConnectionID: MainConnectionIDFunction = symbol("SLSMainConnectionID"), + let copySpacesForWindows: CopySpacesForWindowsFunction = symbol("SLSCopySpacesForWindows"), + let spaceGetType: SpaceGetTypeFunction = symbol("SLSSpaceGetType"), + let transactionCreate: TransactionCreateFunction = symbol("SLSTransactionCreate"), + let transactionSetMenuBarSystemOverrideAlpha: TransactionSetMenuBarSystemOverrideAlphaFunction = symbol("SLSTransactionSetMenuBarSystemOverrideAlpha"), + let transactionCommit: TransactionCommitFunction = symbol("SLSTransactionCommit") else { + return nil + } + self.mainConnectionID = mainConnectionID + self.copySpacesForWindows = copySpacesForWindows + self.spaceGetType = spaceGetType + self.transactionCreate = transactionCreate + self.transactionSetMenuBarSystemOverrideAlpha = transactionSetMenuBarSystemOverrideAlpha + self.transactionCommit = transactionCommit + } + + /// - Returns: Space that the window is in, if it is in exactly one + func spaceID(for window: NSWindow) -> UInt64? { + let allSpacesMask: Int32 = 7 + let windows = [NSNumber(value: window.windowNumber)] as CFArray + guard let spaces = copySpacesForWindows(mainConnectionID(), allSpacesMask, windows)?.takeRetainedValue() as? [NSNumber], + spaces.count == 1 else { + return nil + } + return spaces[0].uint64Value + } + + /// - Returns: Space that the window is in, if that is a full screen space + func fullScreenSpaceID(for window: NSWindow) -> UInt64? { + let fullScreenSpaceType: Int32 = 4 + guard let spaceID = spaceID(for: window), spaceGetType(mainConnectionID(), spaceID) == fullScreenSpaceType else { + return nil + } + return spaceID + } + + /// The override only exists for the lifetime of the space, which for full screen is until the window leaves it. + func setMenuBarAlpha(_ alpha: Float, inSpace spaceID: UInt64) { + guard let transaction = transactionCreate(mainConnectionID())?.takeRetainedValue() else { + return + } + transactionSetMenuBarSystemOverrideAlpha(transaction, spaceID, alpha) + _ = transactionCommit(transaction, 1) + } +} + +/// Observes AppKit being told to reveal or hide the menu bar in a full screen space. +private enum MenuBarReveal { + static let didChangeNotification = Notification.Name("VMDisplayWindowMenuBarRevealDidChange") + static let spaceIDKey = "SpaceID" + static let durationKey = "Duration" + + private typealias DispatchRevealChangedFunction = @convention(c) (AnyClass, Selector, UInt64, Double, Double, Double) -> Void + private typealias DispatchRevealChangedBlock = @convention(block) (AnyClass, UInt64, Double, Double, Double) -> Void + + struct State { + var isMenuBarRevealed: Bool = false + var isToolbarRevealed: Bool = false + } + + private static var states = [UInt64: State]() + + static let isObserving: Bool = { + let selector = NSSelectorFromString("_dispatchAgentRevealChangedForSpaceID:menuBarRevealTarget:toolbarRevealTarget:duration:") + guard let method = class_getClassMethod(NSMenu.self, selector), + let types = method_getTypeEncoding(method), String(cString: types) == "v48@0:8q16d24d32d40" else { + logger.debug("cannot observe menu bar reveal") + return false + } + let original = unsafeBitCast(method_getImplementation(method), to: DispatchRevealChangedFunction.self) + let block: DispatchRevealChangedBlock = { cls, spaceID, menuBarRevealTarget, toolbarRevealTarget, duration in + original(cls, selector, spaceID, menuBarRevealTarget, toolbarRevealTarget, duration) + let update = { + let state = State(isMenuBarRevealed: menuBarRevealTarget > 0, isToolbarRevealed: toolbarRevealTarget > 0) + states[spaceID] = state.isMenuBarRevealed || state.isToolbarRevealed ? state : nil + NotificationCenter.default.post(name: didChangeNotification, object: nil, userInfo: [spaceIDKey: spaceID, durationKey: duration]) + } + if Thread.isMainThread { + update() + } else { + DispatchQueue.main.async(execute: update) + } + } + method_setImplementation(method, imp_implementationWithBlock(block)) + return true + }() + + static func state(inSpace spaceID: UInt64) -> State { + states[spaceID] ?? State() + } +} diff --git a/Platform/macOS/SettingsView.swift b/Platform/macOS/SettingsView.swift index 9f7af9c0..ac832d74 100644 --- a/Platform/macOS/SettingsView.swift +++ b/Platform/macOS/SettingsView.swift @@ -198,6 +198,7 @@ struct ApplicationSettingsView: View { struct DisplaySettingsView: View { @AppStorage("NoScreenshot") var isNoScreenshot = false @AppStorage("NoSaveScreenshot") var isNoSaveScreenshot = false + @AppStorage("FullScreenUseCameraHousingArea") var isFullScreenUseCameraHousingArea = false @AppStorage("QEMURendererBackend") var qemuRendererBackend: UTMQEMURendererBackend = .qemuRendererBackendDefault @AppStorage("QEMUVulkanDriver") var qemuVulkanDriver: UTMQEMUVulkanDriver = .qemuVulkanDriverDefault @AppStorage("QEMUDirectXDriver") var qemuDirectXDriver: UTMQEMUDirectXDriver = .qemuDirectXDriverDefault @@ -237,6 +238,11 @@ struct DisplaySettingsView: View { Text("Do not save VM screenshot to disk") }.help("If enabled, any existing screenshot will be deleted the next time the VM is started.") .disabled(isNoScreenshot) + if #available(macOS 27, *) { + Toggle(isOn: $isFullScreenUseCameraHousingArea) { + Text("Fill the area beside the camera housing in full screen") + }.help("If enabled, a full screen VM display will use the entire screen including the area beside the built-in camera. The menu bar will cover this area while it is revealed.") + } } Section(header: Text("QEMU Graphics Acceleration")) { diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 9f8b3729..54320d1d 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -653,6 +653,7 @@ CE611BEB29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */; }; CE611BEC29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */; }; CE611BED29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */; }; + CE5A7E112E9F4A1100C0FFEE /* VMDisplayWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5A7E102E9F4A1100C0FFEE /* VMDisplayWindow.swift */; }; CE612AC624D3B50700FA6300 /* VMDisplayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */; }; CE63B0DB2F0ED4FF00C59D13 /* vulkan_kosmickrisp.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = CE63B0D72F0ED4FE00C59D13 /* vulkan_kosmickrisp.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE63B0DD2F0ED4FF00C59D13 /* vulkan.1.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = CE63B0D82F0ED4FE00C59D13 /* vulkan.1.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -2030,6 +2031,7 @@ CE59A7AF2AA69AE400E5FFBD /* UTMScriptingUSBDeviceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingUSBDeviceImpl.swift; sourceTree = ""; }; CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMReleaseHelper.swift; sourceTree = ""; }; CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMReleaseNotesView.swift; sourceTree = ""; }; + CE5A7E102E9F4A1100C0FFEE /* VMDisplayWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayWindow.swift; sourceTree = ""; }; CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayWindowController.swift; sourceTree = ""; }; CE63B0D72F0ED4FE00C59D13 /* vulkan_kosmickrisp.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = vulkan_kosmickrisp.framework; path = "$(SYSROOT_DIR)/Frameworks/vulkan_kosmickrisp.framework"; sourceTree = ""; }; CE63B0D82F0ED4FE00C59D13 /* vulkan.1.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = vulkan.1.framework; path = "$(SYSROOT_DIR)/Frameworks/vulkan.1.framework"; sourceTree = ""; }; @@ -2645,6 +2647,7 @@ children = ( A1C4F00130C1200000CA9501 /* CapsLockRemapper.swift */, CE061CDD289E6DC30000351C /* VMDisplayWindow.xib */, + CE5A7E102E9F4A1100C0FFEE /* VMDisplayWindow.swift */, CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */, 84F746B8276FF40900A20C87 /* VMDisplayAppleWindowController.swift */, 845F170A289CB07200944904 /* VMDisplayAppleDisplayWindowController.swift */, @@ -4167,6 +4170,7 @@ 841E997728AA1191003C6CB6 /* UTMRegistry.swift in Sources */, 841E997B28AA119B003C6CB6 /* UTMRegistryEntry.swift in Sources */, CE2D956424AD4F990059923A /* VMConfigNetworkPortForwardView.swift in Sources */, + CE5A7E112E9F4A1100C0FFEE /* VMDisplayWindow.swift in Sources */, CE612AC624D3B50700FA6300 /* VMDisplayWindowController.swift in Sources */, 53A0BDD726D79FE40010EDC5 /* SavePanel.swift in Sources */, 843BF82A28441FAF0029D60D /* QEMUConstantGenerated.swift in Sources */,