Преглед изворни кода

display(iOS): refactor VM display window to SwiftUI

In preparation for multiple-display support, the existing view controller is
decoupled from UTMVirtualMachine and UTMSpiceIO and now interfaces solely
with VMDisplayViewControllerDelegate. This allows us to then wrap the VC into
a UIViewControllerRepresentable and isolate much of the state into
VMWindowState (a single window) and VMSessionState (entire VM). Finally, the
loading screens are re-implemented in SwiftUI.
osy пре 3 година
родитељ
комит
f80002feac

+ 10 - 2
Managers/UTMQemuVirtualMachine.m

@@ -38,6 +38,7 @@ NSString *const kSuspendSnapshotName = @"suspend";
 @property (nonatomic, readwrite, nullable) UTMQemuManager *qemu;
 @property (nonatomic, readwrite, nullable) UTMQemuManager *qemu;
 @property (nonatomic, readwrite, nullable) UTMQemuSystem *system;
 @property (nonatomic, readwrite, nullable) UTMQemuSystem *system;
 @property (nonatomic, readwrite, nullable) UTMSpiceIO *ioService;
 @property (nonatomic, readwrite, nullable) UTMSpiceIO *ioService;
+@property (nonatomic, weak) id<UTMSpiceIODelegate> ioServiceDelegate;
 @property (nonatomic) dispatch_queue_t vmOperations;
 @property (nonatomic) dispatch_queue_t vmOperations;
 @property (nonatomic, nullable) dispatch_semaphore_t qemuWillQuitEvent;
 @property (nonatomic, nullable) dispatch_semaphore_t qemuWillQuitEvent;
 @property (nonatomic, nullable) dispatch_semaphore_t qemuDidExitEvent;
 @property (nonatomic, nullable) dispatch_semaphore_t qemuDidExitEvent;
@@ -49,11 +50,16 @@ NSString *const kSuspendSnapshotName = @"suspend";
 @implementation UTMQemuVirtualMachine
 @implementation UTMQemuVirtualMachine
 
 
 - (id<UTMSpiceIODelegate>)ioDelegate {
 - (id<UTMSpiceIODelegate>)ioDelegate {
-    return self.ioService.delegate;
+    return self.ioService ? self.ioService.delegate : self.ioServiceDelegate;
 }
 }
 
 
 - (void)setIoDelegate:(id<UTMSpiceIODelegate>)ioDelegate {
 - (void)setIoDelegate:(id<UTMSpiceIODelegate>)ioDelegate {
-    self.ioService.delegate = ioDelegate;
+    if (self.ioService) {
+        self.ioService.delegate = ioDelegate;
+    } else {
+        // we haven't started the VM yet, save a copy
+        self.ioServiceDelegate = ioDelegate;
+    }
 }
 }
 
 
 - (instancetype)init {
 - (instancetype)init {
@@ -162,6 +168,8 @@ NSString *const kSuspendSnapshotName = @"suspend";
     }
     }
     
     
     self.ioService = [[UTMSpiceIO alloc] initWithConfiguration:self.config];
     self.ioService = [[UTMSpiceIO alloc] initWithConfiguration:self.config];
