Files
UTM/Platform/macOS/Display/VMDisplayAppleDisplayWindowController.swift
osy 230f400ddc 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
2026-09-19 18:27:21 -05:00

235 lines
9.2 KiB
Swift

//
// Copyright © 2022 osy. 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 Foundation
import Virtualization
class VMDisplayAppleDisplayWindowController: VMDisplayAppleWindowController {
var appleView: VZVirtualMachineView! {
mainView as? VZVirtualMachineView
}
override var contentView: NSView? {
appleView
}
var supportsReconfiguration: Bool {
guard #available(macOS 14, *) else {
return false
}
guard let display = appleVM.apple?.graphicsDevices.first?.displays.first else {
return false
}
return display.value(forKey: "_supportsReconfiguration") as? Bool ?? false
}
var isDynamicResolution: Bool {
appleConfig.displays.first!.isDynamicResolution
}
private let checkSupportsReconfigurationTimeoutPeriod: Double = 1
private var checkSupportsReconfigurationTimeoutAttempts: Int = 60
private var aspectRatioLocked: Bool = false
private var screenChangedToken: Any?
private var isFullscreen: Bool = false
private var cancelCheckSupportsReconfiguration: DispatchWorkItem?
private var isReadyToSaveResolution: Bool = false
@Setting("FullScreenAutoCapture") private var isFullScreenAutoCapture: Bool = false
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,
let window = window,
let primaryDisplay = appleConfig.displays.first,
!supportsReconfiguration || !isDynamicResolution {
window.contentMinSize = contentMinSize(in: window, for: windowSize(for: primaryDisplay))
}
}
super.windowDidLoad()
}
override func windowWillClose(_ notification: Notification) {
if let screenChangedToken = screenChangedToken {
NotificationCenter.default.removeObserver(screenChangedToken)
}
screenChangedToken = nil
stopPollingForSupportsReconfiguration()
super.windowWillClose(notification)
}
override func enterLive() {
appleView.isHidden = false
appleView.virtualMachine = appleVM.apple
screenshotView.isHidden = true
if #available(macOS 14, *) {
appleView.automaticallyReconfiguresDisplay = isDynamicResolution
startPollingForSupportsReconfiguration()
}
super.enterLive()
}
override func enterSuspended(isBusy busy: Bool) {
if !busy {
appleView.virtualMachine = nil
appleView.isHidden = true
screenshotView.image = vm.screenshot?.image
screenshotView.isHidden = false
}
captureMouseToolbarButton.state = .off
captureMouseButtonPressed(self)
stopPollingForSupportsReconfiguration()
super.enterSuspended(isBusy: busy)
}
private func windowSize(for display: UTMAppleConfigurationDisplay) -> CGSize {
let currentScreenScale = window?.screen?.backingScaleFactor ?? 1.0
let useHidpi = display.pixelsPerInch >= 226
let scale = useHidpi ? currentScreenScale : 1.0
return CGSize(width: CGFloat(display.widthInPixels) / scale, height: CGFloat(display.heightInPixels) / scale)
}
override func updateWindowFrame() {
guard let window = window else {
return
}
guard let primaryDisplay = appleConfig.displays.first else {
return //FIXME: add multiple displays
}
let size = windowSize(for: primaryDisplay)
let frame = window.frameRect(forContentRect: CGRect(origin: window.frame.origin, size: size))
window.contentAspectRatio = size
aspectRatioLocked = true
let dynamicResolution = supportsReconfiguration && isDynamicResolution
if dynamicResolution {
window.minSize = NSSize(width: 400, height: 400)
} else {
window.minSize = contentMinSize(in: window, for: size)
}
if !dynamicResolution || !restoreDynamicResolution(for: window) {
window.setFrame(frame, display: false, animate: true)
}
super.updateWindowFrame()
}
override func resizeConsoleButtonPressed(_ sender: Any) {
updateWindowFrame()
}
override func captureMouseButtonPressed(_ sender: Any) {
appleView!.capturesSystemKeys = captureMouseToolbarButton.state == .on
}
func windowDidEnterFullScreen(_ notification: Notification) {
isFullscreen = true
if isFullScreenAutoCapture {
captureMouseToolbarButton.state = .on
captureMouseButtonPressed(self)
}
saveDynamicResolution()
}
func windowDidExitFullScreen(_ notification: Notification) {
isFullscreen = false
if isFullScreenAutoCapture {
captureMouseToolbarButton.state = .off
captureMouseButtonPressed(self)
}
saveDynamicResolution()
}
func windowDidResize(_ notification: Notification) {
if supportsReconfiguration && isDynamicResolution {
if aspectRatioLocked {
window!.resizeIncrements = NSSize(width: 1.0, height: 1.0)
window!.minSize = NSSize(width: 400, height: 400)
aspectRatioLocked = false
}
saveDynamicResolution()
}
}
private func contentMinSize(in window: NSWindow, for scaledSize: CGSize) -> CGSize {
guard let screenSize = window.screen?.visibleFrame.size else {
return scaledSize
}
let excessSize = window.frameRect(forContentRect: .zero).size
// if the window is larger than our host screen, shrink the min size allowed
let widthScale = (screenSize.width - excessSize.width) / scaledSize.width
let heightScale = (screenSize.height - excessSize.height) / scaledSize.height
let scale = min(min(widthScale, heightScale), 1.0)
return CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale)
}
}
// MARK: - Save and restore resolution
@MainActor extension VMDisplayAppleDisplayWindowController {
func saveDynamicResolution() {
guard supportsReconfiguration && isDynamicResolution && isReadyToSaveResolution else {
return
}
var resolution = UTMRegistryEntry.Resolution()
resolution.isFullscreen = isFullscreen
resolution.size = window!.contentRect(forFrameRect: window!.frame).size
vm.registryEntry.resolutionSettings[0] = resolution
}
@discardableResult
func restoreDynamicResolution(for window: NSWindow) -> Bool {
isReadyToSaveResolution = true
guard let resolution = vm.registryEntry.resolutionSettings[0] else {
return false
}
if resolution.isFullscreen && !isFullscreen {
window.toggleFullScreen(self)
} else if resolution.size != .zero {
let frame = window.frameRect(forContentRect: CGRect(origin: window.frame.origin, size: resolution.size))
window.setFrame(frame, display: false, animate: true)
}
return true
}
func startPollingForSupportsReconfiguration() {
cancelCheckSupportsReconfiguration?.cancel()
cancelCheckSupportsReconfiguration = DispatchWorkItem { [weak self] in
guard let self = self else {
return
}
if supportsReconfiguration, let window = window {
restoreDynamicResolution(for: window)
checkSupportsReconfigurationTimeoutAttempts = 0
cancelCheckSupportsReconfiguration = nil
} else if checkSupportsReconfigurationTimeoutAttempts > 0 {
checkSupportsReconfigurationTimeoutAttempts -= 1
DispatchQueue.main.asyncAfter(deadline: .now() + checkSupportsReconfigurationTimeoutPeriod, execute: cancelCheckSupportsReconfiguration!)
} else {
cancelCheckSupportsReconfiguration = nil
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + checkSupportsReconfigurationTimeoutPeriod, execute: cancelCheckSupportsReconfiguration!)
}
func stopPollingForSupportsReconfiguration() {
cancelCheckSupportsReconfiguration?.cancel()
cancelCheckSupportsReconfiguration = nil
isReadyToSaveResolution = false
}
}