mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
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
235 lines
9.2 KiB
Swift
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
|
|
}
|
|
}
|