+    self.ioService.delegate = self.ioServiceDelegate;
+    self.ioServiceDelegate = nil;
     
     
     NSError *spiceError;
     NSError *spiceError;
     if (![self.ioService startWithError:&spiceError]) {
     if (![self.ioService startWithError:&spiceError]) {

+ 33 - 0
Platform/Shared/BusyIndicator.swift

@@ -0,0 +1,33 @@
+//
+// 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 SwiftUI
+
+struct BusyIndicator: View {
+    var body: some View {
+        BigWhiteSpinner()
+            .frame(width: 100, height: 100, alignment: .center)
+            .foregroundColor(.white)
+            .background(Color.gray.opacity(0.5))
+            .clipShape(RoundedRectangle(cornerRadius: 25.0, style: .continuous))
+    }
+}
+
+struct BusyIndicator_Previews: PreviewProvider {
+    static var previews: some View {
+        BusyIndicator()
+    }
+}

+ 1 - 5
Platform/Shared/BusyOverlay.swift

@@ -22,11 +22,7 @@ struct BusyOverlay: View {
     var body: some View {
     var body: some View {
         Group {
         Group {
             if data.busy {
             if data.busy {
-                BigWhiteSpinner()
-                    .frame(width: 100, height: 100, alignment: .center)
-                    .foregroundColor(.white)
-                    .background(Color.gray.opacity(0.5))
-                    .clipShape(RoundedRectangle(cornerRadius: 25.0, style: .continuous))
+                BusyIndicator()
             } else {
             } else {
                 EmptyView()
                 EmptyView()
             }
             }

+ 5 - 1
Platform/UTMData.swift

@@ -19,6 +19,7 @@ import Foundation
 import AppKit
 import AppKit
 #else
 #else
 import UIKit
 import UIKit
+import SwiftUI
 #endif
 #endif
 #if canImport(AltKit) && !WITH_QEMU_TCI
 #if canImport(AltKit) && !WITH_QEMU_TCI
 import AltKit
 import AltKit
@@ -72,7 +73,10 @@ class UTMData: ObservableObject {
     var vmWindows: [UTMVirtualMachine: VMDisplayWindowController] = [:]
     var vmWindows: [UTMVirtualMachine: VMDisplayWindowController] = [:]
     #else
     #else
     /// View controller for currently active VM
     /// View controller for currently active VM
-    var vmVC: VMDisplayViewController?
+    var vmVC: Any?
+    
+    /// View state for active VM primary display
+    @State var vmPrimaryWindowState: VMWindowState?
     #endif
     #endif
     
     
     /// Shortcut for accessing FileManager.default
     /// Shortcut for accessing FileManager.default

+ 1 - 1
Platform/iOS/Display/VMDisplayMetalViewController+Pointer.m

@@ -126,7 +126,7 @@ NS_AVAILABLE_IOS(13.4)
 }
 }
 
 
 - (BOOL)hasTouchpadPointer {
 - (BOOL)hasTouchpadPointer {
-    return !self.vmQemuConfig.qemuInputLegacy && !self.vmInput.serverModeCursor && self.indirectMouseType != VMMouseTypeRelative;
+    return !self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor && self.indirectMouseType != VMMouseTypeRelative;
 }
 }
 
 
 - (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region {
 - (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region {

+ 12 - 18
Platform/iOS/Display/VMDisplayMetalViewController+Touch.m

@@ -303,11 +303,9 @@ static CGFloat CGPointToPixel(CGFloat point) {
         viewport.x = CGPointToPixel(translation.x) + _lastTwoPanOrigin.x;
         viewport.x = CGPointToPixel(translation.x) + _lastTwoPanOrigin.x;
         viewport.y = CGPointToPixel(translation.y) + _lastTwoPanOrigin.y;
         viewport.y = CGPointToPixel(translation.y) + _lastTwoPanOrigin.y;
         self.vmDisplay.viewportOrigin = [self clipDisplayToView:viewport];
         self.vmDisplay.viewportOrigin = [self clipDisplayToView:viewport];
-        // reset the resize toolbar icon
-        self.toolbar.isViewportChanged = YES;
         // persist this change in viewState
         // persist this change in viewState
-        self.vm.viewState.displayOriginX = self.vmDisplay.viewportOrigin.x;
-        self.vm.viewState.displayOriginY = self.vmDisplay.viewportOrigin.y;
+        self.delegate.displayOriginX = self.vmDisplay.viewportOrigin.x;
+        self.delegate.displayOriginY = self.vmDisplay.viewportOrigin.y;
     }
     }
     if (sender.state == UIGestureRecognizerStateEnded) {
     if (sender.state == UIGestureRecognizerStateEnded) {
         // TODO: decelerate
         // TODO: decelerate
@@ -467,27 +465,21 @@ static CGFloat CGPointToPixel(CGFloat point) {
         sender.state == UIGestureRecognizerStateEnded) {
         sender.state == UIGestureRecognizerStateEnded) {
         NSAssert(sender.scale > 0, @"sender.scale cannot be 0");
         NSAssert(sender.scale > 0, @"sender.scale cannot be 0");
         self.vmDisplay.viewportScale *= sender.scale;
         self.vmDisplay.viewportScale *= sender.scale;
-        // reset the resize toolbar icon
-        self.toolbar.isViewportChanged = YES;
         // persist this change in viewState
         // persist this change in viewState
-        self.vm.viewState.displayScale = self.vmDisplay.viewportScale;
+        self.delegate.displayScale = self.vmDisplay.viewportScale;
         sender.scale = 1.0;
         sender.scale = 1.0;
     }
     }
 }
 }
 
 
 - (IBAction)gestureSwipeUp:(UISwipeGestureRecognizer *)sender {
 - (IBAction)gestureSwipeUp:(UISwipeGestureRecognizer *)sender {
     if (sender.state == UIGestureRecognizerStateEnded) {
     if (sender.state == UIGestureRecognizerStateEnded) {
-        if (!self.keyboardVisible) {
-            self.keyboardVisible = YES;
-        }
+        [self showKeyboard];
     }
     }
 }
 }
 
 
 - (IBAction)gestureSwipeDown:(UISwipeGestureRecognizer *)sender {
 - (IBAction)gestureSwipeDown:(UISwipeGestureRecognizer *)sender {
     if (sender.state == UIGestureRecognizerStateEnded) {
     if (sender.state == UIGestureRecognizerStateEnded) {
-        if (self.keyboardVisible) {
-            self.keyboardVisible = NO;
-        }
+        [self hideKeyboard];
     }
     }
 }
 }
 
 
@@ -626,7 +618,7 @@ static CGFloat CGPointToPixel(CGFloat point) {
     self.vmDisplay.cursor.isInhibited = shouldHideCursor;
     self.vmDisplay.cursor.isInhibited = shouldHideCursor;
     if (shouldUseServerMouse != self.vmInput.serverModeCursor) {
     if (shouldUseServerMouse != self.vmInput.serverModeCursor) {
         UTMLog(@"Switching mouse mode to server:%d for type:%ld", shouldUseServerMouse, type);
         UTMLog(@"Switching mouse mode to server:%d for type:%ld", shouldUseServerMouse, type);
-        [self.vm requestInputTablet:!shouldUseServerMouse];
+        [self.delegate requestInputTablet:!shouldUseServerMouse];
         return YES;
         return YES;
     }
     }
     return NO;
     return NO;
@@ -635,7 +627,7 @@ static CGFloat CGPointToPixel(CGFloat point) {
 #pragma mark - Touch event handling
 #pragma mark - Touch event handling
 
 
 - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
 - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
-    if (!self.vmQemuConfig.qemuInputLegacy) {
+    if (!self.delegate.qemuInputLegacy) {
         for (UITouch *touch in touches) {
         for (UITouch *touch in touches) {
             if (@available(iOS 14, *)) {
             if (@available(iOS 14, *)) {
                 if (self.prefersPointerLocked && (touch.type == UITouchTypeIndirect || touch.type == UITouchTypeIndirectPointer)) {
                 if (self.prefersPointerLocked && (touch.type == UITouchTypeIndirect || touch.type == UITouchTypeIndirectPointer)) {
@@ -672,13 +664,15 @@ static CGFloat CGPointToPixel(CGFloat point) {
             }
             }
             break; // handle a single touch only
             break; // handle a single touch only
         }
         }
+    } else {
+        [self switchMouseType:VMMouseTypeRelative];
     }
     }
     [super touchesBegan:touches withEvent:event];
     [super touchesBegan:touches withEvent:event];
 }
 }
 
 
 - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
 - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
     // move cursor in client mode, in server mode we handle in gesturePan
     // move cursor in client mode, in server mode we handle in gesturePan
-    if (!self.vmQemuConfig.qemuInputLegacy && !self.vmInput.serverModeCursor) {
+    if (!self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor) {
         for (UITouch *touch in touches) {
         for (UITouch *touch in touches) {
             [_cursor updateMovement:[touch locationInView:self.mtkView]];
             [_cursor updateMovement:[touch locationInView:self.mtkView]];
             break; // handle single touch
             break; // handle single touch
@@ -689,7 +683,7 @@ static CGFloat CGPointToPixel(CGFloat point) {
 
 
 - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
 - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
     // release click in client mode, in server mode we handle in gesturePan
     // release click in client mode, in server mode we handle in gesturePan
-    if (!self.vmQemuConfig.qemuInputLegacy && !self.vmInput.serverModeCursor) {
+    if (!self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor) {
         [self dragCursor:UIGestureRecognizerStateEnded primary:YES secondary:YES middle:YES];
         [self dragCursor:UIGestureRecognizerStateEnded primary:YES secondary:YES middle:YES];
     }
     }
     [super touchesCancelled:touches withEvent:event];
     [super touchesCancelled:touches withEvent:event];
@@ -697,7 +691,7 @@ static CGFloat CGPointToPixel(CGFloat point) {
 
 
 - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
 - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
     // release click in client mode, in server mode we handle in gesturePan
     // release click in client mode, in server mode we handle in gesturePan
-    if (!self.vmQemuConfig.qemuInputLegacy && !self.vmInput.serverModeCursor) {
+    if (!self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor) {
         [self dragCursor:UIGestureRecognizerStateEnded primary:YES secondary:YES middle:YES];
         [self dragCursor:UIGestureRecognizerStateEnded primary:YES secondary:YES middle:YES];
     }
     }
     [super touchesEnded:touches withEvent:event];
     [super touchesEnded:touches withEvent:event];

+ 6 - 3
Platform/iOS/Display/VMDisplayMetalViewController.h

@@ -60,14 +60,17 @@ NS_ASSUME_NONNULL_BEGIN
 }
 }
 
 
 @property (nonatomic) IBOutlet MTKView *mtkView;
 @property (nonatomic) IBOutlet MTKView *mtkView;
-@property (nonatomic) IBOutlet UIImageView *placeholderImageView;
 @property (nonatomic) IBOutlet VMKeyboardView *keyboardView;
 @property (nonatomic) IBOutlet VMKeyboardView *keyboardView;
 
 
-@property (weak, nonatomic) CSInput *vmInput;
-@property (weak, nonatomic) CSDisplay *vmDisplay;
+@property (nonatomic, nullable) CSInput *vmInput;
+@property (nonatomic) CSDisplay *vmDisplay;
 
 
 @property (nonatomic, readonly) BOOL serverModeCursor;
 @property (nonatomic, readonly) BOOL serverModeCursor;
 
 
+- (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;
+
 - (void)sendExtendedKey:(CSInputKey)type code:(int)code;
 - (void)sendExtendedKey:(CSInputKey)type code:(int)code;
 - (void)resetDisplay;
 - (void)resetDisplay;
 - (void)resizeDisplayToFit;
 - (void)resizeDisplayToFit;

+ 53 - 81
Platform/iOS/Display/VMDisplayMetalViewController.m

@@ -32,18 +32,28 @@
     CSRenderer *_renderer;
     CSRenderer *_renderer;
 }
 }
 
 
-- (void)setupSubviews {
-    self.vm.delegate = self;
+- (instancetype)initWithDisplay:(CSDisplay *)display input:(CSInput *)input {
+    if (self = [super initWithNibName:nil bundle:nil]) {
+        self.vmDisplay = display;
+        self.vmInput = input;
+    }
+    return self;
+}
+
+- (void)loadView {
+    [super loadView];
     self.keyboardView = [[VMKeyboardView alloc] initWithFrame:CGRectZero];
     self.keyboardView = [[VMKeyboardView alloc] initWithFrame:CGRectZero];
-    self.placeholderImageView = [[UIImageView alloc] initWithFrame:CGRectZero];
     self.mtkView = [[MTKView alloc] initWithFrame:CGRectZero];
     self.mtkView = [[MTKView alloc] initWithFrame:CGRectZero];
     self.keyboardView.delegate = self;
     self.keyboardView.delegate = self;
     [self.view insertSubview:self.keyboardView atIndex:0];
     [self.view insertSubview:self.keyboardView atIndex:0];
-    [self.view insertSubview:self.placeholderImageView atIndex:1];
-    [self.placeholderImageView bindFrameToSuperviewBounds];
-    [self.view insertSubview:self.mtkView atIndex:2];
+    [self.view insertSubview:self.mtkView atIndex:1];
     [self.mtkView bindFrameToSuperviewBounds];
     [self.mtkView bindFrameToSuperviewBounds];
-    [self createToolbarIn:self.mtkView];
+    [self loadInputAccessory];
+}
+
+- (void)loadInputAccessory {
+    UINib *nib = [UINib nibWithNibName:@"VMDisplayView" bundle:nil];
+    [nib instantiateWithOwner:self options:nil];
 }
 }
 
 
 - (BOOL)serverModeCursor {
 - (BOOL)serverModeCursor {
@@ -72,10 +82,11 @@
     // Initialize our renderer with the view size
     // Initialize our renderer with the view size
     [_renderer mtkView:self.mtkView drawableSizeWillChange:self.mtkView.drawableSize];
     [_renderer mtkView:self.mtkView drawableSizeWillChange:self.mtkView.drawableSize];
     
     
-    [_renderer changeUpscaler:self.vmQemuConfig.qemuDisplayUpscaler
-                   downscaler:self.vmQemuConfig.qemuDisplayDownscaler];
+    [_renderer changeUpscaler:self.delegate.qemuDisplayUpscaler
+                   downscaler:self.delegate.qemuDisplayDownscaler];
     
     
     self.mtkView.delegate = _renderer;
     self.mtkView.delegate = _renderer;
+    self.vmDisplay = self.vmDisplay; // reset renderer
     
     
     [self initTouch];
     [self initTouch];
     [self initGamepad];
     [self initGamepad];
@@ -100,7 +111,10 @@
 
 
 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
     [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
     [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
-    if (self.vmQemuConfig.qemuDisplayIsDynamicResolution) {
+    [coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {
+        self.delegate.displayViewSize = self.mtkView.drawableSize;
+    }];
+    if (self.delegate.qemuDisplayIsDynamicResolution) {
         [self displayResize:size];
         [self displayResize:size];
     }
     }
 }
 }
@@ -108,16 +122,11 @@
 - (void)enterSuspendedWithIsBusy:(BOOL)busy {
 - (void)enterSuspendedWithIsBusy:(BOOL)busy {
     [super enterSuspendedWithIsBusy:busy];
     [super enterSuspendedWithIsBusy:busy];
     if (!busy) {
     if (!busy) {
-        [UIView transitionWithView:self.view duration:0.5 options:UIViewAnimationOptionTransitionCrossDissolve animations:^{
-            self.placeholderImageView.hidden = NO;
-            self.placeholderImageView.image = self.vm.screenshot.image;
-            self.mtkView.hidden = YES;
-        } completion:nil];
-        if (self.vmQemuConfig.qemuHasClipboardSharing) {
+        if (self.delegate.qemuHasClipboardSharing) {
             [[UTMPasteboard generalPasteboard] releasePollingModeForObject:self];
             [[UTMPasteboard generalPasteboard] releasePollingModeForObject:self];
         }
         }
 #if !defined(WITH_QEMU_TCI)
 #if !defined(WITH_QEMU_TCI)
-        if (self.vm.state == kVMStopped) {
+        if (self.delegate.vmState == kVMStopped) {
             [self.usbDevicesViewController clearDevices];
             [self.usbDevicesViewController clearDevices];
         }
         }
 #endif
 #endif
@@ -126,27 +135,25 @@
 
 
 - (void)enterLive {
 - (void)enterLive {
     [super enterLive];
     [super enterLive];
-    [UIView transitionWithView:self.view duration:0.5 options:UIViewAnimationOptionTransitionCrossDissolve animations:^{
-        self.placeholderImageView.hidden = YES;
-        self.mtkView.hidden = NO;
-    } completion:nil];
-    if (self.vmQemuConfig.qemuDisplayIsDynamicResolution) {
+    dispatch_async(dispatch_get_main_queue(), ^{
+        self.delegate.displayViewSize = self.mtkView.drawableSize;
+    });
+    if (self.delegate.qemuDisplayIsDynamicResolution) {
         [self displayResize:self.view.bounds.size];
         [self displayResize:self.view.bounds.size];
     }
     }
-    if (self.vmQemuConfig.qemuHasClipboardSharing) {
+    if (self.delegate.qemuHasClipboardSharing) {
         [[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
         [[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
     }
     }
 }
 }
 
 
 #pragma mark - Key handling
 #pragma mark - Key handling
 
 
-- (void)setKeyboardVisible:(BOOL)keyboardVisible {
-    if (keyboardVisible) {
-        [self.keyboardView becomeFirstResponder];
-    } else {
-        [self.keyboardView resignFirstResponder];
-    }
-    [super setKeyboardVisible:keyboardVisible];
+- (void)showKeyboard {
+    [self.keyboardView becomeFirstResponder];
+}
+
+- (void)hideKeyboard {
+    [self.keyboardView resignFirstResponder];
 }
 }
 
 
 - (void)sendExtendedKey:(CSInputKey)type code:(int)code {
 - (void)sendExtendedKey:(CSInputKey)type code:(int)code {
@@ -167,18 +174,18 @@
     self.vmDisplay.viewportScale = MIN(scaled.width, scaled.height);
     self.vmDisplay.viewportScale = MIN(scaled.width, scaled.height);
     self.vmDisplay.viewportOrigin = CGPointMake(0, 0);
     self.vmDisplay.viewportOrigin = CGPointMake(0, 0);
     // persist this change in viewState
     // persist this change in viewState
-    self.vm.viewState.displayScale = self.vmDisplay.viewportScale;
-    self.vm.viewState.displayOriginX = 0;
-    self.vm.viewState.displayOriginY = 0;
+    self.delegate.displayScale = self.vmDisplay.viewportScale;
+    self.delegate.displayOriginX = 0;
+    self.delegate.displayOriginY = 0;
 }
 }
 
 
 - (void)resetDisplay {
 - (void)resetDisplay {
     self.vmDisplay.viewportScale = 1.0;
     self.vmDisplay.viewportScale = 1.0;
     self.vmDisplay.viewportOrigin = CGPointMake(0, 0);
     self.vmDisplay.viewportOrigin = CGPointMake(0, 0);
     // persist this change in viewState
     // persist this change in viewState
-    self.vm.viewState.displayScale = 1.0;
-    self.vm.viewState.displayOriginX = 0;
-    self.vm.viewState.displayOriginY = 0;
+    self.delegate.displayScale = 1.0;
+    self.delegate.displayOriginX = 0;
+    self.delegate.displayOriginY = 0;
 }
 }
 
 
 #pragma mark - Resizing
 #pragma mark - Resizing
@@ -186,7 +193,7 @@
 - (void)displayResize:(CGSize)size {
 - (void)displayResize:(CGSize)size {
     UTMLog(@"resizing to (%f, %f)", size.width, size.height);
     UTMLog(@"resizing to (%f, %f)", size.width, size.height);
     CGRect bounds = CGRectMake(0, 0, size.width, size.height);
     CGRect bounds = CGRectMake(0, 0, size.width, size.height);
-    if (self.vmQemuConfig.qemuDisplayIsNativeResolution) {
+    if (self.delegate.qemuDisplayIsNativeResolution) {
         CGFloat scale = [UIScreen mainScreen].scale;
         CGFloat scale = [UIScreen mainScreen].scale;
         CGAffineTransform transform = CGAffineTransformMakeScale(scale, scale);
         CGAffineTransform transform = CGAffineTransformMakeScale(scale, scale);
         bounds = CGRectApplyAffineTransform(bounds, transform);
         bounds = CGRectApplyAffineTransform(bounds, transform);
@@ -194,50 +201,15 @@
     [self.vmDisplay requestResolution:bounds];
     [self.vmDisplay requestResolution:bounds];
 }
 }
 
 
-#pragma mark - SPICE IO Delegates
-
-- (void)spiceDidCreateInput:(CSInput *)input {
-    if (self.vmInput == nil) {
-        self.vmInput = input;
-    }
-}
-
-- (void)spiceDidDestroyInput:(CSInput *)input {
-    if (self.vmInput == input) {
-        self.vmInput = nil;
-    }
-}
-
-- (void)spiceDidCreateDisplay:(CSDisplay *)display {
-    if (self.vmDisplay == nil && display.isPrimaryDisplay) {
-        self.vmDisplay = display;
-        _renderer.source = display;
-        // restore last size
-        CGPoint displayOrigin = CGPointMake(self.vm.viewState.displayOriginX, self.vm.viewState.displayOriginY);
-        display.viewportOrigin = displayOrigin;
-        double displayScale = self.vm.viewState.displayScale;
-        if (displayScale) { // cannot be zero
-            display.viewportScale = displayScale;
-        }
-        dispatch_async(dispatch_get_main_queue(), ^{
-            if (displayScale != 1.0 || !CGPointEqualToPoint(displayOrigin, CGPointZero)) {
-                // make the zoom button zoom out
-                self.toolbar.isViewportChanged = YES;
-            }
-        });
-    }
-}
-
-- (void)spiceDidDestroyDisplay:(CSDisplay *)display {
-    if (self.vmDisplay == display) {
-        self.vmDisplay = nil;
-        _renderer.source = nil;
-    }
-}
-
-- (void)spiceDidUpdateDisplay:(CSDisplay *)display {
-    if (display == self.vmDisplay) {
-        
+- (void)setVmDisplay:(CSDisplay *)display {
+    _vmDisplay = display;
+    _renderer.source = display;
+    // restore last size
+    CGPoint displayOrigin = CGPointMake(self.delegate.displayOriginX, self.delegate.displayOriginY);
+    display.viewportOrigin = displayOrigin;
+    double displayScale = self.delegate.displayScale;
+    if (displayScale) { // cannot be zero
+        display.viewportScale = displayScale;
     }
     }
 }
 }
 
 

+ 116 - 31
Platform/iOS/Display/VMDisplayTerminalViewController.swift

@@ -16,60 +16,144 @@
 
 
 import Foundation
 import Foundation
 import SwiftTerm
 import SwiftTerm
-
-private let kVMDefaultResizeCmd = "stty cols $COLS rows $ROWS\\n"
+import SwiftUI
 
 
 @objc class VMDisplayTerminalViewController: VMDisplayViewController {
 @objc class VMDisplayTerminalViewController: VMDisplayViewController {
     private var terminalView: TerminalView!
     private var terminalView: TerminalView!
-    private var vmSerialPort: CSPort?
+    private var vmSerialPort: CSPort
     
     
-    required init(vm: UTMQemuVirtualMachine, port: CSPort? = nil) {
-        super.init(nibName: nil, bundle: nil)
-        self.vm = vm
+    private var style: UTMConfigurationTerminal?
+    private var keyboardDelta: CGFloat = 0
+    
+    required init(port: CSPort, style: UTMConfigurationTerminal? = nil) {
         self.vmSerialPort = port
         self.vmSerialPort = port
+        super.init(nibName: nil, bundle: nil)
+        port.delegate = self
+        self.style = style
     }
     }
     
     
     required init?(coder: NSCoder) {
     required init?(coder: NSCoder) {
-        super.init(coder: coder)
+        return nil
     }
     }
     
     
     override func loadView() {
     override func loadView() {
         super.loadView()
         super.loadView()
-        vm.delegate = self;
-        terminalView = TerminalView(frame: .zero)
+        terminalView = TerminalView(frame: makeFrame (keyboardDelta: 0))
+        terminalView.terminalDelegate = self
         view.insertSubview(terminalView, at: 0)
         view.insertSubview(terminalView, at: 0)
-        terminalView.bindFrameToSuperviewBounds()
+        styleTerminal()
     }
     }
     
     
-    // FIXME: connect this to toolbar action
-    func changeDisplayZoom(_ sender: UIButton) {
-        let cols = terminalView.getTerminal().cols
-        let rows = terminalView.getTerminal().rows
-        let template = vmQemuConfig.qemuConsoleResizeCommand ?? kVMDefaultResizeCmd
-        let cmd = template
-            .replacingOccurrences(of: "$COLS", with: String(cols))
-            .replacingOccurrences(of: "$ROWS", with: String(rows))
-            .replacingOccurrences(of: "\\n", with: "\n")
-        vmSerialPort?.write(cmd.data(using: .nonLossyASCII)!)
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        setupKeyboardMonitor()
     }
     }
     
     
-    override func spiceDidCreateSerial(_ serial: CSPort) {
-        if vmSerialPort == nil {
-            vmSerialPort = serial
-            serial.delegate = self
+    override func enterLive() {
+        super.enterLive()
+        DispatchQueue.main.async {
+            let terminalSize = CGSize(width: self.terminalView.getTerminal().cols, height: self.terminalView.getTerminal().rows)
+            self.delegate.displayViewSize = terminalSize
         }
         }
     }
     }
     
     
-    override func spiceDidDestroySerial(_ serial: CSPort) {
-        if vmSerialPort == serial {
-            serial.delegate = nil
-            vmSerialPort = nil
+    override func showKeyboard() {
+        terminalView.becomeFirstResponder()
+    }
+    
+    override func hideKeyboard() {
+        _ = terminalView.resignFirstResponder()
+    }
+}
+
+// MARK: - Layout terminal
+extension VMDisplayTerminalViewController {
+    var useAutoLayout: Bool {
+        get { true }
+    }
+    
+    func makeFrame (keyboardDelta: CGFloat, _ fn: String = #function, _ ln: Int = #line) -> CGRect
+    {
+        if useAutoLayout {
+            return CGRect.zero
+        } else {
+            return CGRect (x: view.safeAreaInsets.left,
+                           y: view.safeAreaInsets.top,
+                           width: view.frame.width - view.safeAreaInsets.left - view.safeAreaInsets.right,
+                           height: view.frame.height - view.safeAreaInsets.top - keyboardDelta)
+        }
+    }
+    
+    func setupKeyboardMonitor ()
+    {
+        if #available(iOS 15.0, *), useAutoLayout {
+            terminalView.translatesAutoresizingMaskIntoConstraints = false
+            terminalView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
+            terminalView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
+            terminalView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
+            
+            terminalView.keyboardLayoutGuide.topAnchor.constraint(equalTo: terminalView.bottomAnchor).isActive = true
+        } else {
+            NotificationCenter.default.addObserver(
+                self,
+                selector: #selector(keyboardWillShow),
+                name: UIWindow.keyboardWillShowNotification,
+                object: nil)
+            NotificationCenter.default.addObserver(
+                self,
+                selector: #selector(keyboardWillHide),
+                name: UIWindow.keyboardWillHideNotification,
+                object: nil)
+        }
+    }
+    
+    @objc private func keyboardWillShow(_ notification: NSNotification) {
+        guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
+        
+        let keyboardScreenEndFrame = keyboardValue.cgRectValue
+        let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
+        keyboardDelta = keyboardViewEndFrame.height
+        terminalView.frame = makeFrame(keyboardDelta: keyboardViewEndFrame.height)
+    }
+    
+    @objc private func keyboardWillHide(_ notification: NSNotification) {
+        //let key = UIResponder.keyboardFrameBeginUserInfoKey
+        keyboardDelta = 0
+        terminalView.frame = makeFrame(keyboardDelta: 0)
+    }
+}
+
+// MARK: - Style terminal
+extension VMDisplayTerminalViewController {
+    private func styleTerminal() {
+        guard let style = style else {
+            return
+        }
+        let fontSize = style.fontSize
+        let fontName = style.font.rawValue
+        if fontName != "" {
+            let orig = terminalView.font
+            let new = UIFont(name: fontName, size: CGFloat(fontSize)) ?? orig
+            terminalView.font = new
+        } else {
+            let orig = terminalView.font
+            let new = UIFont(descriptor: orig.fontDescriptor, size: CGFloat(fontSize))
+            terminalView.font = new
+        }
+        if let consoleTextColor = style.foregroundColor,
+           let textColor = Color(hexString: consoleTextColor),
+           let consoleBackgroundColor = style.backgroundColor,
+           let backgroundColor = Color(hexString: consoleBackgroundColor) {
+            terminalView.nativeForegroundColor = UIColor(textColor)
+            terminalView.nativeBackgroundColor = UIColor(backgroundColor)
         }
         }
     }
     }
 }
 }
 
 
+// MARK: - TerminalViewDelegate
 extension VMDisplayTerminalViewController: TerminalViewDelegate {
 extension VMDisplayTerminalViewController: TerminalViewDelegate {
     func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
     func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
+        delegate.displayViewSize = CGSize(width: newCols, height: newRows)
     }
     }
     
     
     func setTerminalTitle(source: TerminalView, title: String) {
     func setTerminalTitle(source: TerminalView, title: String) {
@@ -82,18 +166,19 @@ extension VMDisplayTerminalViewController: TerminalViewDelegate {
     }
     }
     
     
     func send(source: TerminalView, data: ArraySlice<UInt8>) {
     func send(source: TerminalView, data: ArraySlice<UInt8>) {
-        if let vmSerialPort = vmSerialPort {
-            vmSerialPort.write(Data(data))
-        }
+        delegate.displayDidAssertUserInteraction()
+        vmSerialPort.write(Data(data))
     }
     }
     
     
     func scrolled(source: TerminalView, position: Double) {
     func scrolled(source: TerminalView, position: Double) {
+        delegate.displayDidAssertUserInteraction()
     }
     }
     
     
     func bell(source: TerminalView) {
     func bell(source: TerminalView) {
     }
     }
 }
 }
 
 
+// MARK: - CSPortDelegate
 extension VMDisplayTerminalViewController: CSPortDelegate {
 extension VMDisplayTerminalViewController: CSPortDelegate {
     func portDidDisconect(_ port: CSPort) {
     func portDidDisconect(_ port: CSPort) {
     }
     }

+ 6 - 22
Platform/iOS/Display/VMDisplayViewController.h

@@ -16,50 +16,34 @@
 
 
 #import <UIKit/UIKit.h>
 #import <UIKit/UIKit.h>
 #import "CSInput.h"
 #import "CSInput.h"
-#import "UTMSpiceIODelegate.h"
-#import "UTMVirtualMachineDelegate.h"
 
 
-@class UTMConfigurationWrapper;
-@class UTMQemuVirtualMachine;
 @class VMKeyboardButton;
 @class VMKeyboardButton;
 @class VMRemovableDrivesViewController;
 @class VMRemovableDrivesViewController;
-@class VMToolbarActions;
 @class VMUSBDevicesViewController;
 @class VMUSBDevicesViewController;
+@protocol VMDisplayViewControllerDelegate;
 
 
-@interface VMDisplayViewController : UIViewController<UTMVirtualMachineDelegate, UTMSpiceIODelegate>
+@interface VMDisplayViewController : UIViewController
 
 
 @property (weak, nonatomic) IBOutlet UIView *displayView;
 @property (weak, nonatomic) IBOutlet UIView *displayView;
 @property (strong, nonatomic) IBOutlet UIInputView *inputAccessoryView;
 @property (strong, nonatomic) IBOutlet UIInputView *inputAccessoryView;
-@property (weak, nonatomic) IBOutlet UIView *toolbarAccessoryView;
-@property (weak, nonatomic) IBOutlet UIButton *powerExitButton;
-@property (weak, nonatomic) IBOutlet UIButton *pauseResumeButton;
-@property (weak, nonatomic) IBOutlet UIButton *restartButton;
-@property (weak, nonatomic) IBOutlet UIButton *zoomButton;
-@property (weak, nonatomic) IBOutlet UIButton *keyboardButton;
-@property (weak, nonatomic) IBOutlet UIButton *drivesButton;
-@property (weak, nonatomic) IBOutlet UIButton *usbButton;
 @property (weak, nonatomic) IBOutlet UIVisualEffectView *placeholderView;
 @property (weak, nonatomic) IBOutlet UIVisualEffectView *placeholderView;
 @property (weak, nonatomic) IBOutlet UIActivityIndicatorView *placeholderIndicator;
 @property (weak, nonatomic) IBOutlet UIActivityIndicatorView *placeholderIndicator;
 @property (weak, nonatomic) IBOutlet UIButton *resumeBigButton;
 @property (weak, nonatomic) IBOutlet UIButton *resumeBigButton;
 @property (strong, nonatomic) IBOutletCollection(VMKeyboardButton) NSArray *customKeyModifierButtons;
 @property (strong, nonatomic) IBOutletCollection(VMKeyboardButton) NSArray *customKeyModifierButtons;
 
 
-@property (nonatomic, readonly) UTMConfigurationWrapper *vmQemuConfig;
-@property (nonatomic) VMToolbarActions *toolbar;
-@property (nonatomic) UIViewController *floatingToolbarViewController;
+@property (weak, nonatomic) id<VMDisplayViewControllerDelegate> delegate;
+
 @property (nonatomic) VMRemovableDrivesViewController *removableDrivesViewController;
 @property (nonatomic) VMRemovableDrivesViewController *removableDrivesViewController;
 @property (nonatomic) VMUSBDevicesViewController *usbDevicesViewController;
 @property (nonatomic) VMUSBDevicesViewController *usbDevicesViewController;
 
 
 @property (nonatomic) BOOL hasAutoSave;
 @property (nonatomic) BOOL hasAutoSave;
 @property (nonatomic, readwrite) BOOL prefersStatusBarHidden;
 @property (nonatomic, readwrite) BOOL prefersStatusBarHidden;
-@property (nonatomic) BOOL keyboardVisible;
-@property (nonatomic, strong) UTMQemuVirtualMachine *vm;
 
 
 @property (nonatomic, strong) NSMutableArray<UIKeyCommand *> *mutableKeyCommands;
 @property (nonatomic, strong) NSMutableArray<UIKeyCommand *> *mutableKeyCommands;
 
 
 @property (nonatomic, strong) NSMutableArray<NSObject *> *notifications;
 @property (nonatomic, strong) NSMutableArray<NSObject *> *notifications;
 
 
-- (void)setupSubviews;
-- (BOOL)inputViewIsFirstResponder;
-- (void)updateKeyboardAccessoryFrame;
+- (void)showKeyboard;
+- (void)hideKeyboard;
 
 
 @end
 @end

+ 4 - 87
Platform/iOS/Display/VMDisplayViewController.m

@@ -23,11 +23,6 @@
 #pragma mark - Properties
 #pragma mark - Properties
 
 
 @synthesize prefersStatusBarHidden = _prefersStatusBarHidden;
 @synthesize prefersStatusBarHidden = _prefersStatusBarHidden;
-@synthesize keyboardVisible = _keyboardVisible;
-
-- (UTMConfigurationWrapper *)vmQemuConfig {
-    return self.vm.config;
-}
 
 
 - (BOOL)prefersHomeIndicatorAutoHidden {
 - (BOOL)prefersHomeIndicatorAutoHidden {
     return YES; // always hide home indicator
     return YES; // always hide home indicator
@@ -42,90 +37,12 @@
     [self setNeedsStatusBarAppearanceUpdate];
     [self setNeedsStatusBarAppearanceUpdate];
 }
 }
 
 
-- (void)setKeyboardVisible:(BOOL)keyboardVisible {
-    if (_keyboardVisible != keyboardVisible) {
-        [[NSUserDefaults standardUserDefaults] setBool:keyboardVisible forKey:@"LastKeyboardVisible"];
-    }
-    _keyboardVisible = keyboardVisible;
-}
-
-#pragma mark - View handling
-
-- (void)setupSubviews {
-    // override by subclasses
-}
-
-- (BOOL)inputViewIsFirstResponder {
-    return NO;
-}
-
-- (void)updateKeyboardAccessoryFrame {
-}
-
-- (void)virtualMachine:(UTMVirtualMachine *)vm didTransitionToState:(UTMVMState)state {
-    static BOOL hasStartedOnce = NO;
-    if (hasStartedOnce && state == kVMStopped) {
-        [self terminateApplication];
-    }
-    switch (state) {
-        case kVMStopped:
-        case kVMPaused: {
-            [self enterSuspendedWithIsBusy:NO];
-            break;
-        }
-        case kVMPausing:
-        case kVMStopping:
-        case kVMStarting:
-        case kVMResuming: {
-            [self enterSuspendedWithIsBusy:YES];
-            break;
-        }
-        case kVMStarted: {
-            hasStartedOnce = YES; // auto-quit after VM ends
-            [self enterLive];
-            break;
-        }
-    }
-}
-
-- (void)virtualMachine:(UTMVirtualMachine *)vm didErrorWithMessage:(NSString *)message {
-    [self.placeholderIndicator stopAnimating];
-    [self showAlert:message actions:nil completion:^(UIAlertAction *action){
-        if (vm.state != kVMStarted && vm.state != kVMPaused) {
-            [self terminateApplication];
-        }
-    }];
-}
-
-#pragma mark - SPICE IO Delegates
-
-- (void)spiceDidCreateInput:(CSInput *)input {
-}
-
-- (void)spiceDidDestroyInput:(CSInput *)input {
-}
-
-- (void)spiceDidCreateDisplay:(CSDisplay *)display {
-}
-
-- (void)spiceDidUpdateDisplay:(CSDisplay *)display {
-}
-
-- (void)spiceDidDestroyDisplay:(CSDisplay *)display {
-}
-
-- (void)spiceDidCreateSerial:(CSPort *)serial {
-}
-
-- (void)spiceDidDestroySerial:(CSPort *)serial {
+- (void)showKeyboard {
+    // implement in subclass
 }
 }
 
 
-#if !defined(WITH_QEMU_TCI)
-- (void)spiceDidChangeUsbManager:(CSUSBManager *)usbManager {
-    [self.usbDevicesViewController clearDevices];
-    self.usbDevicesViewController.vmUsbManager = usbManager;
-    usbManager.delegate = self;
+- (void)hideKeyboard {
+    // implement in subclass
 }
 }
-#endif
 
 
 @end
 @end

+ 28 - 88
Platform/iOS/Display/VMDisplayViewController.swift

@@ -42,14 +42,10 @@ private var memoryAlertOnce = false
 
 
 // MARK: - View Loading
 // MARK: - View Loading
 public extension VMDisplayViewController {
 public extension VMDisplayViewController {
-    func loadDisplayViewFromNib() {
+    func loadViewsFromNib() {
         let nib = UINib(nibName: "VMDisplayView", bundle: nil)
         let nib = UINib(nibName: "VMDisplayView", bundle: nil)
         _ = nib.instantiate(withOwner: self, options: nil)
         _ = nib.instantiate(withOwner: self, options: nil)
-        assert(self.displayView != nil, "Failed to load main view from VMDisplayView nib")
         assert(self.inputAccessoryView != nil, "Failed to load input view from VMDisplayView nib")
         assert(self.inputAccessoryView != nil, "Failed to load input view from VMDisplayView nib")
-        displayView.frame = view.bounds
-        displayView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
-        view.addSubview(displayView)
         
         
         // set up other nibs
         // set up other nibs
         removableDrivesViewController = VMRemovableDrivesViewController(nibName: "VMRemovableDrivesView", bundle: nil)
         removableDrivesViewController = VMRemovableDrivesViewController(nibName: "VMRemovableDrivesView", bundle: nil)
@@ -57,25 +53,10 @@ public extension VMDisplayViewController {
         usbDevicesViewController = VMUSBDevicesViewController(nibName: "VMUSBDevicesView", bundle: nil)
         usbDevicesViewController = VMUSBDevicesViewController(nibName: "VMUSBDevicesView", bundle: nil)
         #endif
         #endif
     }
     }
-    
-    @objc func createToolbar(in view: UIView) {
-        toolbar = VMToolbarActions(with: self)
-        guard floatingToolbarViewController == nil else {
-            return
-        }
-        // create new toolbar
-        floatingToolbarViewController = UIHostingController(rootView: VMToolbarView(state: self.toolbar))
-        let childView = floatingToolbarViewController.view!
-        childView.backgroundColor = .clear
-        view.addSubview(childView)
-        childView.bindFrameToSuperviewBounds()
-        addChild(floatingToolbarViewController)
-        floatingToolbarViewController.didMove(toParent: self)
-    }
-    
+
     override func viewDidLoad() {
     override func viewDidLoad() {
         super.viewDidLoad()
         super.viewDidLoad()
-        loadDisplayViewFromNib()
+        loadViewsFromNib()
         
         
         if largeScreen {
         if largeScreen {
             prefersStatusBarHidden = true
             prefersStatusBarHidden = true
@@ -86,27 +67,9 @@ public extension VMDisplayViewController {
         super.viewWillAppear(animated)
         super.viewWillAppear(animated)
         navigationController?.setNavigationBarHidden(true, animated: animated)
         navigationController?.setNavigationBarHidden(true, animated: animated)
         
         
-        // remove legacy toolbar
-        if !toolbar.hasLegacyToolbar {
-            // remove legacy toolbar
-            toolbarAccessoryView.removeFromSuperview()
-        }
-        
-        // hide USB icon if not supported
-        toolbar.isUsbSupported = vm.hasUsbRedirection
-        
         let nc = NotificationCenter.default
         let nc = NotificationCenter.default
         weak var _self = self
         weak var _self = self
         notifications = NSMutableArray()
         notifications = NSMutableArray()
-        notifications.add(nc.addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: .main) { _ in
-            _self?.keyboardDidShow()
-        })
-        notifications.add(nc.addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: .main) { _ in
-            _self?.keyboardDidHide()
-        })
-        notifications.add(nc.addObserver(forName: UIResponder.keyboardDidChangeFrameNotification, object: nil, queue: .main) { _ in
-            _self?.keyboardDidChangeFrame()
-        })
         notifications.add(nc.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
         notifications.add(nc.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
             _self?.handleEnteredBackground()
             _self?.handleEnteredBackground()
         })
         })
@@ -116,11 +79,6 @@ public extension VMDisplayViewController {
         notifications.add(nc.addObserver(forName: .UTMImport, object: nil, queue: .main) { _ in
         notifications.add(nc.addObserver(forName: .UTMImport, object: nil, queue: .main) { _ in
             _self?.handleImportUTM()
             _self?.handleImportUTM()
         })
         })
-        
-        // restore keyboard state
-        if UserDefaults.standard.bool(forKey: "LastKeyboardVisible") {
-            keyboardVisible = true
-        }
     }
     }
     
     
     override func viewWillDisappear(_ animated: Bool) {
     override func viewWillDisappear(_ animated: Bool) {
@@ -137,9 +95,7 @@ public extension VMDisplayViewController {
             logger.info("Start location tracking to enable running in background")
             logger.info("Start location tracking to enable running in background")
             UTMLocationManager.sharedInstance().startUpdatingLocation()
             UTMLocationManager.sharedInstance().startUpdatingLocation()
         }
         }
-        if vm.state == .vmStopped {
-            vm.requestVmStart()
-        }
+        delegate.displayDidAppear()
     }
     }
     
     
     override func didReceiveMemoryWarning() {
     override func didReceiveMemoryWarning() {
@@ -147,7 +103,7 @@ public extension VMDisplayViewController {
         
         
         if autosaveLowMemory {
         if autosaveLowMemory {
             logger.info("Saving VM state on low memory warning.")
             logger.info("Saving VM state on low memory warning.")
-            vm.vmSaveState { _ in
+            delegate.vmSaveState { _ in
                 // ignore error
                 // ignore error
             }
             }
         }
         }
@@ -161,34 +117,13 @@ public extension VMDisplayViewController {
 
 
 @objc extension VMDisplayViewController {
 @objc extension VMDisplayViewController {
     func enterSuspended(isBusy busy: Bool) {
     func enterSuspended(isBusy busy: Bool) {
-        if busy {
-            resumeBigButton.isHidden = true
-            placeholderView.isHidden = false
-            placeholderIndicator.startAnimating()
-        } else {
-            UIView.transition(with: view, duration: 0.5, options: .transitionCrossDissolve) {
-                self.placeholderView.isHidden = false
-                if self.vm.state == .vmPaused {
-                    self.resumeBigButton.isHidden = false
-                }
-            } completion: { _ in
-            }
-            placeholderIndicator.stopAnimating()
+        if !busy {
             UIApplication.shared.isIdleTimerDisabled = false
             UIApplication.shared.isIdleTimerDisabled = false
         }
         }
-        toolbar.enterSuspended(isBusy: busy)
     }
     }
     
     
     func enterLive() {
     func enterLive() {
-        UIView.transition(with: view, duration: 0.5, options: .transitionCrossDissolve) {
-            self.placeholderView.isHidden = true
-            self.resumeBigButton.isHidden = true
-        } completion: { _ in
-        }
-        placeholderIndicator.stopAnimating()
         UIApplication.shared.isIdleTimerDisabled = disableIdleTimer
         UIApplication.shared.isIdleTimerDisabled = disableIdleTimer
-        vm.ioDelegate = self
-        toolbar.enterLive()
     }
     }
     
     
     private func suspend() {
     private func suspend() {
@@ -215,7 +150,7 @@ public extension VMDisplayViewController {
     override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
     override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
         for touch in touches {
         for touch in touches {
             if touch.type == .direct {
             if touch.type == .direct {
-                toolbar.assertUserInteraction()
+                delegate.displayDidAssertUserInteraction()
                 break
                 break
             }
             }
         }
         }
@@ -227,7 +162,7 @@ public extension VMDisplayViewController {
 extension VMDisplayViewController {
 extension VMDisplayViewController {
     func handleEnteredBackground() {
     func handleEnteredBackground() {
         logger.info("Entering background")
         logger.info("Entering background")
-        if autosaveBackground && vm.state == .vmStarted {
+        if autosaveBackground && delegate.vmState == .vmStarted {
             logger.info("Saving snapshot")
             logger.info("Saving snapshot")
             var task: UIBackgroundTaskIdentifier = .invalid
             var task: UIBackgroundTaskIdentifier = .invalid
             task = UIApplication.shared.beginBackgroundTask {
             task = UIApplication.shared.beginBackgroundTask {
@@ -235,7 +170,7 @@ extension VMDisplayViewController {
                 UIApplication.shared.endBackgroundTask(task)
                 UIApplication.shared.endBackgroundTask(task)
                 task = .invalid
                 task = .invalid
             }
             }
-            vm.vmSaveState { error in
+            delegate.vmSaveState { error in
                 if let error = error {
                 if let error = error {
                     logger.error("error saving snapshot: \(error)")
                     logger.error("error saving snapshot: \(error)")
                 } else {
                 } else {
@@ -250,28 +185,33 @@ extension VMDisplayViewController {
     
     
     func handleEnteredForeground() {
     func handleEnteredForeground() {
         logger.info("Entering foreground!")
         logger.info("Entering foreground!")
-        if (hasAutoSave && vm.state == .vmStarted) {
+        if (hasAutoSave && delegate.vmState == .vmStarted) {
             logger.info("Deleting snapshot")
             logger.info("Deleting snapshot")
             DispatchQueue.global(qos: .background).async {
             DispatchQueue.global(qos: .background).async {
-                self.vm.requestVmDeleteState()
+                self.delegate.requestVmDeleteState()
             }
             }
         }
         }
     }
     }
     
     
-    func keyboardDidShow() {
-        keyboardVisible = true
-    }
-    
-    func keyboardDidHide() {
-        // workaround for notification when hw keyboard connected
-        keyboardVisible = inputViewIsFirstResponder()
+    func handleImportUTM() {
+        showAlert(NSLocalizedString("You must terminate the running VM before you can import a new VM.", comment: "VMDisplayViewController"), actions: nil, completion: nil)
     }
     }
-    
-    func keyboardDidChangeFrame() {
-        updateKeyboardAccessoryFrame()
+}
+
+// MARK: - Popup menus
+
+extension VMDisplayViewController {
+    func presentDrives(for vm: UTMQemuVirtualMachine) {
+        removableDrivesViewController.modalPresentationStyle = .pageSheet
+        removableDrivesViewController.vm = vm
+        present(removableDrivesViewController, animated: true, completion: nil)
     }
     }
     
     
-    func handleImportUTM() {
-        showAlert(NSLocalizedString("You must terminate the running VM before you can import a new VM.", comment: "VMDisplayViewController"), actions: nil, completion: nil)
+    #if !WITH_QEMU_TCI
+    func presentUsb(for usbManager: CSUSBManager) {
+        usbDevicesViewController.modalPresentationStyle = .pageSheet
+        usbDevicesViewController.vmUsbManager = usbManager
+        present(usbDevicesViewController, animated: true, completion: nil)
     }
     }
+    #endif
 }
 }

+ 37 - 0
Platform/iOS/Display/VMDisplayViewControllerDelegate.swift

@@ -0,0 +1,37 @@
+//
+// 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
+
+@objc protocol VMDisplayViewControllerDelegate {
+    var vmState: UTMVMState { get }
+    var qemuInputLegacy: Bool { get }
+    var qemuDisplayUpscaler: MTLSamplerMinMagFilter { get }
+    var qemuDisplayDownscaler: MTLSamplerMinMagFilter { get }
+    var qemuDisplayIsDynamicResolution: Bool { get }
+    var qemuDisplayIsNativeResolution: Bool { get }
+    var qemuHasClipboardSharing: Bool { get }
+    var displayOriginX: Float { get set }
+    var displayOriginY: Float { get set }
+    var displayScale: Float { get set }
+    var displayViewSize: CGSize { get set }
+    
+    func displayDidAssertUserInteraction()
+    func displayDidAppear()
+    func vmSaveState(onCompletion: @escaping (Error?) -> Void)
+    func requestVmDeleteState()
+    func requestInputTablet(_ tablet: Bool)
+}

+ 0 - 190
Platform/iOS/Display/VMToolbarActions.swift

@@ -1,190 +0,0 @@
-//
-// Copyright © 2021 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 SwiftUI
-
-@objc public class VMToolbarActions: NSObject {
-    weak var viewController: VMDisplayViewController?
-    
-    init(with viewController: VMDisplayViewController) {
-        self.viewController = viewController
-    }
-    
-    @objc var hasLegacyToolbar: Bool {
-        if #available(iOS 14, *) {
-            return false
-        } else {
-            return true
-        }
-    }
-    
-    @objc private(set) var isLegacyToolbarVisible: Bool = false
-    
-    @objc var isViewportChanged: Bool = false {
-        willSet {
-            objectWillChange.send()
-        }
-    }
-    
-    private(set) var isBusy: Bool = false {
-        willSet {
-            objectWillChange.send()
-        }
-    }
-    
-    private(set) var isRunning: Bool = false {
-        willSet {
-            objectWillChange.send()
-        }
-    }
-    
-    var isUsbSupported: Bool = false {
-        willSet {
-            objectWillChange.send()
-        }
-    }
-    
-    private var longIdleTask: DispatchWorkItem?
-    
-    @objc var isUserInteracting: Bool = true {
-        willSet {
-            objectWillChange.send()
-        }
-    }
-    
-    private func setIsUserInteracting(_ value: Bool) {
-        if !UIAccessibility.isReduceMotionEnabled {
-            withAnimation {
-                self.isUserInteracting = value
-            }
-        } else {
-            self.isUserInteracting = value
-        }
-    }
-    
-    func assertUserInteraction() {
-        if let task = longIdleTask {
-            task.cancel()
-        }
-        setIsUserInteracting(true)
-        longIdleTask = DispatchWorkItem {
-            self.longIdleTask = nil
-            self.setIsUserInteracting(false)
-        }
-        DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: longIdleTask!)
-    }
-    
-    @objc func enterSuspended(isBusy busy: Bool) {
-        isBusy = busy
-        isRunning = false
-    }
-    
-    @objc func enterLive() {
-        isBusy = false
-        isRunning = true
-    }
-    
-    @objc func changeDisplayZoomPressed() {
-        guard let viewController = self.viewController as? VMDisplayMetalViewController else {
-            return
-        }
-        if self.isViewportChanged {
-            viewController.resetDisplay()
-        } else {
-            viewController.resizeDisplayToFit()
-        }
-        self.isViewportChanged = !self.isViewportChanged;
-    }
-    
-    @objc func pauseResumePressed() {
-        guard let viewController = self.viewController else {
-            return
-        }
-        let shouldSaveState = !viewController.vm.isRunningAsSnapshot
-        if viewController.vm.state == .vmStarted {
-            viewController.enterSuspended(isBusy: true) // early indicator
-            viewController.vm.requestVmPause(save: shouldSaveState)
-        } else if viewController.vm.state == .vmPaused {
-            viewController.enterSuspended(isBusy: true) // early indicator
-            viewController.vm.requestVmResume()
-        }
-    }
-    
-    @objc func powerPressed() {
-        guard let viewController = self.viewController else {
-            return
-        }
-        if viewController.vm.state == .vmStarted {
-            let yes = UIAlertAction(title: NSLocalizedString("Yes", comment: "VMDisplayViewController"), style: .destructive) { action in
-                viewController.enterSuspended(isBusy: true) // early indicator
-                viewController.vm.requestVmDeleteState()
-                viewController.vm.vmStop { _ in
-                    viewController.terminateApplication()
-                }
-            }
-            let no = UIAlertAction(title: NSLocalizedString("No", comment: "VMDisplayViewController"), style: .cancel, handler: nil)
-            viewController.showAlert(NSLocalizedString("Are you sure you want to stop this VM and exit? Any unsaved changes will be lost.", comment: "VMDisplayViewController"), actions: [yes, no], completion: nil)
-        } else {
-            let yes = UIAlertAction(title: NSLocalizedString("Yes", comment: "VMDisplayViewController"), style: .destructive) { action in
-                viewController.terminateApplication()
-            }
-            let no = UIAlertAction(title: NSLocalizedString("No", comment: "VMDisplayViewController"), style: .cancel, handler: nil)
-            viewController.showAlert(NSLocalizedString("Are you sure you want to exit UTM?", comment: "VMDisplayViewController"), actions: [yes, no], completion: nil)
-        }
-    }
-    
-    @objc func restartPressed() {
-        guard let viewController = self.viewController else {
-            return
-        }
-        let yes = UIAlertAction(title: NSLocalizedString("Yes", comment: "VMDisplayViewController"), style: .destructive) { action in
-            DispatchQueue.global(qos: .background).async {
-                viewController.vm.requestVmReset()
-            }
-        }
-        let no = UIAlertAction(title: NSLocalizedString("No", comment: "VMDisplayViewController"), style: .cancel, handler: nil)
-        viewController.showAlert(NSLocalizedString("Are you sure you want to reset this VM? Any unsaved changes will be lost.", comment: "VMDisplayViewController"), actions: [yes, no], completion: nil)
-    }
-    
-    @objc func showKeyboardPressed() {
-        guard let viewController = self.viewController else {
-            return
-        }
-        viewController.keyboardVisible = !viewController.keyboardVisible
-    }
-    
-    @objc func drivesPressed() {
-        guard let viewController = self.viewController else {
-            return
-        }
-        viewController.removableDrivesViewController.modalPresentationStyle = .pageSheet
-        viewController.removableDrivesViewController.vm = viewController.vm
-        viewController.present(viewController.removableDrivesViewController, animated: true, completion: nil)
-    }
-    
-    @objc func usbPressed() {
-        guard let viewController = self.viewController else {
-            return
-        }
-        #if !WITH_QEMU_TCI
-        viewController.usbDevicesViewController.modalPresentationStyle = .pageSheet
-        viewController.present(viewController.usbDevicesViewController, animated: true, completion: nil)
-        #endif
-    }
-}
-
-extension VMToolbarActions: ObservableObject {
-}

+ 60 - 15
Platform/iOS/Display/VMToolbarView.swift

@@ -27,8 +27,10 @@ struct VMToolbarView: View {
     
     
     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
     @Environment(\.verticalSizeClass) private var verticalSizeClass
     @Environment(\.verticalSizeClass) private var verticalSizeClass
+    @EnvironmentObject private var session: VMSessionState
+    @StateObject private var longIdleTimeout = LongIdleTimeout()
     
     
-    @StateObject var state: VMToolbarActions
+    @Binding var state: VMWindowState
     
     
     private var spacing: CGFloat {
     private var spacing: CGFloat {
         let direction: CGFloat
         let direction: CGFloat
@@ -63,8 +65,8 @@ struct VMToolbarView: View {
     }
     }
     
     
     private var toolbarToggleOpacity: Double {
     private var toolbarToggleOpacity: Double {
-        if isCollapsed && !isMoving {
-            if !state.isUserInteracting {
+        if !state.isBusy && state.isRunning && isCollapsed && !isMoving {
+            if !longIdleTimeout.isUserInteracting {
                 return 0
                 return 0
             } else if isIdle {
             } else if isIdle {
                 return 0.4
                 return 0.4
@@ -80,39 +82,48 @@ struct VMToolbarView: View {
         GeometryReader { geometry in
         GeometryReader { geometry in
             Group {
             Group {
                 Button {
                 Button {
-                    state.powerPressed()
+                    if session.vm.state == .vmStarted {
+                        state.alert = .powerDown
+                    } else {
+                        state.alert = .terminateApp
+                    }
                 } label: {
                 } label: {
                     Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
                     Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
                 }.offset(offset(for: 7))
                 }.offset(offset(for: 7))
                 Button {
                 Button {
-                    state.pauseResumePressed()
+                    session.pauseResume()
                 } label: {
                 } label: {
                     Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play")
                     Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play")
                 }.offset(offset(for: 6))
                 }.offset(offset(for: 6))
                 Button {
                 Button {
-                    state.restartPressed()
+                    state.alert = .restart
                 } label: {
                 } label: {
                     Label("Restart", systemImage: "restart")
                     Label("Restart", systemImage: "restart")
                 }.offset(offset(for: 5))
                 }.offset(offset(for: 5))
                 Button {
                 Button {
-                    state.changeDisplayZoomPressed()
+                    if case .serial(_) = state.device {
+                        let template = session.qemuConfig.serials[state.configIndex].terminal?.resizeCommand
+                        state.toggleDisplayResize(command: template)
+                    } else {
+                        state.toggleDisplayResize()
+                    }
                 } label: {
                 } label: {
                     Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
                     Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
                 }.offset(offset(for: 4))
                 }.offset(offset(for: 4))
-                if state.isUsbSupported {
+                if session.vm.hasUsbRedirection {
                     Button {
                     Button {
-                        state.usbPressed()
+                        state.isUSBMenuShown.toggle()
                     } label: {
                     } label: {
                         Label("USB", image: "Toolbar USB")
                         Label("USB", image: "Toolbar USB")
                     }.offset(offset(for: 3))
                     }.offset(offset(for: 3))
                 }
                 }
                 Button {
                 Button {
-                    state.drivesPressed()
+                    state.isDrivesMenuShown.toggle()
                 } label: {
                 } label: {
                     Label("Disk", systemImage: "opticaldisc")
                     Label("Disk", systemImage: "opticaldisc")
                 }.offset(offset(for: 2))
                 }.offset(offset(for: 2))
                 Button {
                 Button {
-                    state.showKeyboardPressed()
+                    state.isKeyboardRequested = !state.isKeyboardShown
                 } label: {
                 } label: {
                     Label("Keyboard", systemImage: "keyboard")
                     Label("Keyboard", systemImage: "keyboard")
                 }.offset(offset(for: 1))
                 }.offset(offset(for: 1))
@@ -124,7 +135,7 @@ struct VMToolbarView: View {
             .animation(.default)
             .animation(.default)
             Button {
             Button {
                 resetIdle()
                 resetIdle()
-                state.assertUserInteraction()
+                longIdleTimeout.assertUserInteraction()
                 withOptionalAnimation {
                 withOptionalAnimation {
                     isCollapsed.toggle()
                     isCollapsed.toggle()
                 }
                 }
@@ -150,17 +161,21 @@ struct VMToolbarView: View {
                             dragPosition = position(for: geometry)
                             dragPosition = position(for: geometry)
                         }
                         }
                         resetIdle()
                         resetIdle()
+                        longIdleTimeout.assertUserInteraction()
                     }
                     }
             )
             )
             .onChange(of: state.isRunning) { running in
             .onChange(of: state.isRunning) { running in
                 resetIdle()
                 resetIdle()
-                state.assertUserInteraction()
+                longIdleTimeout.assertUserInteraction()
                 if running && isCollapsed {
                 if running && isCollapsed {
                     withOptionalAnimation(.easeInOut(duration: 1)) {
                     withOptionalAnimation(.easeInOut(duration: 1)) {
                         shake.toggle()
                         shake.toggle()
                     }
                     }
                 }
                 }
             }
             }
+            .onChange(of: state.isUserInteracting) { newValue in
+                longIdleTimeout.assertUserInteraction()
+            }
         }
         }
     }
     }
     
     
@@ -203,7 +218,7 @@ struct VMToolbarView: View {
     
     
     private func offset(for index: Int) -> CGSize {
     private func offset(for index: Int) -> CGSize {
         var sub = 0
         var sub = 0
-        if !state.isUsbSupported && index >= 3 {
+        if !session.vm.hasUsbRedirection && index >= 3 {
             sub = 1
             sub = 1
         }
         }
         let x = isCollapsed ? 0 : -CGFloat(index-sub)*spacing
         let x = isCollapsed ? 0 : -CGFloat(index-sub)*spacing
@@ -287,8 +302,38 @@ extension ButtonStyle where Self == ToolbarButtonStyle {
     }
     }
 }
 }
 
 
+@MainActor private class LongIdleTimeout: ObservableObject {
+    private var longIdleTask: DispatchWorkItem?
+    
+    @Published var isUserInteracting: Bool = true
+    
+    private func setIsUserInteracting(_ value: Bool) {
+        if !UIAccessibility.isReduceMotionEnabled {
+            withAnimation {
+                self.isUserInteracting = value
+            }
+        } else {
+            self.isUserInteracting = value
+        }
+    }
+    
+    func assertUserInteraction() {
+        if let task = longIdleTask {
+            task.cancel()
+        }
+        setIsUserInteracting(true)
+        longIdleTask = DispatchWorkItem {
+            self.longIdleTask = nil
+            self.setIsUserInteracting(false)
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: longIdleTask!)
+    }
+}
+
 struct VMToolbarView_Previews: PreviewProvider {
 struct VMToolbarView_Previews: PreviewProvider {
+    @State static var state = VMWindowState()
+    
     static var previews: some View {
     static var previews: some View {
-        VMToolbarView(state: VMToolbarActions(with: VMDisplayViewController()))
+        VMToolbarView(state: $state)
     }
     }
 }
 }

+ 6 - 19
Platform/iOS/UTMDataExtension.swift

@@ -15,32 +15,18 @@
 //
 //
 
 
 import Foundation
 import Foundation
+import SwiftUI
 
 
 extension UTMData {
 extension UTMData {
-    private func createDisplay(vm: UTMVirtualMachine) -> VMDisplayViewController {
-        let qvm = vm as! UTMQemuVirtualMachine
-        if qvm.config.qemuConfig?.serials.first?.terminal != nil {
-            let vc = VMDisplayTerminalViewController(vm: qvm)
-            vc.virtualMachine(vm, didTransitionTo: vm.state)
-            return vc
-        } else if qvm.config.qemuConfig?.displays.first != nil {
-            let vc = VMDisplayMetalViewController()
-            vc.vm = qvm
-            vc.setupSubviews()
-            vc.virtualMachine(vm, didTransitionTo: vm.state)
-            return vc
-        } else {
-            fatalError()
-        }
-    }
-    
-    func run(vm: UTMVirtualMachine) {
+    @MainActor func run(vm: UTMVirtualMachine) {
         guard let window = UIApplication.shared.windows.filter({$0.isKeyWindow}).first else {
         guard let window = UIApplication.shared.windows.filter({$0.isKeyWindow}).first else {
             logger.error("Cannot find key window")
             logger.error("Cannot find key window")
             return
             return
         }
         }
         
         
-        let vc = self.createDisplay(vm: vm)
+        let session = VMSessionState(for: vm as! UTMQemuVirtualMachine)
+        let vmWindow = VMWindowView().environmentObject(session)
+        let vc = UIHostingController(rootView: vmWindow)
         self.vmVC = vc
         self.vmVC = vc
         window.rootViewController = vc
         window.rootViewController = vc
         window.makeKeyAndVisible()
         window.makeKeyAndVisible()
@@ -48,6 +34,7 @@ extension UTMData {
         let duration: TimeInterval = 0.3
         let duration: TimeInterval = 0.3
 
 
         UIView.transition(with: window, duration: duration, options: options, animations: {}, completion: nil)
         UIView.transition(with: window, duration: duration, options: options, animations: {}, completion: nil)
+        vm.requestVmStart()
     }
     }
     
     
     func stop(vm: UTMVirtualMachine) throws {
     func stop(vm: UTMVirtualMachine) throws {

+ 208 - 0
Platform/iOS/VMDisplayHostedView.swift

@@ -0,0 +1,208 @@
+//
+// 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 Combine
+import SwiftUI
+
+struct VMDisplayHostedView: UIViewControllerRepresentable {
+    internal class Coordinator: VMDisplayViewControllerDelegate {
+        let vm: UTMQemuVirtualMachine
+        let device: VMWindowState.Device
+        @Binding var state: VMWindowState
+        var vmStateCancellable: AnyCancellable?
+        
+        var vmState: UTMVMState {
+            vm.state
+        }
+        
+        var vmConfig: UTMQemuConfiguration! {
+            vm.config.qemuConfig
+        }
+        
+        var qemuInputLegacy: Bool {
+            vmConfig.input.usbBusSupport == .disabled || vmConfig.qemu.hasPS2Controller
+        }
+        
+        var qemuDisplayUpscaler: MTLSamplerMinMagFilter {
+            vmConfig.displays[state.configIndex].upscalingFilter.metalSamplerMinMagFilter
+        }
+        
+        var qemuDisplayDownscaler: MTLSamplerMinMagFilter {
+            vmConfig.displays[state.configIndex].downscalingFilter.metalSamplerMinMagFilter
+        }
+        
+        var qemuDisplayIsDynamicResolution: Bool {
+            vmConfig.displays[state.configIndex].isDynamicResolution
+        }
+        
+        var qemuDisplayIsNativeResolution: Bool {
+            vmConfig.displays[state.configIndex].isNativeResolution
+        }
+        
+        var qemuHasClipboardSharing: Bool {
+            vmConfig.sharing.hasClipboardSharing
+        }
+        
+        var qemuConsoleResizeCommand: String? {
+            vmConfig.serials[state.configIndex].terminal?.resizeCommand
+        }
+        
+        var isViewportChanged: Bool {
+            get {
+                state.isViewportChanged
+            }
+            
+            set {
+                state.isViewportChanged = newValue
+            }
+        }
+        
+        var displayOriginX: Float {
+            get {
+                state.displayOriginX
+            }
+            
+            set {
+                state.displayOriginX = newValue
+            }
+        }
+        
+        var displayOriginY: Float {
+            get {
+                state.displayOriginY
+            }
+            
+            set {
+                state.displayOriginY = newValue
+            }
+        }
+        
+        var displayScale: Float {
+            get {
+                state.displayScale
+            }
+            
+            set {
+                state.displayScale = newValue
+            }
+        }
+        
+        var displayViewSize: CGSize {
+            get {
+                state.displayViewSize
+            }
+            
+            set {
+                state.displayViewSize = newValue
+            }
+        }
+        
+        init(with vm: UTMQemuVirtualMachine, device: VMWindowState.Device, state: Binding<VMWindowState>) {
+            self.vm = vm
+            self.device = device
+            self._state = state
+        }
+        
+        func displayDidAssertUserInteraction() {
+            state.isUserInteracting.toggle()
+        }
+        
+        func displayDidAppear() {
+            if vm.state == .vmStopped {
+                vm.requestVmStart()
+            }
+        }
+        
+        func vmSaveState(onCompletion completion: @escaping (Error?) -> Void) {
+            vm.vmSaveState(completion: completion)
+        }
+        
+        func requestVmDeleteState() {
+            vm.requestVmDeleteState()
+        }
+        
+        func requestInputTablet(_ tablet: Bool) {
+            vm.requestInputTablet(tablet)
+        }
+    }
+    
+    let vm: UTMQemuVirtualMachine
+    let device: VMWindowState.Device
+    
+    @Binding var state: VMWindowState
+    
+    @EnvironmentObject private var session: VMSessionState
+    
+    func makeUIViewController(context: Context) -> VMDisplayViewController {
+        let vc: VMDisplayViewController
+        switch device {
+        case .display(let display):
+            vc = VMDisplayMetalViewController(display: display, input: session.primaryInput)
+            vc.delegate = context.coordinator
+        case .serial(let serial):
+            vc = VMDisplayTerminalViewController(port: serial)
+            vc.delegate = context.coordinator
+        }
+        context.coordinator.vmStateCancellable = session.$vmState.sink { vmState in
+            switch vmState {
+            case .vmStopped, .vmPaused:
+                vc.enterSuspended(isBusy: false)
+            case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
+                vc.enterSuspended(isBusy: true)
+            case .vmStarted:
+                vc.enterLive()
+            @unknown default:
+                break
+            }
+        }
+        return vc
+    }
+    
+    func updateUIViewController(_ uiViewController: VMDisplayViewController, context: Context) {
+        if let vc = uiViewController as? VMDisplayMetalViewController {
+            vc.vmInput = session.primaryInput
+        }
+        if state.isKeyboardShown != state.isKeyboardRequested {
+            DispatchQueue.main.async {
+                if state.isKeyboardRequested {
+                    uiViewController.showKeyboard()
+                } else {
+                    uiViewController.hideKeyboard()
+                }
+            }
+        }
+        if state.isDrivesMenuShown {
+            DispatchQueue.main.async {
+                uiViewController.presentDrives(for: session.vm)
+                state.isDrivesMenuShown = false
+            }
+        }
+        if state.isUSBMenuShown {
+            #if !WITH_QEMU_TCI
+            DispatchQueue.main.async {
+                if let usbManager = session.primaryUsbManager {
+                    uiViewController.presentUsb(for: usbManager)
+                }
+                state.isUSBMenuShown = false
+            }
+            #endif
+        }
+    }
+    
+    func makeCoordinator() -> Coordinator {
+        Coordinator(with: vm, device: device, state: $state)
+    }
+}

+ 193 - 0
Platform/iOS/VMSessionState.swift

@@ -0,0 +1,193 @@
+//
+// 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
+
+/// Represents the UI state for a single VM session.
+@MainActor class VMSessionState: NSObject, ObservableObject {
+    let vm: UTMQemuVirtualMachine
+    
+    var qemuConfig: UTMQemuConfiguration! {
+        vm.config.qemuConfig
+    }
+    
+    @Published var vmState: UTMVMState = .vmStopped
+    
+    @Published var vmError: String?
+    
+    @Published var primaryInput: CSInput?
+    
+    #if !WITH_QEMU_TCI
+    @Published var primaryUsbManager: CSUSBManager?
+    #else
+    let primaryUsbManager: Any? = nil
+    #endif
+    
+    @Published var primaryDisplay: CSDisplay?
+    
+    @Published var otherDisplays: [CSDisplay] = []
+    
+    @Published var primarySerial: CSPort?
+    
+    @Published var otherSerials: [CSPort] = []
+    
+    init(for vm: UTMQemuVirtualMachine) {
+        self.vm = vm
+        super.init()
+        vm.delegate = self
+        vm.ioDelegate = self
+    }
+}
+
+extension VMSessionState: UTMVirtualMachineDelegate {
+    func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
+        Task {
+            await MainActor.run {
+                vmState = state
+            }
+        }
+    }
+    
+    func virtualMachine(_ vm: UTMVirtualMachine, didErrorWithMessage message: String) {
+        Task {
+            await MainActor.run {
+                vmError = message
+            }
+        }
+    }
+}
+
+extension VMSessionState: UTMSpiceIODelegate {
+    func spiceDidCreateInput(_ input: CSInput) {
+        guard primaryInput == nil else {
+            return
+        }
+        Task {
+            await MainActor.run {
+                primaryInput = input
+            }
+        }
+    }
+    
+    func spiceDidDestroyInput(_ input: CSInput) {
+        guard primaryInput == input else {
+            return
+        }
+        Task {
+            await MainActor.run {
+                primaryInput = nil
+            }
+        }
+    }
+    
+    func spiceDidCreateDisplay(_ display: CSDisplay) {
+        guard display.isPrimaryDisplay else {
+            return
+        }
+        Task {
+            await MainActor.run {
+                primaryDisplay = display
+            }
+        }
+    }
+    
+    func spiceDidDestroyDisplay(_ display: CSDisplay) {
+        guard display == primaryDisplay else {
+            return
+        }
+        Task {
+            await MainActor.run {
+                primaryDisplay = nil
+            }
+        }
+    }
+    
+    func spiceDidUpdateDisplay(_ display: CSDisplay) {
+        // nothing to do
+    }
+    
+    func spiceDidCreateSerial(_ serial: CSPort) {
+        guard primarySerial == nil else {
+            return
+        }
+        Task {
+            await MainActor.run {
+                primarySerial = serial
+            }
+        }
+    }
+    
+    func spiceDidDestroySerial(_ serial: CSPort) {
+        guard primarySerial == serial else {
+            return
+        }
+        Task {
+            await MainActor.run {
+                primarySerial = nil
+            }
+        }
+    }
+    
+    #if !WITH_QEMU_TCI
+    func spiceDidChangeUsbManager(_ usbManager: CSUSBManager?) {
+        Task {
+            await MainActor.run {
+                primaryUsbManager = usbManager
+            }
+        }
+    }
+    #endif
+}
+
+extension VMSessionState {
+    @objc private func suspend() {
+        // dummy function for selector
+    }
+    
+    func terminateApplication() {
+        DispatchQueue.main.async { [self] in
+            // animate to home screen
+            let app = UIApplication.shared
+            app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
+            
+            // wait 2 seconds while app is going background
+            Thread.sleep(forTimeInterval: 2)
+            
+            // exit app when app is in background
+            exit(0);
+        }
+    }
+    
+    func powerDown() {
+        vm.requestVmDeleteState()
+        vm.vmStop { _ in
+            self.terminateApplication()
+        }
+    }
+    
+    func pauseResume() {
+        let shouldSaveState = !vm.isRunningAsSnapshot
+        if vm.state == .vmStarted {
+            vm.requestVmPause(save: shouldSaveState)
+        } else if vm.state == .vmPaused {
+            vm.requestVmResume()
+        }
+    }
+    
+    func reset() {
+        vm.requestVmReset()
+    }
+}

+ 142 - 0
Platform/iOS/VMWindowState.swift

@@ -0,0 +1,142 @@
+//
+// 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
+
+/// Represents the UI state for a single window
+struct VMWindowState: Identifiable {
+    enum Device {
+        case display(CSDisplay)
+        case serial(CSPort)
+    }
+    
+    let id = UUID()
+    
+    var device: Device?
+    
+    var configIndex: Int = 0
+    
+    private var shouldViewportChange: Bool {
+        !(displayScale == 1.0 && displayOriginX == 0.0 && displayOriginY == 0.0)
+    }
+    
+    var displayScale: Float = 1.0 {
+        didSet {
+            isViewportChanged = shouldViewportChange
+        }
+    }
+    
+    var displayOriginX: Float = 0.0 {
+        didSet {
+            isViewportChanged = shouldViewportChange
+        }
+    }
+    
+    var displayOriginY: Float = 0.0 {
+        didSet {
+            isViewportChanged = shouldViewportChange
+        }
+    }
+    
+    var displayViewSize: CGSize = .zero
+    
+    var isUSBMenuShown: Bool = false
+    
+    var isDrivesMenuShown: Bool = false
+    
+    var isKeyboardRequested: Bool = false
+    
+    var isKeyboardShown: Bool = false
+    
+    var isViewportChanged: Bool = false
+    
+    var isUserInteracting: Bool = false
+    
+    var isBusy: Bool = false
+    
+    var isRunning: Bool = false
+    
+    var alert: Alert?
+}
+
+// MARK: - VM action alerts
+
+extension VMWindowState {
+    enum Alert: Identifiable {
+        var id: Self {
+            self
+        }
+        
+        case powerDown
+        case terminateApp
+        case restart
+        case error
+    }
+}
+
+// MARK: - Resizing display
+
+extension VMWindowState {
+    private var kVMDefaultResizeCmd: String {
+        "stty cols $COLS rows $ROWS\\n"
+    }
+    
+    private mutating func resizeDisplayToFit(_ display: CSDisplay) {
+        let viewSize = displayViewSize
+        let displaySize = display.displaySize
+        let scaled = CGSize(width: viewSize.width / displaySize.width, height: viewSize.height / displaySize.height)
+        let viewportScale = min(scaled.width, scaled.height)
+        display.viewportScale = viewportScale
+        display.viewportOrigin = .zero
+        // persist this change in viewState
+        displayScale = Float(viewportScale)
+        displayOriginX = 0;
+        displayOriginY = 0;
+    }
+    
+    private mutating func resetDisplay(_ display: CSDisplay) {
+        display.viewportScale = 1.0
+        display.viewportOrigin = .zero
+        // persist this change in viewState
+        displayScale = 1.0
+        displayOriginX = 0
+        displayOriginY = 0
+    }
+    
+    private mutating func resetConsole(_ serial: CSPort, command: String? = nil) {
+        let cols = Int(displayViewSize.width)
+        let rows = Int(displayViewSize.height)
+        let template = command ?? kVMDefaultResizeCmd
+        let cmd = template
+            .replacingOccurrences(of: "$COLS", with: String(cols))
+            .replacingOccurrences(of: "$ROWS", with: String(rows))
+            .replacingOccurrences(of: "\\n", with: "\n")
+        serial.write(cmd.data(using: .nonLossyASCII)!)
+    }
+    
+    mutating func toggleDisplayResize(command: String? = nil) {
+        if case let .display(display) = device {
+            if isViewportChanged {
+                resetDisplay(display)
+            } else {
+                resizeDisplayToFit(display)
+            }
+        } else if case let .serial(serial) = device {
+            resetConsole(serial)
+            isViewportChanged = false
+        }
+    }
+}

+ 187 - 0
Platform/iOS/VMWindowView.swift

@@ -0,0 +1,187 @@
+//
+// 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 SwiftUI
+import SwiftUIVisualEffects
+
+struct VMWindowView: View {
+    @State private var state: VMWindowState
+    @EnvironmentObject private var session: VMSessionState
+    
+    private let keyboardDidShowNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
+    private let keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
+    
+    init() {
+        _state = State(initialValue: VMWindowState())
+    }
+    
+    init(for display: CSDisplay, index: Int) {
+        self.init()
+        assert(index != 0)
+        state.device = .display(display)
+        state.configIndex = index
+    }
+    
+    init(for serial: CSPort, index: Int) {
+        self.init()
+        assert(index != 0)
+        state.device = .serial(serial)
+        state.configIndex = index
+    }
+    
+    private func withOptionalAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
+        if UIAccessibility.isReduceMotionEnabled {
+            return try body()
+        } else {
+            return try withAnimation(animation, body)
+        }
+    }
+    
+    var body: some View {
+        ZStack {
+            ZStack {
+                if let device = state.device {
+                    switch device {
+                    case .display(_):
+                        VMDisplayHostedView(vm: session.vm, device: device, state: $state)
+                    case .serial(_):
+                        VMDisplayHostedView(vm: session.vm, device: device, state: $state)
+                    }
+                } else if !state.isBusy && state.isRunning {
+                    // headless
+                    BusyIndicator()
+                }
+                if state.isBusy || !state.isRunning {
+                    BlurEffect().blurEffectStyle(.light)
+                    VStack {
+                        Spacer()
+                        HStack {
+                            Spacer()
+                            if state.isBusy {
+                                BigWhiteSpinner()
+                            } else if session.vmState == .vmPaused {
+                                Button {
+                                    session.vm.requestVmResume()
+                                } label: {
+                                    Label("Resume", systemImage: "playpause.circle.fill")
+                                }
+                            } else {
+                                Button {
+                                    session.vm.requestVmStart()
+                                } label: {
+                                    Label("Start", systemImage: "play.circle.fill")
+                                }
+                            }
+                            Spacer()
+                        }
+                        Spacer()
+                    }.labelStyle(.iconOnly)
+                        .font(.system(size: 128))
+                        .vibrancyEffect()
+                        .vibrancyEffectStyle(.label)
+                }
+            }.background(Color.black)
+            .ignoresSafeArea()
+            VMToolbarView(state: $state)
+        }
+        .alert(item: $state.alert, content: { type in
+            switch type {
+            case .powerDown:
+                return Alert(title: Text("Are you sure you want to stop this VM and exit? Any unsaved changes will be lost."), primaryButton: .cancel(Text("No")), secondaryButton: .destructive(Text("Yes")) {
+                    session.powerDown()
+                })
+            case .terminateApp:
+                return Alert(title: Text("Are you sure you want to exit UTM?"), primaryButton: .cancel(Text("No")), secondaryButton: .destructive(Text("Yes")) {
+                    session.terminateApplication()
+                })
+            case .restart:
+                return Alert(title: Text("Are you sure you want to reset this VM? Any unsaved changes will be lost."), primaryButton: .cancel(Text("No")), secondaryButton: .destructive(Text("Yes")) {
+                    session.reset()
+                })
+            case .error:
+                return Alert(title: Text(session.vmError!), dismissButton: .cancel(Text("OK")) {
+                    session.vmError = nil
+                    session.terminateApplication()
+                })
+            }
+        })
+        .onChange(of: session.primaryDisplay) { newValue in
+            if state.configIndex == 0 && !session.qemuConfig.displays.isEmpty {
+                withOptionalAnimation {
+                    if let display = newValue {
+                        state.device = .display(display)
+                    } else {
+                        state.device = nil
+                    }
+                }
+            }
+        }
+        .onChange(of: session.primarySerial) { newValue in
+            if state.configIndex == 0 && session.qemuConfig.displays.isEmpty && !session.qemuConfig.builtinSerials.isEmpty {
+                withOptionalAnimation {
+                    if let serial = newValue {
+                        state.device = .serial(serial)
+                    } else {
+                        state.device = nil
+                    }
+                }
+            }
+        }
+        .onChange(of: session.vmError) { newValue in
+            if newValue != nil {
+                state.alert = .error
+            }
+        }
+        .onChange(of: session.vmState) { newValue in
+            switch newValue {
+            case .vmStopped, .vmPaused:
+                withOptionalAnimation {
+                    state.isBusy = false
+                    state.isRunning = false
+                }
+                if newValue == .vmStopped {
+                    session.terminateApplication()
+                }
+            case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
+                withOptionalAnimation {
+                    state.isBusy = true
+                    state.isRunning = false
+                }
+            case .vmStarted:
+                withOptionalAnimation {
+                    state.isBusy = false
+                    state.isRunning = true
+                }
+            @unknown default:
+                break
+            }
+        }
+        .onReceive(keyboardDidShowNotification) { _ in
+            state.isKeyboardShown = true
+            state.isKeyboardRequested = true
+        }
+        .onReceive(keyboardDidHideNotification) { _ in
+            state.isKeyboardShown = false
+            state.isKeyboardRequested = false
+        }
+    }
+}
+
+struct VMWindowView_Previews: PreviewProvider {
+    static var previews: some View {
+        VMWindowView()
+    }
+}

+ 67 - 10
UTM.xcodeproj/project.pbxproj

@@ -45,6 +45,20 @@
 		8401865C2887AFDC0050AC51 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 8401865B2887AFDC0050AC51 /* SwiftTerm */; };
 		8401865C2887AFDC0050AC51 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 8401865B2887AFDC0050AC51 /* SwiftTerm */; };
 		8401865E2887B1620050AC51 /* VMDisplayTerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401865D2887B1620050AC51 /* VMDisplayTerminalViewController.swift */; };
 		8401865E2887B1620050AC51 /* VMDisplayTerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401865D2887B1620050AC51 /* VMDisplayTerminalViewController.swift */; };
 		8401865F2887B1620050AC51 /* VMDisplayTerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401865D2887B1620050AC51 /* VMDisplayTerminalViewController.swift */; };
 		8401865F2887B1620050AC51 /* VMDisplayTerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401865D2887B1620050AC51 /* VMDisplayTerminalViewController.swift */; };
+		84018683288A3B2E0050AC51 /* VMWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018682288A3B2E0050AC51 /* VMWindowView.swift */; };
+		84018684288A3B2E0050AC51 /* VMWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018682288A3B2E0050AC51 /* VMWindowView.swift */; };
+		84018686288A3B5B0050AC51 /* VMSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018685288A3B5B0050AC51 /* VMSessionState.swift */; };
+		84018687288A3B5B0050AC51 /* VMSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018685288A3B5B0050AC51 /* VMSessionState.swift */; };
+		84018689288A44C20050AC51 /* VMWindowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018688288A44C20050AC51 /* VMWindowState.swift */; };
+		8401868A288A44C20050AC51 /* VMWindowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018688288A44C20050AC51 /* VMWindowState.swift */; };
+		8401868F288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401868E288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift */; };
+		84018690288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401868E288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift */; };
+		84018691288A73300050AC51 /* VMDisplayViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = CE72B4AC2463579D00716A11 /* VMDisplayViewController.m */; };
+		84018692288A73310050AC51 /* VMDisplayViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = CE72B4AC2463579D00716A11 /* VMDisplayViewController.m */; };
+		84018695288B66370050AC51 /* SwiftUIVisualEffects in Frameworks */ = {isa = PBXBuildFile; productRef = 84018694288B66370050AC51 /* SwiftUIVisualEffects */; };
+		84018697288B71BF0050AC51 /* BusyIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018696288B71BF0050AC51 /* BusyIndicator.swift */; };
+		84018698288B71BF0050AC51 /* BusyIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018696288B71BF0050AC51 /* BusyIndicator.swift */; };
+		84018699288B71BF0050AC51 /* BusyIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84018696288B71BF0050AC51 /* BusyIndicator.swift */; };
 		8401FD71269BEB2B00265F0D /* main.c in Sources */ = {isa = PBXBuildFile; fileRef = CE6B240A25F1F3CE0020D43E /* main.c */; };
 		8401FD71269BEB2B00265F0D /* main.c in Sources */ = {isa = PBXBuildFile; fileRef = CE6B240A25F1F3CE0020D43E /* main.c */; };
 		8401FD72269BEB3000265F0D /* Bootstrap.c in Sources */ = {isa = PBXBuildFile; fileRef = CE0DF17125A80B6300A51894 /* Bootstrap.c */; };
 		8401FD72269BEB3000265F0D /* Bootstrap.c in Sources */ = {isa = PBXBuildFile; fileRef = CE0DF17125A80B6300A51894 /* Bootstrap.c */; };
 		8401FD7A269BECE200265F0D /* QEMULauncher.app in Embed Launcher */ = {isa = PBXBuildFile; fileRef = 8401FD62269BE9C500265F0D /* QEMULauncher.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		8401FD7A269BECE200265F0D /* QEMULauncher.app in Embed Launcher */ = {isa = PBXBuildFile; fileRef = 8401FD62269BE9C500265F0D /* QEMULauncher.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -194,10 +208,9 @@
 		84C60FB82681A41B00B58C00 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; };
 		84C60FB82681A41B00B58C00 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; };
 		84C60FBA268269D700B58C00 /* VMDisplayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */; };
 		84C60FBA268269D700B58C00 /* VMDisplayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */; };
 		84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */; };
 		84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */; };
-		84C60FBC268289EF00B58C00 /* VMDisplayViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = CE72B4AC2463579D00716A11 /* VMDisplayViewController.m */; };
-		84C60FBD268289EF00B58C00 /* VMDisplayViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = CE72B4AC2463579D00716A11 /* VMDisplayViewController.m */; };
-		84C60FBF2682BDA800B58C00 /* VMToolbarActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FBE2682BDA800B58C00 /* VMToolbarActions.swift */; };
-		84C60FC02682BDA800B58C00 /* VMToolbarActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FBE2682BDA800B58C00 /* VMToolbarActions.swift */; };
+		84CF5DD3288DCE6400D01721 /* VMDisplayHostedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CF5DD2288DCE6400D01721 /* VMDisplayHostedView.swift */; };
+		84CF5DD4288DCE6400D01721 /* VMDisplayHostedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CF5DD2288DCE6400D01721 /* VMDisplayHostedView.swift */; };
+		84CF5DF3288E433F00D01721 /* SwiftUIVisualEffects in Frameworks */ = {isa = PBXBuildFile; productRef = 84CF5DF2288E433F00D01721 /* SwiftUIVisualEffects */; };
 		84F746B9276FF40900A20C87 /* VMDisplayAppleWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F746B8276FF40900A20C87 /* VMDisplayAppleWindowController.swift */; };
 		84F746B9276FF40900A20C87 /* VMDisplayAppleWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F746B8276FF40900A20C87 /* VMDisplayAppleWindowController.swift */; };
 		84F746BB276FF70700A20C87 /* VMDisplayQemuDisplayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F746BA276FF70700A20C87 /* VMDisplayQemuDisplayController.swift */; };
 		84F746BB276FF70700A20C87 /* VMDisplayQemuDisplayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F746BA276FF70700A20C87 /* VMDisplayQemuDisplayController.swift */; };
 		84FCABBA268CE05E0036196C /* UTMQemuVirtualMachine.m in Sources */ = {isa = PBXBuildFile; fileRef = 84FCABB9268CE05E0036196C /* UTMQemuVirtualMachine.m */; };
 		84FCABBA268CE05E0036196C /* UTMQemuVirtualMachine.m in Sources */ = {isa = PBXBuildFile; fileRef = 84FCABB9268CE05E0036196C /* UTMQemuVirtualMachine.m */; };
@@ -1551,6 +1564,11 @@
 		83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "VMDisplayMetalViewController+Pointer.h"; sourceTree = "<group>"; };
 		83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "VMDisplayMetalViewController+Pointer.h"; sourceTree = "<group>"; };
 		83FBDD55242FA7BC00D2C5D7 /* VMDisplayMetalViewController+Pointer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "VMDisplayMetalViewController+Pointer.m"; sourceTree = "<group>"; };
 		83FBDD55242FA7BC00D2C5D7 /* VMDisplayMetalViewController+Pointer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "VMDisplayMetalViewController+Pointer.m"; sourceTree = "<group>"; };
 		8401865D2887B1620050AC51 /* VMDisplayTerminalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMDisplayTerminalViewController.swift; sourceTree = "<group>"; };
 		8401865D2887B1620050AC51 /* VMDisplayTerminalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMDisplayTerminalViewController.swift; sourceTree = "<group>"; };
+		84018682288A3B2E0050AC51 /* VMWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMWindowView.swift; sourceTree = "<group>"; };
+		84018685288A3B5B0050AC51 /* VMSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSessionState.swift; sourceTree = "<group>"; };
+		84018688288A44C20050AC51 /* VMWindowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMWindowState.swift; sourceTree = "<group>"; };
+		8401868E288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayViewControllerDelegate.swift; sourceTree = "<group>"; };
+		84018696288B71BF0050AC51 /* BusyIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusyIndicator.swift; sourceTree = "<group>"; };
 		8401FD62269BE9C500265F0D /* QEMULauncher.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QEMULauncher.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		8401FD62269BE9C500265F0D /* QEMULauncher.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QEMULauncher.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		8401FD9F269D266E00265F0D /* VMConfigAppleBootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAppleBootView.swift; sourceTree = "<group>"; };
 		8401FD9F269D266E00265F0D /* VMConfigAppleBootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAppleBootView.swift; sourceTree = "<group>"; };
 		8401FDA1269D3E2500265F0D /* VMConfigAppleNetworkingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAppleNetworkingView.swift; sourceTree = "<group>"; };
 		8401FDA1269D3E2500265F0D /* VMConfigAppleNetworkingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAppleNetworkingView.swift; sourceTree = "<group>"; };
@@ -1621,7 +1639,7 @@
 		84C584EA268FA6D1000FCABF /* VMConfigAppleSystemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAppleSystemView.swift; sourceTree = "<group>"; };
 		84C584EA268FA6D1000FCABF /* VMConfigAppleSystemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAppleSystemView.swift; sourceTree = "<group>"; };
 		84C60FB62681A41B00B58C00 /* VMToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMToolbarView.swift; sourceTree = "<group>"; };
 		84C60FB62681A41B00B58C00 /* VMToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMToolbarView.swift; sourceTree = "<group>"; };
 		84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayViewController.swift; sourceTree = "<group>"; };
 		84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayViewController.swift; sourceTree = "<group>"; };
-		84C60FBE2682BDA800B58C00 /* VMToolbarActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMToolbarActions.swift; sourceTree = "<group>"; };
+		84CF5DD2288DCE6400D01721 /* VMDisplayHostedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayHostedView.swift; sourceTree = "<group>"; };
 		84F746B8276FF40900A20C87 /* VMDisplayAppleWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayAppleWindowController.swift; sourceTree = "<group>"; };
 		84F746B8276FF40900A20C87 /* VMDisplayAppleWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayAppleWindowController.swift; sourceTree = "<group>"; };
 		84F746BA276FF70700A20C87 /* VMDisplayQemuDisplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayQemuDisplayController.swift; sourceTree = "<group>"; };
 		84F746BA276FF70700A20C87 /* VMDisplayQemuDisplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayQemuDisplayController.swift; sourceTree = "<group>"; };
 		84FCABB8268CE05E0036196C /* UTMQemuVirtualMachine.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMQemuVirtualMachine.h; sourceTree = "<group>"; };
 		84FCABB8268CE05E0036196C /* UTMQemuVirtualMachine.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMQemuVirtualMachine.h; sourceTree = "<group>"; };
@@ -2188,6 +2206,7 @@
 				CE2D933A24AD46670059923A /* libgstaudiorate.a in Frameworks */,
 				CE2D933A24AD46670059923A /* libgstaudiorate.a in Frameworks */,
 				B3DDF57226E9BBA300CE47F0 /* AltKit in Frameworks */,
 				B3DDF57226E9BBA300CE47F0 /* AltKit in Frameworks */,
 				CE2D933B24AD46670059923A /* libgstvideofilter.a in Frameworks */,
 				CE2D933B24AD46670059923A /* libgstvideofilter.a in Frameworks */,
+				84018695288B66370050AC51 /* SwiftUIVisualEffects in Frameworks */,
 				CE2D933C24AD46670059923A /* libgstapp.a in Frameworks */,
 				CE2D933C24AD46670059923A /* libgstapp.a in Frameworks */,
 				CE2D933D24AD46670059923A /* libgstgio.a in Frameworks */,
 				CE2D933D24AD46670059923A /* libgstgio.a in Frameworks */,
 				CE2D933E24AD46670059923A /* libgsttypefindfunctions.a in Frameworks */,
 				CE2D933E24AD46670059923A /* libgsttypefindfunctions.a in Frameworks */,
@@ -2331,6 +2350,7 @@
 				CEA45F28263519B5002FA97D /* libgstvideoscale.a in Frameworks */,
 				CEA45F28263519B5002FA97D /* libgstvideoscale.a in Frameworks */,
 				CEA45F29263519B5002FA97D /* IQKeyboardManagerSwift in Frameworks */,
 				CEA45F29263519B5002FA97D /* IQKeyboardManagerSwift in Frameworks */,
 				CEA45F2A263519B5002FA97D /* MetalKit.framework in Frameworks */,
 				CEA45F2A263519B5002FA97D /* MetalKit.framework in Frameworks */,
+				84CF5DF3288E433F00D01721 /* SwiftUIVisualEffects in Frameworks */,
 				CEA45F2B263519B5002FA97D /* libgstvolume.a in Frameworks */,
 				CEA45F2B263519B5002FA97D /* libgstvolume.a in Frameworks */,
 				CEA45F2C263519B5002FA97D /* libgstcoreelements.a in Frameworks */,
 				CEA45F2C263519B5002FA97D /* libgstcoreelements.a in Frameworks */,
 				CEA45F2D263519B5002FA97D /* libgstvideorate.a in Frameworks */,
 				CEA45F2D263519B5002FA97D /* libgstvideorate.a in Frameworks */,
@@ -2606,8 +2626,12 @@
 				CED814EE24C7EB760042F0F1 /* ImagePicker.swift */,
 				CED814EE24C7EB760042F0F1 /* ImagePicker.swift */,
 				CEBBF1A424B56A2900C15049 /* UTMDataExtension.swift */,
 				CEBBF1A424B56A2900C15049 /* UTMDataExtension.swift */,
 				CE2D954D24AD4F980059923A /* VMConfigNetworkPortForwardView.swift */,
 				CE2D954D24AD4F980059923A /* VMConfigNetworkPortForwardView.swift */,
+				84CF5DD2288DCE6400D01721 /* VMDisplayHostedView.swift */,
+				84018685288A3B5B0050AC51 /* VMSessionState.swift */,
 				CE2D955124AD4F980059923A /* VMDrivesSettingsView.swift */,
 				CE2D955124AD4F980059923A /* VMDrivesSettingsView.swift */,
 				CE2D954C24AD4F980059923A /* VMSettingsView.swift */,
 				CE2D954C24AD4F980059923A /* VMSettingsView.swift */,
+				84018688288A44C20050AC51 /* VMWindowState.swift */,
+				84018682288A3B2E0050AC51 /* VMWindowView.swift */,
 				CEF0307026A2B04300667B63 /* VMWizardView.swift */,
 				CEF0307026A2B04300667B63 /* VMWizardView.swift */,
 				CE95877426D74C2A0086BDE8 /* iOS.entitlements */,
 				CE95877426D74C2A0086BDE8 /* iOS.entitlements */,
 				CE2D954F24AD4F980059923A /* Info.plist */,
 				CE2D954F24AD4F980059923A /* Info.plist */,
@@ -3015,7 +3039,6 @@
 				CE20FAE62448D2BE0059AE11 /* VMScroll.h */,
 				CE20FAE62448D2BE0059AE11 /* VMScroll.h */,
 				CE20FAE72448D2BE0059AE11 /* VMScroll.m */,
 				CE20FAE72448D2BE0059AE11 /* VMScroll.m */,
 				84C60FB62681A41B00B58C00 /* VMToolbarView.swift */,
 				84C60FB62681A41B00B58C00 /* VMToolbarView.swift */,
-				84C60FBE2682BDA800B58C00 /* VMToolbarActions.swift */,
 				CEC05DF42463E3D300DA82B2 /* VMDisplayView.h */,
 				CEC05DF42463E3D300DA82B2 /* VMDisplayView.h */,
 				CEC05DF52463E3D300DA82B2 /* VMDisplayView.m */,
 				CEC05DF52463E3D300DA82B2 /* VMDisplayView.m */,
 				CE72B4A92463532B00716A11 /* VMDisplayView.xib */,
 				CE72B4A92463532B00716A11 /* VMDisplayView.xib */,
@@ -3024,6 +3047,7 @@
 				CE72B4AC2463579D00716A11 /* VMDisplayViewController.m */,
 				CE72B4AC2463579D00716A11 /* VMDisplayViewController.m */,
 				CEA905C72603DA0D00801E7C /* VMDisplayViewController+USB.h */,
 				CEA905C72603DA0D00801E7C /* VMDisplayViewController+USB.h */,
 				CEA905C82603DA0D00801E7C /* VMDisplayViewController+USB.m */,
 				CEA905C82603DA0D00801E7C /* VMDisplayViewController+USB.m */,
+				8401868E288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift */,
 				5286EC93243748C3007E6CBC /* VMDisplayMetalViewController.h */,
 				5286EC93243748C3007E6CBC /* VMDisplayMetalViewController.h */,
 				5286EC94243748C3007E6CBC /* VMDisplayMetalViewController.m */,
 				5286EC94243748C3007E6CBC /* VMDisplayMetalViewController.m */,
 				CE3ADD65240EFBCA002D6A5F /* VMDisplayMetalViewController+Keyboard.h */,
 				CE3ADD65240EFBCA002D6A5F /* VMDisplayMetalViewController+Keyboard.h */,
@@ -3071,6 +3095,7 @@
 				8471772727CD3CAB00D3A50B /* DetailedSection.swift */,
 				8471772727CD3CAB00D3A50B /* DetailedSection.swift */,
 				4B224B9C279D4D8100B63CFF /* InListButtonStyle.swift */,
 				4B224B9C279D4D8100B63CFF /* InListButtonStyle.swift */,
 				CEF0304D26A2AFBE00667B63 /* BigWhiteSpinner.swift */,
 				CEF0304D26A2AFBE00667B63 /* BigWhiteSpinner.swift */,
+				84018696288B71BF0050AC51 /* BusyIndicator.swift */,
 				CE7D972B24B2B17D0080CB69 /* BusyOverlay.swift */,
 				CE7D972B24B2B17D0080CB69 /* BusyOverlay.swift */,
 				CE772AAB25C8B0F600E4E379 /* ContentView.swift */,
 				CE772AAB25C8B0F600E4E379 /* ContentView.swift */,
 				8471770927CCA60600D3A50B /* DefaultPicker.swift */,
 				8471770927CCA60600D3A50B /* DefaultPicker.swift */,
@@ -3216,6 +3241,7 @@
 				B329049B270FE136002707AC /* AltKit */,
 				B329049B270FE136002707AC /* AltKit */,
 				84B36D1D27B3264600C22685 /* CocoaSpice */,
 				84B36D1D27B3264600C22685 /* CocoaSpice */,
 				840186592887AFD50050AC51 /* SwiftTerm */,
 				840186592887AFD50050AC51 /* SwiftTerm */,
+				84018694288B66370050AC51 /* SwiftUIVisualEffects */,
 			);
 			);
 			productName = UTM;
 			productName = UTM;
 			productReference = CE2D93BE24AD46670059923A /* UTM.app */;
 			productReference = CE2D93BE24AD46670059923A /* UTM.app */;
@@ -3287,6 +3313,7 @@
 				83993293272F685F0059355F /* ZIPFoundation */,
 				83993293272F685F0059355F /* ZIPFoundation */,
 				84B36D1F27B3264E00C22685 /* CocoaSpiceNoUsb */,
 				84B36D1F27B3264E00C22685 /* CocoaSpiceNoUsb */,
 				8401865B2887AFDC0050AC51 /* SwiftTerm */,
 				8401865B2887AFDC0050AC51 /* SwiftTerm */,
+				84CF5DF2288E433F00D01721 /* SwiftUIVisualEffects */,
 			);
 			);
 			productName = UTM;
 			productName = UTM;
 			productReference = CEA45FB9263519B5002FA97D /* UTM SE.app */;
 			productReference = CEA45FB9263519B5002FA97D /* UTM SE.app */;
@@ -3358,6 +3385,7 @@
 				B329049A270FE136002707AC /* XCRemoteSwiftPackageReference "AltKit" */,
 				B329049A270FE136002707AC /* XCRemoteSwiftPackageReference "AltKit" */,
 				848F71E4277A2466006A0240 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
 				848F71E4277A2466006A0240 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
 				84B36D1C27B3261E00C22685 /* XCRemoteSwiftPackageReference "CocoaSpice" */,
 				84B36D1C27B3261E00C22685 /* XCRemoteSwiftPackageReference "CocoaSpice" */,
+				84018693288B66370050AC51 /* XCRemoteSwiftPackageReference "swiftui-visual-effects" */,
 			);
 			);
 			productRefGroup = CE550BCA225947990063E575 /* Products */;
 			productRefGroup = CE550BCA225947990063E575 /* Products */;
 			projectDirPath = "";
 			projectDirPath = "";
@@ -3484,6 +3512,7 @@
 				CE2D927C24AD46670059923A /* UTMQemu.m in Sources */,
 				CE2D927C24AD46670059923A /* UTMQemu.m in Sources */,
 				CE2D927D24AD46670059923A /* qapi-visit-machine.c in Sources */,
 				CE2D927D24AD46670059923A /* qapi-visit-machine.c in Sources */,
 				CEBE820326A4C1B5007AAB12 /* VMWizardDrivesView.swift in Sources */,
 				CEBE820326A4C1B5007AAB12 /* VMWizardDrivesView.swift in Sources */,
+				84018689288A44C20050AC51 /* VMWindowState.swift in Sources */,
 				CE2D927F24AD46670059923A /* qapi-commands-machine-target.c in Sources */,
 				CE2D927F24AD46670059923A /* qapi-commands-machine-target.c in Sources */,
 				CE2D928024AD46670059923A /* UTMLegacyQemuConfigurationPortForward.m in Sources */,
 				CE2D928024AD46670059923A /* UTMLegacyQemuConfigurationPortForward.m in Sources */,
 				CE2D928124AD46670059923A /* qapi-events.c in Sources */,
 				CE2D928124AD46670059923A /* qapi-events.c in Sources */,
@@ -3509,6 +3538,7 @@
 				CE2D928E24AD46670059923A /* UTMLegacyQemuConfiguration+Miscellaneous.m in Sources */,
 				CE2D928E24AD46670059923A /* UTMLegacyQemuConfiguration+Miscellaneous.m in Sources */,
 				CEBBF1A524B56A2900C15049 /* UTMDataExtension.swift in Sources */,
 				CEBBF1A524B56A2900C15049 /* UTMDataExtension.swift in Sources */,
 				CE2D928F24AD46670059923A /* qapi-types-migration.c in Sources */,
 				CE2D928F24AD46670059923A /* qapi-types-migration.c in Sources */,
+				84CF5DD3288DCE6400D01721 /* VMDisplayHostedView.swift in Sources */,
 				CE2D929024AD46670059923A /* qapi-types-net.c in Sources */,
 				CE2D929024AD46670059923A /* qapi-types-net.c in Sources */,
 				CE2D929124AD46670059923A /* qapi-types-rdma.c in Sources */,
 				CE2D929124AD46670059923A /* qapi-types-rdma.c in Sources */,
 				848D99C02866D9CE0055C215 /* QEMUArgumentBuilder.swift in Sources */,
 				848D99C02866D9CE0055C215 /* QEMUArgumentBuilder.swift in Sources */,
@@ -3527,7 +3557,6 @@
 				CE2D958324AD4F990059923A /* VMConfigNetworkView.swift in Sources */,
 				CE2D958324AD4F990059923A /* VMConfigNetworkView.swift in Sources */,
 				CE2D929A24AD46670059923A /* qapi-commands-sockets.c in Sources */,
 				CE2D929A24AD46670059923A /* qapi-commands-sockets.c in Sources */,
 				CE2D929C24AD46670059923A /* UTMViewState.m in Sources */,
 				CE2D929C24AD46670059923A /* UTMViewState.m in Sources */,
-				84C60FBF2682BDA800B58C00 /* VMToolbarActions.swift in Sources */,
 				CE020BAB24AEE00000B44AB6 /* UTMLoggingSwift.swift in Sources */,
 				CE020BAB24AEE00000B44AB6 /* UTMLoggingSwift.swift in Sources */,
 				CEF0306426A2AFDF00667B63 /* VMWizardOSLinuxView.swift in Sources */,
 				CEF0306426A2AFDF00667B63 /* VMWizardOSLinuxView.swift in Sources */,
 				CEBE820B26A4C8E0007AAB12 /* VMWizardSummaryView.swift in Sources */,
 				CEBE820B26A4C8E0007AAB12 /* VMWizardSummaryView.swift in Sources */,
@@ -3553,6 +3582,7 @@
 				CE2D92A924AD46670059923A /* qapi-types-transaction.c in Sources */,
 				CE2D92A924AD46670059923A /* qapi-types-transaction.c in Sources */,
 				843BF83828451B380029D60D /* UTMConfigurationTerminal.swift in Sources */,
 				843BF83828451B380029D60D /* UTMConfigurationTerminal.swift in Sources */,
 				CE4EF2702506DBFD00E9D33B /* VMRemovableDrivesViewController.swift in Sources */,
 				CE4EF2702506DBFD00E9D33B /* VMRemovableDrivesViewController.swift in Sources */,
+				84018683288A3B2E0050AC51 /* VMWindowView.swift in Sources */,
 				83034C0726AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
 				83034C0726AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
 				CE2D92AA24AD46670059923A /* UTMSpiceIO.m in Sources */,
 				CE2D92AA24AD46670059923A /* UTMSpiceIO.m in Sources */,
 				84909A9127CADAE0005605F1 /* UTMUnavailableVMView.swift in Sources */,
 				84909A9127CADAE0005605F1 /* UTMUnavailableVMView.swift in Sources */,
@@ -3589,11 +3619,13 @@
 				CE2D92BE24AD46670059923A /* qapi-types-error.c in Sources */,
 				CE2D92BE24AD46670059923A /* qapi-types-error.c in Sources */,
 				CE2D92BF24AD46670059923A /* qapi-commands-authz.c in Sources */,
 				CE2D92BF24AD46670059923A /* qapi-commands-authz.c in Sources */,
 				CE2D92C124AD46670059923A /* UIViewController+Extensions.m in Sources */,
 				CE2D92C124AD46670059923A /* UIViewController+Extensions.m in Sources */,
+				84018697288B71BF0050AC51 /* BusyIndicator.swift in Sources */,
 				CE2D92C224AD46670059923A /* qapi-types.c in Sources */,
 				CE2D92C224AD46670059923A /* qapi-types.c in Sources */,
 				CE2D92C324AD46670059923A /* qapi-events-sockets.c in Sources */,
 				CE2D92C324AD46670059923A /* qapi-events-sockets.c in Sources */,
 				CE2D92C424AD46670059923A /* qapi-visit-sockets.c in Sources */,
 				CE2D92C424AD46670059923A /* qapi-visit-sockets.c in Sources */,
 				CE2D92C524AD46670059923A /* qapi-events-misc-target.c in Sources */,
 				CE2D92C524AD46670059923A /* qapi-events-misc-target.c in Sources */,
 				CE2D92C624AD46670059923A /* qapi-commands-common.c in Sources */,
 				CE2D92C624AD46670059923A /* qapi-commands-common.c in Sources */,
+				84018686288A3B5B0050AC51 /* VMSessionState.swift in Sources */,
 				CE2D92C724AD46670059923A /* qapi-types-run-state.c in Sources */,
 				CE2D92C724AD46670059923A /* qapi-types-run-state.c in Sources */,
 				CE2D92C824AD46670059923A /* qapi-commands-machine.c in Sources */,
 				CE2D92C824AD46670059923A /* qapi-commands-machine.c in Sources */,
 				CE2D957324AD4F990059923A /* VMConfigSharingView.swift in Sources */,
 				CE2D957324AD4F990059923A /* VMConfigSharingView.swift in Sources */,
@@ -3611,6 +3643,7 @@
 				CE2D92D024AD46670059923A /* qapi-types-machine.c in Sources */,
 				CE2D92D024AD46670059923A /* qapi-types-machine.c in Sources */,
 				CE2D92D124AD46670059923A /* qapi-commands-transaction.c in Sources */,
 				CE2D92D124AD46670059923A /* qapi-commands-transaction.c in Sources */,
 				CE2D92D224AD46670059923A /* UTMVirtualMachine.m in Sources */,
 				CE2D92D224AD46670059923A /* UTMVirtualMachine.m in Sources */,
+				8401868F288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift in Sources */,
 				CE2D92D324AD46670059923A /* qapi-events-qom.c in Sources */,
 				CE2D92D324AD46670059923A /* qapi-events-qom.c in Sources */,
 				CE2D92D424AD46670059923A /* qapi-commands-migration.c in Sources */,
 				CE2D92D424AD46670059923A /* qapi-commands-migration.c in Sources */,
 				CE2D92D524AD46670059923A /* qapi-visit-misc-target.c in Sources */,
 				CE2D92D524AD46670059923A /* qapi-visit-misc-target.c in Sources */,
@@ -3709,9 +3742,9 @@
 				CE2D931324AD46670059923A /* qapi-visit-authz.c in Sources */,
 				CE2D931324AD46670059923A /* qapi-visit-authz.c in Sources */,
 				CE2D956924AD4F990059923A /* VMPlaceholderView.swift in Sources */,
 				CE2D956924AD4F990059923A /* VMPlaceholderView.swift in Sources */,
 				CE2D931524AD46670059923A /* VMDisplayMetalViewController+Pointer.m in Sources */,
 				CE2D931524AD46670059923A /* VMDisplayMetalViewController+Pointer.m in Sources */,
+				84018692288A73310050AC51 /* VMDisplayViewController.m in Sources */,
 				CE2D931624AD46670059923A /* qapi-commands-run-state.c in Sources */,
 				CE2D931624AD46670059923A /* qapi-commands-run-state.c in Sources */,
 				CE2D931924AD46670059923A /* qapi-types-misc-target.c in Sources */,
 				CE2D931924AD46670059923A /* qapi-types-misc-target.c in Sources */,
-				84C60FBD268289EF00B58C00 /* VMDisplayViewController.m in Sources */,
 				CE2D931A24AD46670059923A /* qapi-events-rdma.c in Sources */,
 				CE2D931A24AD46670059923A /* qapi-events-rdma.c in Sources */,
 				CE2D931D24AD46670059923A /* qapi-visit-dump.c in Sources */,
 				CE2D931D24AD46670059923A /* qapi-visit-dump.c in Sources */,
 				CE2D931E24AD46670059923A /* qapi-visit-error.c in Sources */,
 				CE2D931E24AD46670059923A /* qapi-visit-error.c in Sources */,
@@ -3977,6 +4010,7 @@
 				848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
 				848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
 				848A98BA286A17A8006F0550 /* UTMAppleConfigurationNetwork.swift in Sources */,
 				848A98BA286A17A8006F0550 /* UTMAppleConfigurationNetwork.swift in Sources */,
 				CE0B6D1124AD57C700FE012D /* qapi-builtin-visit.c in Sources */,
 				CE0B6D1124AD57C700FE012D /* qapi-builtin-visit.c in Sources */,
+				84018699288B71BF0050AC51 /* BusyIndicator.swift in Sources */,
 				848A98C0286A20E3006F0550 /* UTMAppleConfigurationBoot.swift in Sources */,
 				848A98C0286A20E3006F0550 /* UTMAppleConfigurationBoot.swift in Sources */,
 				CE0B6D3F24AD584C00FE012D /* qapi-visit-qom.c in Sources */,
 				CE0B6D3F24AD584C00FE012D /* qapi-visit-qom.c in Sources */,
 				848D99B6286300160055C215 /* QEMUArgument.swift in Sources */,
 				848D99B6286300160055C215 /* QEMUArgument.swift in Sources */,
@@ -4047,6 +4081,7 @@
 				CEA45E30263519B5002FA97D /* WKWebView+Workarounds.m in Sources */,
 				CEA45E30263519B5002FA97D /* WKWebView+Workarounds.m in Sources */,
 				83A004BA26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */,
 				83A004BA26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */,
 				CEA45E31263519B5002FA97D /* qapi-visit-crypto.c in Sources */,
 				CEA45E31263519B5002FA97D /* qapi-visit-crypto.c in Sources */,
+				8401868A288A44C20050AC51 /* VMWindowState.swift in Sources */,
 				843BF8312844853E0029D60D /* UTMQemuConfigurationNetwork.swift in Sources */,
 				843BF8312844853E0029D60D /* UTMQemuConfigurationNetwork.swift in Sources */,
 				CEA45E32263519B5002FA97D /* qapi-visit-tpm.c in Sources */,
 				CEA45E32263519B5002FA97D /* qapi-visit-tpm.c in Sources */,
 				CEA45E35263519B5002FA97D /* qapi-visit-trace.c in Sources */,
 				CEA45E35263519B5002FA97D /* qapi-visit-trace.c in Sources */,
@@ -4099,6 +4134,7 @@
 				CEA45E60263519B5002FA97D /* UTMJSONStream.m in Sources */,
 				CEA45E60263519B5002FA97D /* UTMJSONStream.m in Sources */,
 				CEA45E61263519B5002FA97D /* VMConfigNetworkView.swift in Sources */,
 				CEA45E61263519B5002FA97D /* VMConfigNetworkView.swift in Sources */,
 				CEA45E62263519B5002FA97D /* qapi-commands-sockets.c in Sources */,
 				CEA45E62263519B5002FA97D /* qapi-commands-sockets.c in Sources */,
+				84018698288B71BF0050AC51 /* BusyIndicator.swift in Sources */,
 				CEA45E63263519B5002FA97D /* UTMViewState.m in Sources */,
 				CEA45E63263519B5002FA97D /* UTMViewState.m in Sources */,
 				CEA45E64263519B5002FA97D /* UTMLoggingSwift.swift in Sources */,
 				CEA45E64263519B5002FA97D /* UTMLoggingSwift.swift in Sources */,
 				CEA45E65263519B5002FA97D /* UTMApp.swift in Sources */,
 				CEA45E65263519B5002FA97D /* UTMApp.swift in Sources */,
@@ -4142,6 +4178,7 @@
 				CEA45E89263519B5002FA97D /* UTMQemuVirtualMachine+Drives.m in Sources */,
 				CEA45E89263519B5002FA97D /* UTMQemuVirtualMachine+Drives.m in Sources */,
 				CEA45E8A263519B5002FA97D /* qapi-events-trace.c in Sources */,
 				CEA45E8A263519B5002FA97D /* qapi-events-trace.c in Sources */,
 				CEA45E8B263519B5002FA97D /* UTMQcow2.c in Sources */,
 				CEA45E8B263519B5002FA97D /* UTMQcow2.c in Sources */,
+				84018684288A3B2E0050AC51 /* VMWindowView.swift in Sources */,
 				8401865F2887B1620050AC51 /* VMDisplayTerminalViewController.swift in Sources */,
 				8401865F2887B1620050AC51 /* VMDisplayTerminalViewController.swift in Sources */,
 				CEA45E8C263519B5002FA97D /* qapi-types-block-export.c in Sources */,
 				CEA45E8C263519B5002FA97D /* qapi-types-block-export.c in Sources */,
 				CEA45E8D263519B5002FA97D /* qapi-events-qdev.c in Sources */,
 				CEA45E8D263519B5002FA97D /* qapi-events-qdev.c in Sources */,
@@ -4163,6 +4200,7 @@
 				CEA45E9D263519B5002FA97D /* qapi-events-sockets.c in Sources */,
 				CEA45E9D263519B5002FA97D /* qapi-events-sockets.c in Sources */,
 				848D99BD28636AC90055C215 /* UTMConfigurationDrive.swift in Sources */,
 				848D99BD28636AC90055C215 /* UTMConfigurationDrive.swift in Sources */,
 				CEA45E9E263519B5002FA97D /* qapi-visit-sockets.c in Sources */,
 				CEA45E9E263519B5002FA97D /* qapi-visit-sockets.c in Sources */,
+				84018687288A3B5B0050AC51 /* VMSessionState.swift in Sources */,
 				CEA45E9F263519B5002FA97D /* qapi-events-misc-target.c in Sources */,
 				CEA45E9F263519B5002FA97D /* qapi-events-misc-target.c in Sources */,
 				CEA45EA0263519B5002FA97D /* qapi-commands-common.c in Sources */,
 				CEA45EA0263519B5002FA97D /* qapi-commands-common.c in Sources */,
 				CEA45EA1263519B5002FA97D /* qapi-types-run-state.c in Sources */,
 				CEA45EA1263519B5002FA97D /* qapi-types-run-state.c in Sources */,
@@ -4196,7 +4234,6 @@
 				CEF0305226A2AFBF00667B63 /* BigWhiteSpinner.swift in Sources */,
 				CEF0305226A2AFBF00667B63 /* BigWhiteSpinner.swift in Sources */,
 				CEA45EBC263519B5002FA97D /* qapi-events-tpm.c in Sources */,
 				CEA45EBC263519B5002FA97D /* qapi-events-tpm.c in Sources */,
 				CEA45EBD263519B5002FA97D /* UTMQemuSystem.m in Sources */,
 				CEA45EBD263519B5002FA97D /* UTMQemuSystem.m in Sources */,
-				84C60FC02682BDA800B58C00 /* VMToolbarActions.swift in Sources */,
 				CEA45EBE263519B5002FA97D /* NumberTextField.swift in Sources */,
 				CEA45EBE263519B5002FA97D /* NumberTextField.swift in Sources */,
 				CEA45EC0263519B5002FA97D /* qapi-events-job.c in Sources */,
 				CEA45EC0263519B5002FA97D /* qapi-events-job.c in Sources */,
 				CEF0306526A2AFDF00667B63 /* VMWizardOSLinuxView.swift in Sources */,
 				CEF0306526A2AFDF00667B63 /* VMWizardOSLinuxView.swift in Sources */,
@@ -4217,6 +4254,7 @@
 				CEA45ED0263519B5002FA97D /* qapi-types-machine-target.c in Sources */,
 				CEA45ED0263519B5002FA97D /* qapi-types-machine-target.c in Sources */,
 				CEA45ED1263519B5002FA97D /* UTMLocationManager.m in Sources */,
 				CEA45ED1263519B5002FA97D /* UTMLocationManager.m in Sources */,
 				CEA45ED2263519B5002FA97D /* qapi-events-block.c in Sources */,
 				CEA45ED2263519B5002FA97D /* qapi-events-block.c in Sources */,
+				84CF5DD4288DCE6400D01721 /* VMDisplayHostedView.swift in Sources */,
 				CEBE820826A4C74E007AAB12 /* VMWizardSharingView.swift in Sources */,
 				CEBE820826A4C74E007AAB12 /* VMWizardSharingView.swift in Sources */,
 				84C60FB82681A41B00B58C00 /* VMToolbarView.swift in Sources */,
 				84C60FB82681A41B00B58C00 /* VMToolbarView.swift in Sources */,
 				CEA45ED3263519B5002FA97D /* VMSettingsView.swift in Sources */,
 				CEA45ED3263519B5002FA97D /* VMSettingsView.swift in Sources */,
@@ -4247,6 +4285,7 @@
 				CEA45EEE263519B5002FA97D /* qapi-commands.c in Sources */,
 				CEA45EEE263519B5002FA97D /* qapi-commands.c in Sources */,
 				CEA45EEF263519B5002FA97D /* qapi-commands-audio.c in Sources */,
 				CEA45EEF263519B5002FA97D /* qapi-commands-audio.c in Sources */,
 				CEF0304F26A2AFBF00667B63 /* BigButtonStyle.swift in Sources */,
 				CEF0304F26A2AFBF00667B63 /* BigButtonStyle.swift in Sources */,
+				84018691288A73300050AC51 /* VMDisplayViewController.m in Sources */,
 				CEA45EF2263519B5002FA97D /* qapi-events-common.c in Sources */,
 				CEA45EF2263519B5002FA97D /* qapi-events-common.c in Sources */,
 				84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
 				84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
 				CEA45EF4263519B5002FA97D /* VMConfigSoundView.swift in Sources */,
 				CEA45EF4263519B5002FA97D /* VMConfigSoundView.swift in Sources */,
@@ -4277,7 +4316,6 @@
 				848F71E9277A2A4E006A0240 /* UTMSerialPort.swift in Sources */,
 				848F71E9277A2A4E006A0240 /* UTMSerialPort.swift in Sources */,
 				843BF83928451B380029D60D /* UTMConfigurationTerminal.swift in Sources */,
 				843BF83928451B380029D60D /* UTMConfigurationTerminal.swift in Sources */,
 				CEA45F0A263519B5002FA97D /* UTMPasteboard.swift in Sources */,
 				CEA45F0A263519B5002FA97D /* UTMPasteboard.swift in Sources */,
-				84C60FBC268289EF00B58C00 /* VMDisplayViewController.m in Sources */,
 				CEA45F0B263519B5002FA97D /* qapi-visit-authz.c in Sources */,
 				CEA45F0B263519B5002FA97D /* qapi-visit-authz.c in Sources */,
 				CEA45F0C263519B5002FA97D /* VMPlaceholderView.swift in Sources */,
 				CEA45F0C263519B5002FA97D /* VMPlaceholderView.swift in Sources */,
 				CEA45F0D263519B5002FA97D /* VMDisplayMetalViewController+Pointer.m in Sources */,
 				CEA45F0D263519B5002FA97D /* VMDisplayMetalViewController+Pointer.m in Sources */,
@@ -4286,6 +4324,7 @@
 				CEA45F10263519B5002FA97D /* qapi-events-rdma.c in Sources */,
 				CEA45F10263519B5002FA97D /* qapi-events-rdma.c in Sources */,
 				CEA45F12263519B5002FA97D /* qapi-visit-dump.c in Sources */,
 				CEA45F12263519B5002FA97D /* qapi-visit-dump.c in Sources */,
 				CEA45F13263519B5002FA97D /* qapi-visit-error.c in Sources */,
 				CEA45F13263519B5002FA97D /* qapi-visit-error.c in Sources */,
+				84018690288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift in Sources */,
 				CEA45F14263519B5002FA97D /* error.c in Sources */,
 				CEA45F14263519B5002FA97D /* error.c in Sources */,
 				CEA45F15263519B5002FA97D /* UTMQemuManager+BlockDevices.m in Sources */,
 				CEA45F15263519B5002FA97D /* UTMQemuManager+BlockDevices.m in Sources */,
 				CEA45F16263519B5002FA97D /* cf-output-visitor.c in Sources */,
 				CEA45F16263519B5002FA97D /* cf-output-visitor.c in Sources */,
@@ -4938,6 +4977,14 @@
 				minimumVersion = 0.9.9;
 				minimumVersion = 0.9.9;
 			};
 			};
 		};
 		};
+		84018693288B66370050AC51 /* XCRemoteSwiftPackageReference "swiftui-visual-effects" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "https://github.com/lucasbrown/swiftui-visual-effects.git";
+			requirement = {
+				kind = exactVersion;
+				version = 1.0.3;
+			};
+		};
 		848F71E4277A2466006A0240 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
 		848F71E4277A2466006A0240 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
 			isa = XCRemoteSwiftPackageReference;
 			isa = XCRemoteSwiftPackageReference;
 			repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
 			repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
@@ -5022,6 +5069,11 @@
 			package = 848F71E4277A2466006A0240 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
 			package = 848F71E4277A2466006A0240 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
 			productName = SwiftTerm;
 			productName = SwiftTerm;
 		};
 		};
+		84018694288B66370050AC51 /* SwiftUIVisualEffects */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 84018693288B66370050AC51 /* XCRemoteSwiftPackageReference "swiftui-visual-effects" */;
+			productName = SwiftUIVisualEffects;
+		};
 		848F71E5277A2466006A0240 /* SwiftTerm */ = {
 		848F71E5277A2466006A0240 /* SwiftTerm */ = {
 			isa = XCSwiftPackageProductDependency;
 			isa = XCSwiftPackageProductDependency;
 			package = 848F71E4277A2466006A0240 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
 			package = 848F71E4277A2466006A0240 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
@@ -5042,6 +5094,11 @@
 			package = 84B36D1C27B3261E00C22685 /* XCRemoteSwiftPackageReference "CocoaSpice" */;
 			package = 84B36D1C27B3261E00C22685 /* XCRemoteSwiftPackageReference "CocoaSpice" */;
 			productName = CocoaSpice;
 			productName = CocoaSpice;
 		};
 		};
+		84CF5DF2288E433F00D01721 /* SwiftUIVisualEffects */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 84018693288B66370050AC51 /* XCRemoteSwiftPackageReference "swiftui-visual-effects" */;
+			productName = SwiftUIVisualEffects;
+		};
 		85EC515B27CC74AA004A51DE /* Logging */ = {
 		85EC515B27CC74AA004A51DE /* Logging */ = {
 			isa = XCSwiftPackageProductDependency;
 			isa = XCSwiftPackageProductDependency;
 			package = CE020BA524AEDEF000B44AB6 /* XCRemoteSwiftPackageReference "swift-log" */;
 			package = CE020BA524AEDEF000B44AB6 /* XCRemoteSwiftPackageReference "swift-log" */;

+ 9 - 0
UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -45,6 +45,15 @@
         "revision" : "e92856df7b5e4e12b09a9ebeb9abbd8410c9343b"
         "revision" : "e92856df7b5e4e12b09a9ebeb9abbd8410c9343b"
       }
       }
     },
     },
+    {
+      "identity" : "swiftui-visual-effects",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/lucasbrown/swiftui-visual-effects.git",
+      "state" : {
+        "revision" : "b26f8cebd55ff60ed8953768aa818dfb005b5838",
+        "version" : "1.0.3"
+      }
+    },
     {
     {
       "identity" : "zipfoundation",
       "identity" : "zipfoundation",
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",