diff --git a/Platform/iOS/Display/VMDisplayMetalViewController.h b/Platform/iOS/Display/VMDisplayMetalViewController.h index 21ad7ae1..8bf301bd 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController.h +++ b/Platform/iOS/Display/VMDisplayMetalViewController.h @@ -42,6 +42,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong) NSMutableArray *mutableKeyCommands; +@property (nonatomic) BOOL isDynamicResolutionSupported; + - (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; - (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE; - (instancetype)initWithDisplay:(CSDisplay *)display input:(nullable CSInput *)input NS_DESIGNATED_INITIALIZER; diff --git a/Platform/iOS/Display/VMDisplayMetalViewController.m b/Platform/iOS/Display/VMDisplayMetalViewController.m index 02a1ffe8..934bf509 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController.m +++ b/Platform/iOS/Display/VMDisplayMetalViewController.m @@ -29,11 +29,15 @@ #import "UTM-Swift.h" @import CocoaSpiceRenderer; +static const NSInteger kResizeDebounceSecs = 1; +static const NSInteger kResizeTimeoutSecs = 5; + @interface VMDisplayMetalViewController () @property (nonatomic, nullable) CSMetalRenderer *renderer; -@property (nonatomic) CGFloat windowScaling; -@property (nonatomic) CGPoint windowOrigin; +@property (nonatomic, nullable) id debounceResize; +@property (nonatomic, nullable) id cancelResize; +@property (nonatomic) BOOL ignoreNextResize; @end @@ -43,9 +47,6 @@ if (self = [super initWithNibName:nil bundle:nil]) { self.vmDisplay = display; self.vmInput = input; - self.windowScaling = 1.0; - self.windowOrigin = CGPointZero; - [self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:NSKeyValueObservingOptionNew context:nil]; } return self; } @@ -128,11 +129,13 @@ [super viewWillDisappear:animated]; [self stopGCMouse]; [self.vmDisplay removeRenderer:self.renderer]; + [self removeObserver:self forKeyPath:@"vmDisplay.displaySize"]; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; self.delegate.displayViewSize = [self convertSizeToNative:self.view.bounds.size]; + [self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial) context:nil]; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { @@ -140,10 +143,12 @@ [coordinator animateAlongsideTransition:nil completion:^(id _Nonnull context) { self.delegate.displayViewSize = [self convertSizeToNative:size]; [self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize]; + if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) { + if (!CGSizeEqualToSize(size, self.vmDisplay.displaySize)) { + [self requestResolutionChangeToSize:size]; + } + } }]; - if (self.delegate.qemuDisplayIsDynamicResolution) { - [self displayResize:size]; - } } - (void)enterSuspendedWithIsBusy:(BOOL)busy { @@ -161,8 +166,8 @@ [super enterLive]; self.prefersPointerLocked = YES; self.view.window.isIndirectPointerTouchIgnored = YES; - if (self.delegate.qemuDisplayIsDynamicResolution) { - [self displayResize:self.view.bounds.size]; + if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) { + [self requestResolutionChangeToSize:self.view.bounds.size]; } if (self.delegate.qemuHasClipboardSharing) { [[UTMPasteboard generalPasteboard] requestPollingModeForObject:self]; @@ -200,11 +205,21 @@ return size; } -- (void)displayResize:(CGSize)size { - UTMLog(@"resizing to (%f, %f)", size.width, size.height); - size = [self convertSizeToNative:size]; - CGRect bounds = CGRectMake(0, 0, size.width, size.height); - [self.vmDisplay requestResolution:bounds]; +- (void)requestResolutionChangeToSize:(CGSize)size { + self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{ + UTMLog(@"DISPLAY: requesting resolution (%f, %f)", size.width, size.height); + CGSize newSize = [self convertSizeToNative:size]; + CGRect bounds = CGRectMake(0, 0, newSize.width, newSize.height); + self.debounceResize = nil; +#if defined(TARGET_OS_VISION) && TARGET_OS_VISION + self.cancelResize = [self debounce:kResizeTimeoutSecs context:self.cancelResize action:^{ + self.cancelResize = nil; + UTMLog(@"DISPLAY: requesting resolution cancelled"); + [self resizeWindowToDisplaySize]; + }]; +#endif + [self.vmDisplay requestResolution:bounds]; + }]; } - (void)setVmDisplay:(CSDisplay *)display { @@ -217,8 +232,6 @@ - (void)setDisplayScaling:(CGFloat)scaling origin:(CGPoint)origin { self.vmDisplay.viewportOrigin = origin; - self.windowScaling = scaling; - self.windowOrigin = origin; if (!self.delegate.qemuDisplayIsNativeResolution) { scaling = CGPointToPixel(scaling); } @@ -229,25 +242,67 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:@"vmDisplay.displaySize"]) { -#if defined(TARGET_OS_VISION) && TARGET_OS_VISION - dispatch_async(dispatch_get_main_queue(), ^{ - CGSize minSize = self.vmDisplay.displaySize; - if (self.delegate.qemuDisplayIsNativeResolution) { - minSize.width = CGPixelToPoint(minSize.width); - minSize.height = CGPixelToPoint(minSize.height); - } - CGSize displaySize = CGSizeMake(minSize.width * self.windowScaling, minSize.height * self.windowScaling); - CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference); - UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:displaySize]; - geoPref.minimumSize = minSize; - geoPref.maximumSize = maxSize; - geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform; - [self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil]; - }); -#else - [self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize]; -#endif + UTMLog(@"DISPLAY: vmDisplay.displaySize changed"); + if (self.cancelResize) { + [self debounce:0 context:self.cancelResize action:^{}]; + self.cancelResize = nil; + } + self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{ + [self resizeWindowToDisplaySize]; + }]; } } +- (void)setIsDynamicResolutionSupported:(BOOL)isDynamicResolutionSupported { + if (_isDynamicResolutionSupported != isDynamicResolutionSupported) { + _isDynamicResolutionSupported = isDynamicResolutionSupported; + UTMLog(@"DISPLAY: isDynamicResolutionSupported = %d", isDynamicResolutionSupported); + if (self.delegate.qemuDisplayIsDynamicResolution) { + if (isDynamicResolutionSupported) { + [self requestResolutionChangeToSize:self.view.bounds.size]; + } else { + [self resizeWindowToDisplaySize]; + } + } + } +} + +- (void)resizeWindowToDisplaySize { + CGSize displaySize = self.vmDisplay.displaySize; + UTMLog(@"DISPLAY: request window resize to (%f, %f)", displaySize.width, displaySize.height); +#if defined(TARGET_OS_VISION) && TARGET_OS_VISION + CGSize minSize = displaySize; + if (self.delegate.qemuDisplayIsNativeResolution) { + minSize.width = CGPixelToPoint(minSize.width); + minSize.height = CGPixelToPoint(minSize.height); + } + CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference); + UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:minSize]; + if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) { + geoPref.minimumSize = CGSizeMake(800, 600); + geoPref.maximumSize = maxSize; + geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsFreeform; + } else { + geoPref.minimumSize = minSize; + geoPref.maximumSize = maxSize; + geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform; + } + dispatch_async(dispatch_get_main_queue(), ^{ + CGSize currentViewSize = self.view.bounds.size; + UTMLog(@"DISPLAY: old view size = (%f, %f)", currentViewSize.width, currentViewSize.height); + if (CGSizeEqualToSize(minSize, currentViewSize)) { + // since `-viewWillTransitionToSize:withTransitionCoordinator:` is not called + self.delegate.displayViewSize = [self convertSizeToNative:currentViewSize]; + [self.delegate display:self.vmDisplay didResizeTo:displaySize]; + } + [self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil]; + }); +#else + if (CGSizeEqualToSize(displaySize, CGSizeZero)) { + return; + } + [self.delegate display:self.vmDisplay didResizeTo:displaySize]; +#endif +} + @end diff --git a/Platform/iOS/Display/VMDisplayViewController.swift b/Platform/iOS/Display/VMDisplayViewController.swift index c631ca08..211602a9 100644 --- a/Platform/iOS/Display/VMDisplayViewController.swift +++ b/Platform/iOS/Display/VMDisplayViewController.swift @@ -134,4 +134,15 @@ public extension VMDisplayViewController { func integerForSetting(_ key: String) -> Int { return UserDefaults.standard.integer(forKey: key) } + + @discardableResult + func debounce(_ delaySeconds: Int, context: Any? = nil, action: @escaping () -> Void) -> Any { + if context != nil { + let previous = context as! DispatchWorkItem + previous.cancel() + } + let item = DispatchWorkItem(block: action) + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(delaySeconds), execute: item) + return item + } } diff --git a/Platform/iOS/VMDisplayHostedView.swift b/Platform/iOS/VMDisplayHostedView.swift index 1cbd001b..d0fb0fae 100644 --- a/Platform/iOS/VMDisplayHostedView.swift +++ b/Platform/iOS/VMDisplayHostedView.swift @@ -190,6 +190,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable { } // some obscure SwiftUI error means we cannot refer to Coordinator's state binding vc.setDisplayScaling(state.displayScale, origin: state.displayOrigin) + vc.isDynamicResolutionSupported = state.isDynamicResolutionSupported } case .serial(let serial, _): if let vc = uiViewController as? VMDisplayTerminalViewController { diff --git a/Platform/iOS/VMSessionState.swift b/Platform/iOS/VMSessionState.swift index 74aef834..565f39f3 100644 --- a/Platform/iOS/VMSessionState.swift +++ b/Platform/iOS/VMSessionState.swift @@ -78,7 +78,9 @@ import SwiftUI @Published var externalWindowBinding: Binding? @Published var hasShownMemoryWarning: Bool = false - + + @Published var isDynamicResolutionSupported: Bool = false + private var hasAutosave: Bool = false init(for vm: any UTMSpiceVirtualMachine) { @@ -291,6 +293,12 @@ extension VMSessionState: UTMSpiceIODelegate { } } #endif + + nonisolated func spiceDynamicResolutionSupportDidChange(_ supported: Bool) { + Task { @MainActor in + isDynamicResolutionSupported = supported + } + } } #if WITH_USB diff --git a/Platform/iOS/VMWindowState.swift b/Platform/iOS/VMWindowState.swift index 353bee95..c9452fc8 100644 --- a/Platform/iOS/VMWindowState.swift +++ b/Platform/iOS/VMWindowState.swift @@ -71,6 +71,8 @@ struct VMWindowState: Identifiable { var isRunning: Bool = false var alert: Alert? + + var isDynamicResolutionSupported: Bool = false } // MARK: - VM action alerts diff --git a/Platform/iOS/VMWindowView.swift b/Platform/iOS/VMWindowView.swift index 7108e1d7..956d533d 100644 --- a/Platform/iOS/VMWindowView.swift +++ b/Platform/iOS/VMWindowView.swift @@ -171,6 +171,9 @@ struct VMWindowView: View { .onChange(of: session.vmState) { [oldValue = session.vmState] newValue in vmStateUpdated(from: oldValue, to: newValue) } + .onChange(of: session.isDynamicResolutionSupported) { newValue in + state.isDynamicResolutionSupported = newValue + } .onReceive(keyboardDidShowNotification) { _ in state.isKeyboardShown = true state.isKeyboardRequested = true