Browse Source

Merge tag 'v2.4.0' into dev-monterey

osy 3 năm trước cách đây
mục cha
commit
7b1982907c
76 tập tin đã thay đổi với 2273 bổ sung533 xóa
  1. 3 3
      .github/workflows/build.yml
  2. 2 2
      Build.xcconfig
  3. 2 1
      CocoaSpice/CSConnection.m
  4. 7 4
      CocoaSpice/CSDisplayMetal.m
  5. 6 0
      Configuration/UTMQemuConfiguration+ConstantsGenerated.m
  6. 1 1
      Configuration/UTMQemuConfiguration+Defaults.h
  7. 85 25
      Configuration/UTMQemuConfiguration+Defaults.m
  8. 1 0
      Configuration/UTMQemuConfiguration+Networking.h
  9. 2 2
      Configuration/UTMQemuConfiguration+Networking.m
  10. BIN
      Icons/windows-11.png
  11. BIN
      Icons/windows-9x.png
  12. BIN
      Icons/windows-xp.png
  13. 16 16
      Managers/UTMJSONStream.m
  14. 56 0
      Managers/UTMPendingVirtualMachine.swift
  15. 2 1
      Managers/UTMQemuManager.m
  16. 91 34
      Managers/UTMQemuSystem.m
  17. 2 4
      Managers/UTMScreenshot.h
  18. 0 9
      Managers/UTMScreenshot.m
  19. 1 3
      Managers/UTMTerminal.m
  20. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt.png
  21. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt@2x.png
  22. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt.png
  23. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt@2x.png
  24. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt.png
  25. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt@2x.png
  26. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt.png
  27. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt@2x.png
  28. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt.png
  29. BIN
      Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt@2x.png
  30. 87 1
      Platform/Shared/ContentView.swift
  31. 1 1
      Platform/Shared/HTerm/libapps
  32. 5 5
      Platform/Shared/HTerm/terminal.js
  33. 153 0
      Platform/Shared/UTMImportFromWebTask.swift
  34. 102 0
      Platform/Shared/UTMPendingVMView.swift
  35. 5 1
      Platform/Shared/VMConfigDisplayView.swift
  36. 3 2
      Platform/Shared/VMConfigDriveCreateView.swift
  37. 4 1
      Platform/Shared/VMConfigInfoView.swift
  38. 9 2
      Platform/Shared/VMConfigNetworkView.swift
  39. 7 7
      Platform/Shared/VMConfigQEMUView.swift
  40. 1 1
      Platform/Shared/VMContextMenuModifier.swift
  41. 21 8
      Platform/Shared/VMDetailsView.swift
  42. 2 2
      Platform/Shared/VMDriveImage.swift
  43. 21 3
      Platform/Shared/VMShareFileModifier.swift
  44. 2 3
      Platform/Shared/VMToolbarModifier.swift
  45. 3 3
      Platform/Shared/VMWizardState.swift
  46. 5 0
      Platform/UTMApp.swift
  47. 107 17
      Platform/UTMData.swift
  48. 9 0
      Platform/UTMExtensions.swift
  49. 7 5
      Platform/iOS/Display/VMDisplayTerminalViewController.m
  50. 13 0
      Platform/iOS/Info.plist
  51. 2 2
      Platform/iOS/Legacy/VMConfigDriveDetailViewController.m
  52. 48 0
      Platform/iOS/Legacy/zh-Hans.lproj/Main.strings
  53. 16 0
      Platform/iOS/UTMDataExtension.swift
  54. 7 5
      Platform/iOS/VMConfigDrivesView.swift
  55. 18 0
      Platform/iOS/zh-Hans.lproj/InfoPlist.strings
  56. 39 0
      Platform/macOS/AppDelegate.swift
  57. 26 46
      Platform/macOS/Display/VMDisplayMetalWindowController.swift
  58. 6 3
      Platform/macOS/Display/VMDisplayTerminalWindowController.swift
  59. 1 1
      Platform/macOS/Display/VMDisplayWindow.xib
  60. 35 0
      Platform/macOS/Display/VMDisplayWindowController.swift
  61. 177 156
      Platform/macOS/Display/VMMetalView.swift
  62. 5 3
      Platform/macOS/Display/VMMetalViewInputDelegate.swift
  63. 13 0
      Platform/macOS/Info.plist
  64. 361 0
      Platform/macOS/KeyCodeMap.swift
  65. 105 0
      Platform/macOS/SavePanel.swift
  66. 5 0
      Platform/macOS/SettingsView.swift
  67. 0 67
      Platform/macOS/SharingServicePicker.swift
  68. 106 0
      Platform/macOS/UTMDataExtension.swift
  69. 2 2
      Platform/macOS/VMConfigDrivesButtons.swift
  70. 6 0
      Platform/macOS/zh-Hans.lproj/InfoPlist.strings
  71. 168 74
      Platform/zh-Hans.lproj/Localizable.strings
  72. 9 0
      QEMUHelper/zh-Hans.lproj/InfoPlist.strings
  73. 9 0
      QEMUHelper/zh-Hans.lproj/Localizable.strings
  74. 74 7
      UTM.xcodeproj/project.pbxproj
  75. 179 0
      patches/qemu-6.1.0-utm.patch
  76. 12 0
      scripts/const-gen.py

+ 3 - 3
.github/workflows/build.yml

@@ -41,13 +41,13 @@ jobs:
           submodules: recursive
       - name: Setup Xcode
         shell: bash
-        run: sudo xcode-select -switch /Applications/Xcode_13.0_beta.app
+        run: sudo xcode-select -switch /Applications/Xcode_13.1.app
       - name: Cache Sysroot
         id: cache-sysroot
         uses: actions/cache@v2
         with:
           path: sysroot-${{ matrix.platform }}-${{ matrix.arch }}
-          key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}-xcode-12
+          key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
       - name: Setup Path
         shell: bash
         run: |
@@ -112,7 +112,7 @@ jobs:
           path: sysroot.tgz
       - name: Setup Xcode
         shell: bash
-        run: sudo xcode-select -switch /Applications/Xcode_13.0_beta.app
+        run: sudo xcode-select -switch /Applications/Xcode_13.1.app
       - name: Build UTM
         run: |
           ./scripts/build_utm.sh -t "$SIGNING_TEAM_ID" -p macos -a "arm64 x86_64" -o UTM

+ 2 - 2
Build.xcconfig

@@ -17,8 +17,8 @@
 // Configuration settings file format documentation can be found at:
 // https://help.apple.com/xcode/#/dev745c5c974
 
-MARKETING_VERSION = 2.2.4
-CURRENT_PROJECT_VERSION = 36
+MARKETING_VERSION = 2.4.0
+CURRENT_PROJECT_VERSION = 39
 
 // Codesigning settings defined optionally, see Documentation/iOSDevelopment.md
 #include? "CodeSigning.xcconfig"

+ 2 - 1
CocoaSpice/CSConnection.m

@@ -39,6 +39,7 @@ static void cs_main_channel_event(SpiceChannel *channel, SpiceChannelEvent event
 {
     CSConnection *self = (__bridge CSConnection *)data;
     const GError *error = NULL;
+    NSString *genericMsg = NSLocalizedString(@"An error occurred trying to connect to SPICE.", @"CSConnection");
     
     switch (event) {
         case SPICE_CHANNEL_OPENED:
@@ -62,7 +63,7 @@ static void cs_main_channel_event(SpiceChannel *channel, SpiceChannelEvent event
             if (error) {
                 g_message("channel error: %s", error->message);
             }
-            [self.delegate spiceError:self err:(error ? [NSString stringWithUTF8String:error->message] : nil)];
+            [self.delegate spiceError:self err:(error ? [NSString stringWithUTF8String:error->message] : genericMsg)];
             break;
         default:
             /* TODO: more sophisticated error handling */

+ 7 - 4
CocoaSpice/CSDisplayMetal.m

@@ -14,6 +14,7 @@
 // limitations under the License.
 //
 
+@import CoreImage;
 #import "TargetConditionals.h"
 #import "UTMScreenshot.h"
 #import "UTMShaderTypes.h"
@@ -431,9 +432,11 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
         CGDataProviderRef dataProviderRef = CGDataProviderCreateWithData(NULL, self.canvasData, self.canvasStride * self.canvasArea.size.height, nil);
         img = CGImageCreate(self.canvasArea.size.width, self.canvasArea.size.height, 8, 32, self.canvasStride, colorSpaceRef, kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipFirst, dataProviderRef, NULL, NO, kCGRenderingIntentDefault);
         CGDataProviderRelease(dataProviderRef);
-    } else if (_glTexture) {
-        // TODO: make screenshot from IOSurface
-        img = NULL;
+    } else if (self.glTexture) {
+        CIImage *ciimage = [[CIImage alloc] initWithMTLTexture:self.glTexture options:nil];
+        CIImage *flipped = [ciimage imageByApplyingOrientation:kCGImagePropertyOrientationDownMirrored];
+        CIContext *cictx = [CIContext context];
+        img = [cictx createCGImage:flipped fromRect:flipped.extent];
     } else {
         img = NULL;
     }
@@ -450,7 +453,7 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
         CGImageRelease(img);
         return [[UTMScreenshot alloc] initWithImage:uiimg];
     } else {
-        return UTMScreenshot.none;
+        return nil;
     }
 }
 

+ 6 - 0
Configuration/UTMQemuConfiguration+ConstantsGenerated.m

@@ -5746,6 +5746,8 @@
             ],
         @"sparc":
             @[
+                @"tcx",
+                @"cg3",
             ],
         @"sparc64":
             @[
@@ -6100,6 +6102,8 @@
             ],
         @"sparc":
             @[
+                @"Sun TCX",
+                @"Sun cgthree",
             ],
         @"sparc64":
             @[
@@ -6671,6 +6675,7 @@
             ],
         @"sparc":
             @[
+                @"lance",
             ],
         @"sparc64":
             @[
@@ -7301,6 +7306,7 @@
             ],
         @"sparc":
             @[
+                @"Lance (Am7990)",
             ],
         @"sparc64":
             @[

+ 1 - 1
Configuration/UTMQemuConfiguration+Defaults.h

@@ -24,7 +24,7 @@ NS_ASSUME_NONNULL_BEGIN
 - (void)loadDefaults;
 - (void)loadDefaultsForTarget:(nullable NSString *)target architecture:(nullable NSString *)architecture;
 + (nullable NSString *)defaultMachinePropertiesForTarget:(nullable NSString *)target;
-+ (NSString *)defaultDriveInterfaceForTarget:(nullable NSString *)target type:(UTMDiskImageType)type;
++ (NSString *)defaultDriveInterfaceForTarget:(nullable NSString *)target architecture:(nullable NSString *)architecture type:(UTMDiskImageType)type;
 + (NSString *)defaultCPUForTarget:(NSString *)target architecture:(NSString *)architecture;
 
 @end

+ 85 - 25
Configuration/UTMQemuConfiguration+Defaults.m

@@ -14,6 +14,7 @@
 // limitations under the License.
 //
 
+#import "UTMQemuConfiguration+Constants.h"
 #import "UTMQemuConfiguration+Defaults.h"
 #import "UTMQemuConfiguration+Display.h"
 #import "UTMQemuConfiguration+Miscellaneous.h"
@@ -31,8 +32,8 @@
 
 - (void)loadDefaults {
     self.systemArchitecture = @"x86_64";
-    self.systemCPU = @"default";
     self.systemTarget = @"q35";
+    [self loadDefaultsForTarget:@"q35" architecture:@"x86_64"];
     self.systemMemory = @512;
     if (@available(iOS 14, *)) {
         // use bootindex on new UI
@@ -40,14 +41,84 @@
     } else {
         self.systemBootDevice = @"cd";
     }
-    self.systemBootUefi = YES;
     self.systemUUID = [[NSUUID UUID] UUIDString];
-    self.displayCard = @"virtio-vga-gl";
     self.displayUpscaler = @"linear";
     self.displayDownscaler = @"linear";
     self.consoleFont = @"Menlo";
     self.consoleFontSize = @12;
     self.consoleTheme = @"Default";
+    self.networkCardMac = [UTMQemuConfiguration generateMacAddress];
+    self.usbRedirectionMaximumDevices = @3;
+    self.name = [NSUUID UUID].UUIDString;
+    self.existingPath = nil;
+    self.selectedCustomIconPath = nil;
+}
+
+- (void)loadDisplayDefaultsForTarget:(nullable NSString *)target architecture:(nullable NSString *)architecture {
+    NSString *card = nil;
+    if ([target hasPrefix:@"pc"] || [target hasPrefix:@"q35"]) {
+        card = @"virtio-vga-gl";
+    } else if ([target isEqualToString:@"virt"] || [target hasPrefix:@"virt-"]) {
+        card = @"virtio-ramfb-gl";
+    } else if (architecture) {
+        NSArray<NSString *> *cards = [UTMQemuConfiguration supportedDisplayCardsForArchitecture:architecture];
+        NSString *first = cards.firstObject;
+        if (first) {
+            card = first;
+        }
+    }
+    if (card.length == 0) {
+        self.displayCard = @"";
+        self.displayConsoleOnly = YES;
+        return;
+    }
+    self.displayCard = card;
+    self.displayConsoleOnly = NO;
+}
+
+- (void)loadSoundDefaultsForTarget:(nullable NSString *)target architecture:(nullable NSString *)architecture {
+    NSString *card = nil;
+    if ([target hasPrefix:@"pc"] || [target hasPrefix:@"q35"]) {
+        card = @"AC97";
+    } else if ([target isEqualToString:@"virt"] || [target hasPrefix:@"virt-"]) {
+        card = @"intel-hda";
+    } else if ([target isEqualToString:@"mac99"]) {
+        card = @"screamer";
+    } else if (architecture) {
+        NSArray<NSString *> *cards = [UTMQemuConfiguration supportedSoundCardsForArchitecture:architecture];
+        NSString *first = cards.firstObject;
+        if (first) {
+            card = first;
+        }
+    }
+    if (card.length == 0) {
+        self.soundCard = @"";
+        self.soundEnabled = NO;
+        return;
+    }
+    self.soundCard = card;
+    self.soundEnabled = NO;
+}
+
+- (void)loadNetworkDefaultsForTarget:(nullable NSString *)target architecture:(nullable NSString *)architecture {
+    NSString *card = nil;
+    if ([target hasPrefix:@"pc"] || [target hasPrefix:@"q35"]) {
+        card = @"rtl8139";
+    } else if ([target isEqualToString:@"virt"] || [target hasPrefix:@"virt-"]) {
+        card = @"virtio-net-pci";
+    } else if (architecture) {
+        NSArray<NSString *> *cards = [UTMQemuConfiguration supportedNetworkCardsForArchitecture:architecture];
+        NSString *first = cards.firstObject;
+        if (first) {
+            card = first;
+        }
+    }
+    if (card.length == 0) {
+        self.networkCard = @"";
+        self.networkMode = @"none";
+        return;
+    }
+    self.networkCard = card;
 #if TARGET_OS_OSX
     if (@available(macOS 11.3, *)) {
         self.networkMode = @"shared";
@@ -57,46 +128,31 @@
 #else
     self.networkMode = @"emulated";
 #endif
-    self.soundEnabled = YES;
-    self.soundCard = @"AC97";
-    self.networkCard = @"rtl8139";
-    self.networkCardMac = [self generateMacAddress];
-    self.shareClipboardEnabled = YES;
-    self.usbRedirectionMaximumDevices = @3;
-    self.name = [NSUUID UUID].UUIDString;
-    self.existingPath = nil;
-    self.selectedCustomIconPath = nil;
-    self.useHypervisor = self.defaultUseHypervisor;
 }
 
 - (void)loadDefaultsForTarget:(nullable NSString *)target architecture:(nullable NSString *)architecture {
+    [self loadDisplayDefaultsForTarget:target architecture:architecture];
+    [self loadSoundDefaultsForTarget:target architecture:architecture];
+    [self loadNetworkDefaultsForTarget:target architecture:architecture];
     if ([target hasPrefix:@"pc"] || [target hasPrefix:@"q35"]) {
-        self.soundCard = @"AC97";
-        self.soundEnabled = YES;
-        self.networkCard = @"rtl8139";
         self.shareClipboardEnabled = YES;
-        self.displayCard = @"virtio-vga-gl";
         self.systemBootUefi = YES;
     } else if ([target isEqualToString:@"virt"] || [target hasPrefix:@"virt-"]) {
-        self.soundCard = @"intel-hda";
-        self.soundEnabled = YES;
-        self.networkCard = @"virtio-net-pci";
         self.shareClipboardEnabled = YES;
-        self.displayCard = @"virtio-ramfb-gl";
         self.usb3Support = NO;
         self.systemBootUefi = YES;
-    } else if ([target isEqualToString:@"mac99"]) {
-        self.soundCard = @"screamer";
-        self.soundEnabled = YES;
     } else if ([target isEqualToString:@"isapc"]) {
         self.inputLegacy = YES; // no USB support
     } else {
+        self.shareClipboardEnabled = NO;
         self.systemBootUefi = NO;
     }
     self.useHypervisor = self.defaultUseHypervisor;
     NSString *machineProp = [UTMQemuConfiguration defaultMachinePropertiesForTarget:target];
     if (machineProp) {
         self.systemMachineProperties = machineProp;
+    } else if (self.systemMachineProperties) {
+        self.systemMachineProperties = @"";
     }
     if (target && architecture) {
         self.systemCPU = [UTMQemuConfiguration defaultCPUForTarget:target architecture:architecture];
@@ -114,13 +170,15 @@
     return nil;
 }
 
-+ (NSString *)defaultDriveInterfaceForTarget:(NSString *)target type:(UTMDiskImageType)type {
++ (NSString *)defaultDriveInterfaceForTarget:(NSString *)target architecture:(NSString *)architecture type:(UTMDiskImageType)type {
     if ([target isEqualToString:@"virt"] || [target hasPrefix:@"virt-"]) {
         if (type == UTMDiskImageTypeCD) {
             return @"usb";
         } else {
             return @"virtio";
         }
+    } else if ([architecture hasPrefix:@"sparc"]) {
+        return @"scsi";
     }
     return @"ide";
 }
@@ -130,6 +188,8 @@
         return @"cortex-a72";
     } else if ([architecture isEqualToString:@"arm"]) {
         return @"cortex-a15";
+    } else if ([target hasPrefix:@"pc"] || [target hasPrefix:@"q35"]) {
+        return @"Skylake-Client";
     } else {
         return @"default";
     }

+ 1 - 0
Configuration/UTMQemuConfiguration+Networking.h

@@ -40,6 +40,7 @@ NS_ASSUME_NONNULL_BEGIN
 @property (nonatomic, nullable, copy) NSString *networkDnsSearch;
 @property (nonatomic, readonly) NSInteger countPortForwards;
 
++ (NSString *)generateMacAddress;
 - (void)migrateNetworkConfigurationIfNecessary;
 
 - (NSInteger)newPortForward:(UTMQemuConfigurationPortForward *)argument;

+ 2 - 2
Configuration/UTMQemuConfiguration+Networking.m

@@ -61,7 +61,7 @@ static const NSString *const kUTMConfigNetworkPortForwardGuestPortKey = @"GuestP
     }
     // Generate MAC if missing
     if (!self.networkCardMac) {
-        self.networkCardMac = [self generateMacAddress];
+        self.networkCardMac = [UTMQemuConfiguration generateMacAddress];
     }
     // default network mode
     if ([self.rootDict[kUTMConfigNetworkingKey] objectForKey:kUTMConfigNetworkEnabledKey]) {
@@ -72,7 +72,7 @@ static const NSString *const kUTMConfigNetworkPortForwardGuestPortKey = @"GuestP
 
 #pragma mark - Generate MAC
 
-- (NSString *)generateMacAddress {
++ (NSString *)generateMacAddress {
     uint8_t bytes[6];
     
     for (int i = 0; i < 6; i++) {

BIN
Icons/windows-11.png


BIN
Icons/windows-9x.png


BIN
Icons/windows-xp.png


+ 16 - 16
Managers/UTMJSONStream.m

@@ -86,21 +86,15 @@ enum ParserState {
             assert(self.inputStream == nil && self.outputStream == nil);
             return;
         }
-        CFReadStreamRef readStream = (CFReadStreamRef)CFBridgingRetain(self.inputStream);
-        CFWriteStreamRef writeStream = (CFWriteStreamRef)CFBridgingRetain(self.outputStream);
-        self.inputStream = nil;
-        self.outputStream = nil;
         self.inputStream.delegate = nil;
         self.outputStream.delegate = nil;
+        [self.inputStream close];
+        [self.outputStream close];
+        CFReadStreamSetDispatchQueue((__bridge CFReadStreamRef)self.inputStream, NULL);
+        CFWriteStreamSetDispatchQueue((__bridge CFWriteStreamRef)self.outputStream, NULL);
+        self.inputStream = nil;
+        self.outputStream = nil;
         self.data = nil;
-        CFReadStreamSetDispatchQueue(readStream, NULL);
-        CFWriteStreamSetDispatchQueue(writeStream, NULL);
-        CFReadStreamClose(readStream);
-        CFWriteStreamClose(writeStream);
-        dispatch_async(self.streamQueue, ^{
-            CFRelease(readStream);
-            CFRelease(writeStream);
-        });
     }
 }
 
@@ -199,20 +193,26 @@ enum ParserState {
 }
 
 - (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode {
+    NSInputStream *inputStream = self.inputStream;
+    @synchronized (self) {
+        if (!inputStream) {
+            return; // stream closing
+        }
+    }
     switch (eventCode) {
         case NSStreamEventHasBytesAvailable: {
             uint8_t buf[kMaxBufferSize];
             NSInteger res;
             @synchronized (self) {
-                NSAssert(aStream == self.inputStream, @"Invalid stream");
-                res = [self.inputStream read:buf maxLength:kMaxBufferSize];
+                NSAssert(aStream == inputStream, @"Invalid stream");
+                res = [inputStream read:buf maxLength:kMaxBufferSize];
                 if (res > 0) {
                     [self.data appendBytes:buf length:res];
                     while (self.parsedBytes < [self.data length]) {
                         [self parseData];
                     }
                 } else if (res < 0) {
-                    [self.delegate jsonStream:self seenError:[self.inputStream streamError]];
+                    [self.delegate jsonStream:self seenError:[inputStream streamError]];
                 }
             }
             break;
@@ -227,7 +227,7 @@ enum ParserState {
         }
         case NSStreamEventOpenCompleted: {
             UTMLog(@"Connected to stream %p", aStream);
-            [self.delegate jsonStream:self connected:(aStream == self.inputStream)];
+            [self.delegate jsonStream:self connected:(aStream == inputStream)];
             break;
         }
         default: {

+ 56 - 0
Managers/UTMPendingVirtualMachine.swift

@@ -0,0 +1,56 @@
+//
+// 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 Foundation
+
+/// A Virtual Machine that has not finished downloading.
+@available(iOS 14, macOS 11, *)
+class UTMPendingVirtualMachine: Equatable, Identifiable, ObservableObject {
+    internal init(name: String, importTask: UTMImportFromWebTask) {
+        self.url = importTask.url
+        self.name = name
+        self.cancel = importTask.cancel
+    }
+    
+    #if DEBUG
+    /// init for SwiftUI Preview
+    internal init(name: String) {
+        self.url = URL(string: "https://getutm.app")!
+        self.name = name
+        self.downloadProgress = 0.41
+        self.cancel = {}
+    }
+    #endif
+    
+    private let downloadStartDate = Date() /// used for identifying separate downloads of the same VM
+    private var url: URL
+    let name: String
+    @Published private(set) var downloadProgress: CGFloat = 0
+    let cancel: () -> ()
+    
+    static func == (lhs: UTMPendingVirtualMachine, rhs: UTMPendingVirtualMachine) -> Bool {
+        lhs.url == rhs.url
+    }
+    
+    var id: String {
+        url.absoluteString + downloadStartDate.description
+    }
+    
+    public func setDownloadProgress(_ progress: Float) {
+        objectWillChange.send()
+        downloadProgress = CGFloat(progress)
+    }
+}

+ 2 - 1
Managers/UTMQemuManager.m

@@ -361,9 +361,10 @@ void qmp_rpc_call(CFDictionaryRef args, CFDictionaryRef *ret, Error **err, void
             error_free(qerr);
         }
         if (info) {
-            for (MouseInfoList *list = info; list->next; list = list->next) {
+            for (MouseInfoList *list = info; list; list = list->next) {
                 if (list->value->absolute == absolute) {
                     index = list->value->index;
+                    break;
                 }
             }
             qapi_free_MouseInfoList(info);

+ 91 - 34
Managers/UTMQemuSystem.m

@@ -45,6 +45,8 @@ extern NSString *const kUTMErrorDomain;
 @property (nonatomic, readonly) BOOL hasCustomBios;
 @property (nonatomic, readonly) BOOL usbSupported;
 @property (nonatomic, readonly) NSURL *efiVariablesURL;
+@property (nonatomic, readonly) BOOL isGLOn;
+@property (nonatomic, readonly) BOOL isSparc;
 
 @end
 
@@ -194,12 +196,18 @@ static size_t sysctl_read(const char *name) {
         [self pushArgv:@"-device"];
         [self pushArgv:[NSString stringWithFormat:@"%@,bus=ide.%lu,drive=%@,bootindex=%lu", removable ? @"ide-cd" : @"ide-hd", busindex++, identifier, bootindex++]];
     } else if ([interface isEqualToString:@"scsi"]) {
-        if (busindex == 0) {
-            [self pushArgv:@"-device"];
-            [self pushArgv:@"lsi53c895a,id=scsi0"];
+        NSString *bus;
+        if (self.isSparc) {
+            bus = @"scsi";
+        } else {
+            bus = @"scsi0";
+            if (busindex == 0) {
+                [self pushArgv:@"-device"];
+                [self pushArgv:@"lsi53c895a,id=scsi0"];
+            }
         }
         [self pushArgv:@"-device"];
-        [self pushArgv:[NSString stringWithFormat:@"%@,bus=scsi0.0,channel=0,scsi-id=%lu,drive=%@,bootindex=%lu", removable ? @"scsi-cd" : @"scsi-hd", busindex++, identifier, bootindex++]];
+        [self pushArgv:[NSString stringWithFormat:@"%@,bus=%@.0,channel=0,scsi-id=%lu,drive=%@,bootindex=%lu", removable ? @"scsi-cd" : @"scsi-hd", bus, busindex++, identifier, bootindex++]];
     } else if ([interface isEqualToString:@"virtio"]) {
         [self pushArgv:@"-device"];
         [self pushArgv:[NSString stringWithFormat:@"%@,drive=%@,bootindex=%lu", [self.configuration.systemArchitecture isEqualToString:@"s390x"] ? @"virtio-blk-ccw" : @"virtio-blk-pci", identifier, bootindex++]];
@@ -208,7 +216,10 @@ static size_t sysctl_read(const char *name) {
         [self pushArgv:[NSString stringWithFormat:@"nvme,drive=%@,serial=%@,bootindex=%lu", identifier, identifier, bootindex++]];
     } else if ([interface isEqualToString:@"usb"]) {
         [self pushArgv:@"-device"];
-        [self pushArgv:[NSString stringWithFormat:@"usb-storage,drive=%@,removable=%@,bootindex=%lu", identifier, removable ? @"true" : @"false", bootindex++]];
+        /// use usb 3 bus for virt system, unless using legacy input setting (this mirrors the code in argsForUsb)
+        bool useUSB3 = !self.configuration.inputLegacy && [self.configuration.systemTarget hasPrefix:@"virt"];
+        NSString *bus = useUSB3 ? @",bus=usb-bus.0" : @"";
+        [self pushArgv:[NSString stringWithFormat:@"usb-storage,drive=%@,removable=%@,bootindex=%lu%@", identifier, removable ? @"true" : @"false", bootindex++, bus]];
     } else if ([interface isEqualToString:@"floppy"] && [self.configuration.systemTarget hasPrefix:@"q35"]) {
         [self pushArgv:@"-device"];
         [self pushArgv:[NSString stringWithFormat:@"isa-fdc,id=fdc%lu,bootindexA=%lu", busindex, bootindex++]];
@@ -245,15 +256,7 @@ static size_t sysctl_read(const char *name) {
 }
 
 - (void)argsForSound {
-    // < macOS 11.3 we use fork() which is buggy and things are broken
-    BOOL forceDisableSound = NO;
-    if (@available(macOS 11.3, *)) {
-    } else {
-        if (self.configuration.displayConsoleOnly) {
-            forceDisableSound = YES;
-        }
-    }
-    if (self.configuration.soundEnabled && !forceDisableSound) {
+    if (self.configuration.soundEnabled) {
         if ([self.configuration.soundCard isEqualToString:@"screamer"]) {
 #if !TARGET_OS_IPHONE
             // force CoreAudio backend for mac99 which only supports 44100 Hz
@@ -297,7 +300,7 @@ static size_t sysctl_read(const char *name) {
             case UTMDiskImageTypeCD: {
                 NSString *interface = [self.configuration driveInterfaceTypeForIndex:i];
                 BOOL removable = (type == UTMDiskImageTypeCD) || [self.configuration driveRemovableForIndex:i];
-                NSString *identifier = [NSString stringWithFormat:@"drive%lu", i];
+                NSString *identifier = [self.configuration driveNameForIndex:i];
                 NSString *realInterface = [self expandDriveInterface:interface identifier:identifier removable:removable busInterfaceMap:busInterfaceMap];
                 NSString *drive;
                 [self pushArgv:@"-drive"];
@@ -349,8 +352,13 @@ static size_t sysctl_read(const char *name) {
 
 - (void)argsForNetwork {
     if (self.configuration.networkEnabled) {
-        [self pushArgv:@"-device"];
-        [self pushArgv:[NSString stringWithFormat:@"%@,mac=%@,netdev=net0", self.configuration.networkCard, self.configuration.networkCardMac]];
+        if (self.isSparc) {
+            [self pushArgv:@"-net"];
+            [self pushArgv:[NSString stringWithFormat:@"nic,model=lance,macaddr=%@,netdev=net0", self.configuration.networkCardMac]];
+        } else {
+            [self pushArgv:@"-device"];
+            [self pushArgv:[NSString stringWithFormat:@"%@,mac=%@,netdev=net0", self.configuration.networkCard, self.configuration.networkCardMac]];
+        }
         [self pushArgv:@"-netdev"];
         NSString *device = @"user";
         NSMutableString *netstr;
@@ -429,10 +437,12 @@ static size_t sysctl_read(const char *name) {
         }
         [self pushArgv:@"-device"];
         [self pushArgv:@"usb-tablet,bus=usb-bus.0"];
-        [self pushArgv:@"-device"];
-        [self pushArgv:@"usb-mouse,bus=usb-bus.0"];
-        [self pushArgv:@"-device"];
-        [self pushArgv:@"usb-kbd,bus=usb-bus.0"];
+        if (![self.configuration.systemTarget hasPrefix:@"pc"] && ![self.configuration.systemTarget hasPrefix:@"q35"]) {
+            [self pushArgv:@"-device"];
+            [self pushArgv:@"usb-mouse,bus=usb-bus.0"];
+            [self pushArgv:@"-device"];
+            [self pushArgv:@"usb-kbd,bus=usb-bus.0"];
+        }
     }
 #if !defined(WITH_QEMU_TCI)
     NSInteger maxDevices = [self.configuration.usbRedirectionMaximumDevices integerValue];
@@ -545,7 +555,18 @@ static size_t sysctl_read(const char *name) {
 }
 
 - (BOOL)usbSupported {
-    return ![self.configuration.systemTarget isEqualToString:@"isapc"];
+    NSString *arch = self.configuration.systemArchitecture;
+    NSString *target = self.configuration.systemTarget;
+    if ([target isEqualToString:@"isapc"]) {
+        return NO;
+    }
+    if ([arch isEqualToString:@"s390x"]) {
+        return NO;
+    }
+    if ([arch hasPrefix:@"sparc"]) {
+        return NO;
+    }
+    return YES;
 }
 
 - (NSURL *)efiVariablesURL {
@@ -559,6 +580,16 @@ static size_t sysctl_read(const char *name) {
     return @"";
 }
 
+- (BOOL)isGLOn {
+    // GL supported devices have contains GL moniker
+    return [self.configuration.displayCard containsString:@"-gl-"] ||
+           [self.configuration.displayCard hasSuffix:@"-gl"];
+}
+
+- (BOOL)isSparc {
+    return [self.configuration.systemArchitecture isEqualToString:@"sparc"];
+}
+
 - (void)argsRequired {
     [self clearArgv];
     [self pushArgv:@"-L"];
@@ -570,11 +601,18 @@ static size_t sysctl_read(const char *name) {
     [self pushArgv:@"-S"]; // startup stopped
     [self pushArgv:@"-qmp"];
     [self pushArgv:[NSString stringWithFormat:@"tcp:127.0.0.1:%lu,server,nowait", self.qmpPort]];
-    // prevent QEMU default devices, which leads to duplicate CD drive (fix #2538)
-    // see https://github.com/qemu/qemu/blob/6005ee07c380cbde44292f5f6c96e7daa70f4f7d/docs/qdev-device-use.txt#L382
-    [self pushArgv:@"-nodefaults"];
-    [self pushArgv:@"-vga"];
-    [self pushArgv:@"none"];// -vga none, avoid adding duplicate graphics cards
+    if (self.isSparc) { // SPARC uses -vga
+        if (!self.configuration.displayConsoleOnly) {
+            [self pushArgv:@"-vga"];
+            [self pushArgv:self.configuration.displayCard];
+        }
+    } else { // disable -vga and other default devices
+        // prevent QEMU default devices, which leads to duplicate CD drive (fix #2538)
+        // see https://github.com/qemu/qemu/blob/6005ee07c380cbde44292f5f6c96e7daa70f4f7d/docs/qdev-device-use.txt#L382
+        [self pushArgv:@"-nodefaults"];
+        [self pushArgv:@"-vga"];
+        [self pushArgv:@"none"];// -vga none, avoid adding duplicate graphics cards
+    }
     if (self.configuration.displayConsoleOnly) {
         [self pushArgv:@"-nographic"];
         // terminal character device
@@ -585,13 +623,12 @@ static size_t sysctl_read(const char *name) {
         [self pushArgv: @"chardev:term0"];
     } else {
         NSURL *spiceSocketURL = self.configuration.spiceSocketURL;
-        // GL supported devices have contains GL moniker
-        BOOL isGLOn = [self.configuration.displayCard containsString:@"-gl-"] ||
-                      [self.configuration.displayCard hasSuffix:@"-gl"];
         [self pushArgv:@"-spice"];
-        [self pushArgv:[NSString stringWithFormat:@"unix=on,addr=%@,disable-ticketing=on,image-compression=off,playback-compression=off,streaming-video=off,gl=%@", spiceSocketURL.path, isGLOn ? @"on" : @"off"]];
-        [self pushArgv:@"-device"];
-        [self pushArgv:self.configuration.displayCard];
+        [self pushArgv:[NSString stringWithFormat:@"unix=on,addr=%@,disable-ticketing=on,image-compression=off,playback-compression=off,streaming-video=off,gl=%@", spiceSocketURL.path, self.isGLOn ? @"on" : @"off"]];
+        if (!self.isSparc) { // SPARC uses -vga (above)
+            [self pushArgv:@"-device"];
+            [self pushArgv:self.configuration.displayCard];
+        }
     }
 }
 
@@ -710,14 +747,34 @@ static size_t sysctl_read(const char *name) {
     return YES;
 }
 
+- (BOOL)validateOSSupportWithError:(NSError **)error {
+    if (@available(macOS 11.3, *)) {
+        return YES;
+    } else {
+        if (self.configuration.soundEnabled && self.configuration.displayConsoleOnly) {
+            *error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"This version of macOS does not support audio in console mode. Please change the VM configuration or upgrade macOS.", "UTMQemuSystem")}];
+            return NO;
+        }
+        if (self.isGLOn && !self.configuration.displayConsoleOnly) {
+            *error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"This version of macOS does not support GPU acceleration. Please change the VM configuration or upgrade macOS.", "UTMQemuSystem")}];
+            return NO;
+        }
+    }
+    return YES;
+}
+
 - (void)startWithCompletion:(void (^)(BOOL, NSString * _Nonnull))completion {
+    NSError *err;
     if (self.configuration.systemBootUefi) {
-        NSError *err;
         if (![self createEfiVariablesIfNeededWithError:&err]) {
             completion(NO, err.localizedDescription);
             return;
         }
     }
+    if (![self validateOSSupportWithError:&err]) {
+        completion(NO, err.localizedDescription);
+        return;
+    }
     [self updateArgvWithUserOptions:YES];
     [self startQemu:self.configuration.systemArchitecture completion:completion];
 }

+ 2 - 4
Managers/UTMScreenshot.h

@@ -25,12 +25,10 @@ NS_ASSUME_NONNULL_BEGIN
 
 @interface UTMScreenshot : NSObject
 
-@property (class, nonatomic, readonly) UTMScreenshot *none;
-
 #if TARGET_OS_IPHONE
-@property (nonatomic, readonly, nullable) UIImage *image;
+@property (nonatomic, readonly) UIImage *image;
 #else
-@property (nonatomic, readonly, nullable) NSImage *image;
+@property (nonatomic, readonly) NSImage *image;
 #endif
 
 - (instancetype)init NS_DESIGNATED_INITIALIZER;

+ 0 - 9
Managers/UTMScreenshot.m

@@ -19,15 +19,6 @@
 
 @implementation UTMScreenshot
 
-+ (UTMScreenshot *)none {
-    static dispatch_once_t pred = 0;
-    static id _sharedObject = nil;
-    dispatch_once(&pred, ^{
-        _sharedObject = [[self alloc] init];
-    });
-    return _sharedObject;
-}
-
 - (instancetype)init {
     return [super init];
 }

+ 1 - 3
Managers/UTMTerminal.m

@@ -161,9 +161,7 @@ dispatch_io_t createInputIO(NSURL* url, dispatch_queue_t queue) {
         size_t estimated = dispatch_source_get_data(source);
         NSData* bytesRead = [self evaluateChangesForDescriptor: fd estimatedSize: estimated];
         if (bytesRead != nil) {
-            dispatch_async(dispatch_get_main_queue(), ^{
-                [[self delegate] terminal: self didReceiveData: bytesRead];
-            });
+            [[self delegate] terminal: self didReceiveData: bytesRead];
         }
     });
     dispatch_source_set_cancel_handler(source, ^{

BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt@2x.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt@2x.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt@2x.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt@2x.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt.png


BIN
Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt@2x.png


+ 87 - 1
Platform/Shared/ContentView.swift

@@ -45,6 +45,14 @@ struct ContentView: View {
                         .modifier(VMContextMenuModifier(vm: vm))
                 }.onMove(perform: data.move)
                 .onDelete(perform: delete)
+                
+                if data.pendingVMs.count > 0 {
+                    Section(header: Text("Pending")) {
+                        ForEach(data.pendingVMs, id: \.name) { vm in
+                            UTMPendingVMView(vm: vm)
+                        }.onDelete(perform: cancel)
+                    }.transition(.opacity)
+                }
             }.optionalSidebarFrame()
             .listStyle(SidebarListStyle())
             .navigationTitle(productName)
@@ -70,7 +78,7 @@ struct ContentView: View {
         }.overlay(data.showSettingsModal ? AnyView(EmptyView()) : AnyView(BusyOverlay()))
         .optionalWindowFrame()
         .disabled(data.busy && !data.showNewVMSheet && !data.showSettingsModal)
-        .onOpenURL(perform: importUTM)
+        .onOpenURL(perform: handleURL)
         .handlesExternalEvents(preferring: ["*"], allowing: ["*"])
         .onReceive(NSNotification.NewVirtualMachine) { _ in
             data.newVM()
@@ -110,6 +118,23 @@ struct ContentView: View {
         }
     }
     
+    private func cancel(indexSet: IndexSet) {
+        let selected = data.pendingVMs[indexSet]
+        for vm in selected {
+            data.cancelPendingVM(vm)
+        }
+    }
+    
+    private func handleURL(url: URL) {
+        if url.isFileURL {
+            importUTM(url: url)
+        } else if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+                  let scheme = components.scheme,
+                  scheme.lowercased() == "utm" {
+            handleUTMURL(with: components)
+        }
+    }
+    
     private func importUTM(url: URL) {
         guard url.isFileURL else {
             return // ignore
@@ -125,6 +150,67 @@ struct ContentView: View {
             try data.importUTM(url: url)
         }
     }
+    
+    private func handleUTMURL(with components: URLComponents) {
+        func findVM() -> UTMVirtualMachine? {
+            if let vmName = components.queryItems?.first(where: { $0.name == "name" })?.value {
+                return data.virtualMachines.first(where: { $0.title == vmName })
+            } else {
+                return nil
+            }
+        }
+        
+        if let action = components.host {
+            switch action {
+            case "start":
+                if let vm = findVM(), vm.state == .vmStopped {
+                    data.run(vm: vm)
+                }
+                break
+            case "stop":
+                if let vm = findVM(), vm.state == .vmStarted {
+                    vm.quitVM(force: true)
+                    try? data.stop(vm: vm)
+                }
+                break
+            case "restart":
+                if let vm = findVM(), vm.state == .vmStarted {
+                    DispatchQueue.global(qos: .background).async {
+                        vm.resetVM()
+                    }
+                }
+                break
+            case "pause":
+                if let vm = findVM(), vm.state == .vmStarted {
+                    DispatchQueue.global(qos: .background).async {
+                        vm.pauseVM()
+                    }
+                }
+            case "resume":
+                if let vm = findVM(), vm.state == .vmPaused {
+                    DispatchQueue.global(qos: .background).async {
+                        vm.resumeVM()
+                    }
+                }
+                break
+            case "sendText":
+                if let vm = findVM(), vm.state == .vmStarted {
+                    data.trySendText(vm, urlComponents: components)
+                }
+                break
+            case "click":
+                if let vm = findVM(), vm.state == .vmStarted {
+                    data.tryClickVM(vm, urlComponents: components)
+                }
+                break
+            case "downloadVM":
+                data.tryDownloadVM(components)
+                break
+            default:
+                return
+            }
+        }
+    }
 }
 
 #if os(macOS)

+ 1 - 1
Platform/Shared/HTerm/libapps

@@ -1 +1 @@
-Subproject commit 7017f15511d623c0db95f218968650919544efa3
+Subproject commit 75f66ba9a035fc80b5f927b56f0cea26e8d29bc8

+ 5 - 5
Platform/Shared/HTerm/terminal.js

@@ -17,8 +17,7 @@ function sendTerminalSize(columns, rows) {
 
 function writeData(data) {
     const term = window.term;
-    const str = String.fromCharCode.apply(null, data);
-    term.io.print(str);
+    term.io.writeUTF8(data);
 }
 
 function focusTerminal() {
@@ -172,7 +171,7 @@ function detectGestures(e) {
 
 // Setup
 
-function terminalSetup() {
+function setupHterm() {
     const term = new hterm.Terminal();
     
     term.onTerminalReady = function() {
@@ -222,6 +221,7 @@ function setCursorBlink(blink) {
     term.getPrefs().set('cursor-blink', blink);
 }
 
-window.onload = function() {
-    lib.init(terminalSetup);
+window.onload = async function() {
+    await lib.init();
+    setupHterm();
 };

+ 153 - 0
Platform/Shared/UTMImportFromWebTask.swift

@@ -0,0 +1,153 @@
+//
+// 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 Foundation
+import Logging
+import Zip
+
+/// Downloads a ZIPped UTM file from the web, unzips it and imports it as a UTM virtual machine.
+@available(iOS 14, macOS 11, *)
+class UTMImportFromWebTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
+    let data: UTMData
+    let url: URL
+    private var downloadTask: URLSessionTask!
+    private var pendingVM: UTMPendingVirtualMachine!
+    private(set) var isDone: Bool = false
+    
+    init(data: UTMData, url: URL) {
+        self.data = data
+        self.url = url
+    }
+    
+    internal func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
+        self.downloadTask = nil
+        DispatchQueue.main.async { [self] in
+            pendingVM.setDownloadProgress(1)
+        }
+        let fileManager = FileManager.default
+        let tempDir = fileManager.temporaryDirectory
+        let originalFilename = downloadTask.originalRequest!.url!.lastPathComponent
+        let downloadedZip = tempDir.appendingPathComponent(originalFilename)
+        do {
+            if fileManager.fileExists(atPath: downloadedZip.absoluteString) {
+                try fileManager.removeItem(at: downloadedZip)
+            }
+            try fileManager.moveItem(at: location, to: downloadedZip)
+            let unzippedURL = try Zip.quickUnzipFile(downloadedZip)
+            /// remove the downloaded ZIP file
+            try fileManager.removeItem(at: downloadedZip)
+            handleUnzipped(unzippedURL)
+            /// remove unzipped file
+            try FileManager.default.removeItem(at: unzippedURL)
+        } catch {
+            logger.error(Logger.Message(stringLiteral: error.localizedDescription))
+            try? fileManager.removeItem(at: downloadedZip)
+        }
+        /// remove downloading VM View Model
+        DispatchQueue.main.async { [self] in
+            data.removePendingVM(pendingVM)
+            pendingVM = nil
+            isDone = true
+        }
+    }
+    
+    /// received when the download progresses
+    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
+        DispatchQueue.main.async { [self] in
+            guard pendingVM != nil else { return }
+            let progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
+            pendingVM.setDownloadProgress(progress)
+        }
+    }
+    
+    /// when the session ends with an error, it could be cancelled or an actual error
+    internal func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+        DispatchQueue.main.async { [self] in
+            /// make sure the session didn't already finish
+            guard pendingVM != nil else { return }
+            if let error = error {
+                let error = error as NSError
+                if error.code == NSURLErrorCancelled {
+                    /// download was cancelled normally
+                } else {
+                    /// other error
+                    logger.error("\(error.localizedDescription)")
+                    data.alertMessage = AlertMessage(error.localizedDescription)
+                }
+                isDone = true
+                data.removePendingVM(pendingVM)
+                pendingVM = nil
+            }
+        }
+    }
+    
+    internal func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
+        DispatchQueue.main.async { [self] in
+            /// make sure the session didn't already finish
+            guard pendingVM != nil else { return }
+            if let error = error {
+                logger.error("\(error.localizedDescription)")
+                isDone = true
+                data.removePendingVM(pendingVM)
+                data.alertMessage = AlertMessage(error.localizedDescription)
+                pendingVM = nil
+            }
+        }
+    }
+    
+    /// Call on background queue
+    private func handleUnzipped(_ unzippedFolder: URL) {
+        do {
+            let path = unzippedFolder.path
+            /// try to find .utm file in unzipped folder
+            if let utmFilename = try FileManager.default.contentsOfDirectory(atPath: path).first(where: { $0.hasSuffix(".utm") }) {
+                /// got filename
+                let utmURL = URL(fileURLWithPath: path).appendingPathComponent(utmFilename, isDirectory: false)
+                try self.data.importUTM(url: utmURL)
+            } else {
+                /// utm file not in folder
+                logger.error("No UTM file in extracted ZIP")
+            }
+        } catch {
+            logger.error(Logger.Message(stringLiteral: error.localizedDescription))
+        }
+    }
+    
+    /// Downloads a ZIP-compressed file from the provided URL and imports the UTM file inside, if there is one.
+    func startDownload() -> UTMPendingVirtualMachine {
+        /// begin the download
+        let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)
+        downloadTask = session.downloadTask(with: url)
+        downloadTask.resume()
+        /// try to detect the filename from the URL
+        let filename = url.lastPathComponent
+        var nameWithoutZIP = "UTM Virtual Machine"
+        /// Try to get the start index of the `.zip` part of the filename
+        if let index = filename.range(of: ".zip", options: [])?.lowerBound {
+            nameWithoutZIP = String(filename[..<index])
+        }
+        pendingVM = UTMPendingVirtualMachine(name: nameWithoutZIP, importTask: self)
+        return pendingVM
+    }
+    
+    /// Cancels the network request, if any.
+    /// Cancelling the file operations that occur after the download has finished is not supported.
+    func cancel() {
+        guard !isDone && !downloadTask.progress.isFinished else { return }
+        downloadTask.cancel()
+        downloadTask = nil
+    }
+}

+ 102 - 0
Platform/Shared/UTMPendingVMView.swift

@@ -0,0 +1,102 @@
+//
+// 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
+
+@available(iOS 14, macOS 11, *)
+struct UTMPendingVMView: View {
+    @ObservedObject var vm: UTMPendingVirtualMachine
+    #if os(macOS)
+    @State private var showCancelButton = false
+    #endif
+    
+    var body: some View {
+        HStack(alignment: .center) {
+            /// Computer with download symbol on its screen
+            Image(systemName: "desktopcomputer")
+                .resizable()
+                .frame(width: 30.0, height: 30.0)
+                .aspectRatio(contentMode: .fit)
+                .overlay(
+                    Image(systemName: "arrow.down.circle.fill")
+                        .font(Font.caption.weight(Font.Weight.medium))
+                        .offset(y: -5)
+                )
+                .foregroundColor(.gray)
+            
+            VStack(alignment: .leading) {
+                Text(vm.name)
+                    .font(.headline)
+                Text(" ") /// to create a seamless layout with the ProgressView like a next line of text
+                    .font(.subheadline)
+                    .frame(maxWidth: .infinity)
+                    .overlay(
+                        MinimalProgressView(fractionCompleted: vm.downloadProgress)
+                    )
+            }
+            .frame(maxHeight: 30)
+            .foregroundColor(.gray)
+        }
+        .overlay(
+            HStack {
+                Spacer()
+                #if os(macOS)
+                if showCancelButton {
+                    Button(action: {
+                        vm.cancel()
+                    }, label: {
+                        Image(systemName: "xmark.circle")
+                            .accessibility(label: Text("Cancel download"))
+                    })
+                    .clipShape(Circle())
+                }
+                #endif
+            }
+        )
+        .onHover(perform: { hovering in
+            #if os(macOS)
+            self.showCancelButton = hovering
+            #endif
+        })
+    }
+}
+
+@available(iOS 14, macOS 11, *)
+struct MinimalProgressView: View {
+    let fractionCompleted: CGFloat
+    
+    var body: some View {
+        ZStack {
+            GeometryReader { frame in
+                RoundedRectangle(cornerRadius: frame.size.height/5)
+                    .fill(Color.accentColor)
+                    .frame(width: frame.size.width * fractionCompleted, height: frame.size.height/3)
+            }
+        }
+        .frame(maxWidth: .infinity)
+        .padding(1)
+    }
+}
+
+#if DEBUG
+@available(iOS 14, macOS 11, *)
+struct UTMProgressView_Previews: PreviewProvider {
+    static var previews: some View {
+        UTMPendingVMView(vm: UTMPendingVirtualMachine(name: ""))
+            .frame(width: 350, height: 100)
+    }
+}
+#endif

+ 5 - 1
Platform/Shared/VMConfigDisplayView.swift

@@ -22,8 +22,10 @@ struct VMConfigDisplayView: View {
     
     #if os(macOS)
     let displayTypePickerStyle = RadioGroupPickerStyle()
+    let horizontalPaddingAmount: CGFloat? = nil
     #else
     let displayTypePickerStyle = DefaultPickerStyle()
+    let horizontalPaddingAmount: CGFloat? = 0
     #endif
     
     var body: some View {
@@ -51,7 +53,8 @@ struct VMConfigDisplayView: View {
                         VMConfigStringPicker(selection: $config.displayCard, label: Text("Emulated Display Card"), rawValues: UTMQemuConfiguration.supportedDisplayCards(forArchitecture: config.systemArchitecture), displayValues: UTMQemuConfiguration.supportedDisplayCards(forArchitecturePretty: config.systemArchitecture))
                     }
                     
-                    Section(header: Text("Resolution"), footer: Text("Requires SPICE guest agent tools to be installed. Retina Mode is recommended only if the guest OS supports HiDPI.").padding(.bottom)) {
+                    // https://stackoverflow.com/a/59277022/15603854
+                    Section(header: Text("Resolution"), footer: Text("Requires SPICE guest agent tools to be installed. Retina Mode is recommended only if the guest OS supports HiDPI.").fixedSize(horizontal: false, vertical: true).padding(.bottom)) {
                         Toggle(isOn: $config.displayFitScreen, label: {
                             Text("Fit To Screen")
                         })
@@ -67,6 +70,7 @@ struct VMConfigDisplayView: View {
                 }
             }
         }.disableAutocorrection(true)
+        .padding(.horizontal, horizontalPaddingAmount)
     }
 }
 

+ 3 - 2
Platform/Shared/VMConfigDriveCreateView.swift

@@ -20,6 +20,7 @@ import SwiftUI
 struct VMConfigDriveCreateView: View {
     private let mibToGib = 1024
     let target: String?
+    let architecture: String?
     let minSizeMib = 1
     
     @ObservedObject var driveImage: VMDriveImage
@@ -30,7 +31,7 @@ struct VMConfigDriveCreateView: View {
             Toggle(isOn: $driveImage.removable.animation(), label: {
                 Text("Removable")
             }).onChange(of: driveImage.removable) { removable in
-                driveImage.reset(forSystemTarget: target, removable: removable)
+                driveImage.reset(forSystemTarget: target, architecture: architecture, removable: removable)
             }
             VMConfigStringPicker(selection: $driveImage.interface, label: Text("Interface"), rawValues: UTMQemuConfiguration.supportedDriveInterfaces(), displayValues: UTMQemuConfiguration.supportedDriveInterfacesPretty())
             if !driveImage.removable {
@@ -83,6 +84,6 @@ struct VMConfigDriveCreateView_Previews: PreviewProvider {
     @StateObject static private var driveImage = VMDriveImage()
     
     static var previews: some View {
-        VMConfigDriveCreateView(target: nil, driveImage: driveImage)
+        VMConfigDriveCreateView(target: nil, architecture: nil, driveImage: driveImage)
     }
 }

+ 4 - 1
Platform/Shared/VMConfigInfoView.swift

@@ -173,7 +173,10 @@ private struct IconSelect: View {
     private let gridLayout = [GridItem(.adaptive(minimum: 60))]
     private var icons: [URL] {
         let paths = Bundle.main.paths(forResourcesOfType: "png", inDirectory: "Icons")
-        return paths.map({ URL(fileURLWithPath: $0) })
+        let urls = paths.map({ URL(fileURLWithPath: $0) })
+        return urls.sorted { urlA, urlB in
+            urlA.lastPathComponent < urlB.lastPathComponent
+        }
     }
     
     #if os(macOS)

+ 9 - 2
Platform/Shared/VMConfigNetworkView.swift

@@ -44,6 +44,13 @@ struct VMConfigNetworkView: View {
                 }.disabled(UTMQemuConfiguration.supportedNetworkCards(forArchitecture: config.systemArchitecture)?.isEmpty ?? true)
                 
                 if config.networkEnabled {
+                    HStack {
+                        DefaultTextField("MAC Address", text: $config.networkCardMac.bound, prompt: "00:00:00:00:00:00")
+                        Button("Random") {
+                            config.networkCardMac = UTMQemuConfiguration.generateMacAddress()
+                        }
+                    }
+                    
                     Toggle(isOn: $showAdvanced.animation(), label: {
                         Text("Show Advanced Settings")
                     })
@@ -101,13 +108,13 @@ struct IPConfigurationSection: View {
                     .keyboardType(.decimalPad)
                 DefaultTextField("Host Address (IPv6)", text: $config.networkHostIPv6.bound, prompt: "fec0::2")
                     .keyboardType(.asciiCapable)
-                DefaultTextField("DHCP Start", text: $config.networkDhcpStart.bound, prompt: "10.0.2.0.15")
+                DefaultTextField("DHCP Start", text: $config.networkDhcpStart.bound, prompt: "10.0.2.15")
                     .keyboardType(.decimalPad)
                 DefaultTextField("DHCP Host", text: $config.networkDhcpHost.bound)
                     .keyboardType(.asciiCapable)
                 DefaultTextField("DHCP Domain Name", text: $config.networkDhcpDomain.bound)
                     .keyboardType(.asciiCapable)
-                DefaultTextField("DNS Server", text: $config.networkDnsServer.bound, prompt: "10.0.2.0.15")
+                DefaultTextField("DNS Server", text: $config.networkDnsServer.bound, prompt: "10.0.2.3")
                     .keyboardType(.decimalPad)
                 DefaultTextField("DNS Server (IPv6)", text: $config.networkDnsServerIPv6.bound, prompt: "fec0::3")
                     .keyboardType(.asciiCapable)

+ 7 - 7
Platform/Shared/VMConfigQEMUView.swift

@@ -50,13 +50,13 @@ struct VMConfigQEMUView: View {
                     })
                     Button("Export Debug Log") {
                         showExportLog.toggle()
-                    }.modifier(VMShareItemModifier(isPresented: $showExportLog, items: exportDebugLog))
+                    }.modifier(VMShareItemModifier(isPresented: $showExportLog, makeShareItem: exportDebugLog))
                     .disabled(!logExists)
                 }
                 Section(header: Text("QEMU Arguments")) {
                     Button("Export QEMU Command") {
                         showExportArgs.toggle()
-                    }.modifier(VMShareItemModifier(isPresented: $showExportArgs, items: exportArgs))
+                    }.modifier(VMShareItemModifier(isPresented: $showExportArgs, makeShareItem: exportArgs))
                     Toggle(isOn: $config.ignoreAllConfiguration.animation(), label: {
                         Text("Advanced: Bypass configuration and manually specify arguments")
                     })
@@ -85,11 +85,11 @@ struct VMConfigQEMUView: View {
         }
     }
     
-    private func exportDebugLog() -> [URL] {
-        if let result = try? data.exportDebugLog(forConfig: config) {
+    private func exportDebugLog() -> VMShareItemModifier.ShareItem? {
+        if let result = try? data.exportDebugLog(for: config) {
             return result
         } else {
-            return [] // TODO: implement error handling
+            return nil // TODO: implement error handling
         }
     }
     
@@ -105,7 +105,7 @@ struct VMConfigQEMUView: View {
         }
     }
     
-    private func exportArgs() -> [String] {
+    private func exportArgs() -> VMShareItemModifier.ShareItem {
         let existingPath = config.existingPath ?? URL(fileURLWithPath: "Images")
         let qemuSystem = UTMQemuSystem(configuration: config, imgPath: existingPath)
         qemuSystem.updateArgv(withUserOptions: true)
@@ -117,7 +117,7 @@ struct VMConfigQEMUView: View {
                 argString += " \(arg)"
             }
         }
-        return [argString]
+        return .qemuCommand(argString)
     }
     
     private func arguments(from list: [String]) -> [Argument] {

+ 1 - 1
Platform/Shared/VMContextMenuModifier.swift

@@ -70,7 +70,7 @@ struct VMContextMenuModifier: ViewModifier {
             }
         }
         .modifier(VMShareItemModifier(isPresented: $showSharePopup) {
-            [vm.path!]
+            .utmVm(vm.path!)
         })
         .modifier(VMConfirmActionModifier(vm: vm, confirmAction: $confirmAction) {
             

+ 21 - 8
Platform/Shared/VMDetailsView.swift

@@ -55,14 +55,13 @@ struct VMDetailsView: View {
                 if regularScreenSizeClass && !notes.isEmpty {
                     HStack(alignment: .top) {
                         Details(vm: vm, sizeLabel: sizeLabel)
-                            .padding()
                             .frame(maxWidth: .infinity)
                         Text(notes)
                             .font(.body)
+                            .frame(maxWidth: .infinity, alignment: .topLeading)
                             .fixedSize(horizontal: false, vertical: true)
-                            .padding()
-                            .frame(maxWidth: .infinity)
-                    }
+                            .padding([.leading, .trailing])
+                    }.padding([.leading, .trailing])
                     if #available(macOS 12, *), let appleVM = vm as? UTMAppleVirtualMachine {
                         VMAppleRemovableDrivesView(vm: appleVM, config: appleVM.appleConfig)
                             .padding([.leading, .trailing, .bottom])
@@ -86,7 +85,7 @@ struct VMDetailsView: View {
                     }.padding([.leading, .trailing, .bottom])
                 }
             }.labelStyle(DetailsLabelStyle())
-            .navigationTitle(vm.title)
+            .modifier(VMOptionalNavigationTitleModifier(vm: vm))
             .modifier(VMToolbarModifier(vm: vm, bottom: !regularScreenSizeClass))
             .sheet(isPresented: $data.showSettingsModal) {
                 if #available(macOS 12, *), let appleVM = vm as? UTMAppleVirtualMachine {
@@ -101,6 +100,20 @@ struct VMDetailsView: View {
     }
 }
 
+/// Returns just the content under macOS but adds the title on iOS. #3099
+@available(iOS 14, macOS 11, *)
+private struct VMOptionalNavigationTitleModifier: ViewModifier {
+    let vm: UTMVirtualMachine
+    
+    func body(content: Content) -> some View {
+        #if os(macOS)
+        return content.navigationSubtitle(vm.title)
+        #else
+        return content.navigationTitle(vm.title)
+        #endif
+    }
+}
+
 @available(iOS 14, macOS 11, *)
 struct Screenshot: View {
     let vm: UTMVirtualMachine
@@ -111,13 +124,13 @@ struct Screenshot: View {
         ZStack {
             Rectangle()
                 .fill(Color.black)
-            if vm.screenshot?.image != nil {
+            if vm.screenshot != nil {
                 #if os(macOS)
-                Image(nsImage: vm.screenshot!.image!)
+                Image(nsImage: vm.screenshot!.image)
                     .resizable()
                     .aspectRatio(contentMode: .fit)
                 #else
-                Image(uiImage: vm.screenshot!.image!)
+                Image(uiImage: vm.screenshot!.image)
                     .resizable()
                     .aspectRatio(contentMode: .fit)
                 #endif

+ 2 - 2
Platform/Shared/VMDriveImage.swift

@@ -31,10 +31,10 @@ class VMDriveImage: ObservableObject {
         }
     }
     
-    func reset(forSystemTarget target: String?, removable: Bool) {
+    func reset(forSystemTarget target: String?, architecture: String?, removable: Bool) {
         self.removable = removable
         self.imageType = removable ? .CD : .disk
-        self.interface = UTMQemuConfiguration.defaultDriveInterface(forTarget: target, type: imageType)
+        self.interface = UTMQemuConfiguration.defaultDriveInterface(forTarget: target, architecture: architecture, type: imageType)
         self.size = removable ? 0 : 10240
     }
 }

+ 21 - 3
Platform/Shared/VMShareFileModifier.swift

@@ -19,20 +19,38 @@ import SwiftUI
 @available(iOS 14, macOS 11, *)
 struct VMShareItemModifier: ViewModifier {
     @Binding var isPresented: Bool
-    let items: () -> [Any]
+    // TODO: Change name to shareItem
+    let makeShareItem: () -> ShareItem?
     
     #if os(macOS)
     func body(content: Content) -> some View {
         ZStack {
-            SharingsPicker(isPresented: $isPresented, sharingItems: items())
+            SavePanel(isPresented: $isPresented, shareItem: makeShareItem())
             content
         }
     }
     #else
     func body(content: Content) -> some View {
         content.popover(isPresented: $isPresented) {
-            ActivityView(activityItems: items())
+            if let shareItem = makeShareItem()?.toActivityItem() {
+                ActivityView(activityItems: [shareItem as Any])
+            }
         }
     }
     #endif
+    
+    enum ShareItem {
+        case debugLog(URL)
+        case utmVm(URL)
+        case qemuCommand(String)
+        
+        func toActivityItem() -> Any {
+            switch self {
+            case .debugLog(let url), .utmVm(let url):
+                return url
+            case .qemuCommand(let command):
+                return command
+            }
+        }
+    }
 }

+ 2 - 3
Platform/Shared/VMToolbarModifier.swift

@@ -24,7 +24,6 @@ struct VMToolbarModifier: ViewModifier {
     @State private var showSharePopup = false
     @State private var confirmAction: ConfirmAction?
     @EnvironmentObject private var data: UTMData
-    @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
     
     #if os(macOS)
     let destructiveButtonColor: Color = .primary
@@ -84,7 +83,7 @@ struct VMToolbarModifier: ViewModifier {
                 }.help("Share selected VM")
                 .padding(.leading, padding)
                 .modifier(VMShareItemModifier(isPresented: $showSharePopup) {
-                    [vm.path!]
+                    .utmVm(vm.path!)
                 })
                 #if !os(macOS)
                 if bottom {
@@ -124,7 +123,7 @@ struct VMToolbarModifier: ViewModifier {
             }
         }
         .modifier(VMConfirmActionModifier(vm: vm, confirmAction: $confirmAction) {
-            presentationMode.wrappedValue.dismiss()
+            
         })
     }
 }

+ 3 - 3
Platform/Shared/VMWizardState.swift

@@ -301,7 +301,7 @@ class VMWizardState: ObservableObject {
         config.useHypervisor = useVirtualization
         config.shareDirectoryReadOnly = sharingReadOnly
         if !isSkipBootImage && bootImageURL != nil {
-            config.newRemovableDrive("cdrom0", type: .CD, interface: UTMQemuConfiguration.defaultDriveInterface(forTarget: systemTarget, type: .CD))
+            config.newRemovableDrive("cdrom0", type: .CD, interface: UTMQemuConfiguration.defaultDriveInterface(forTarget: systemTarget, architecture: systemArchitecture, type: .CD))
         }
         switch operatingSystem {
         case .Other:
@@ -316,7 +316,7 @@ class VMWizardState: ObservableObject {
                     config.newDrive("initrd", path: linuxInitialRamdiskURL.lastPathComponent, type: .initrd, interface: "")
                 }
                 if let linuxRootImageURL = linuxRootImageURL {
-                    config.newDrive("root", path: linuxRootImageURL.lastPathComponent, type: .disk, interface: UTMQemuConfiguration.defaultDriveInterface(forTarget: systemTarget, type: .disk))
+                    config.newDrive("root", path: linuxRootImageURL.lastPathComponent, type: .disk, interface: UTMQemuConfiguration.defaultDriveInterface(forTarget: systemTarget, architecture: systemArchitecture, type: .disk))
                 }
                 if linuxBootArguments.count > 0 {
                     config.newArgument("-append")
@@ -330,7 +330,7 @@ class VMWizardState: ObservableObject {
             }
         }
         if windowsBootVhdx == nil {
-            config.newDrive("drive0", path: "data.qcow2", type: .disk, interface: UTMQemuConfiguration.defaultDriveInterface(forTarget: systemTarget, type: .disk))
+            config.newDrive("drive0", path: "data.qcow2", type: .disk, interface: UTMQemuConfiguration.defaultDriveInterface(forTarget: systemTarget, architecture: systemArchitecture, type: .disk))
         }
         return config
     }

+ 5 - 0
Platform/UTMApp.swift

@@ -26,6 +26,11 @@ struct UTMApp: App {
     var body: some Scene {
         WindowGroup {
             ContentView().environmentObject(data)
+                .onAppear {
+                    #if os(macOS)
+                    appDelegate.data = data
+                    #endif
+                }
         }.commands { VMCommands() }
         #if os(macOS)
         Settings {

+ 107 - 17
Platform/UTMData.swift

@@ -15,6 +15,11 @@
 //
 
 import Foundation
+#if os(macOS)
+import AppKit
+#else
+import UIKit
+#endif
 
 @available(iOS 14, macOS 11, *)
 struct AlertMessage: Identifiable {
@@ -48,11 +53,14 @@ class UTMData: ObservableObject {
             defaults.set(paths, forKey: "VMList")
         }
     }
+    @Published private(set) var pendingVMs: [UTMPendingVirtualMachine]
     
     private var selectedDiskImagesCache: [String: URL]
     
     #if os(macOS)
     var vmWindows: [UTMVirtualMachine: VMDisplayWindowController] = [:]
+    #else
+    var vmVC: VMDisplayViewController?
     #endif
     
     var fileManager: FileManager {
@@ -63,10 +71,6 @@ class UTMData: ObservableObject {
         fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
     }
     
-    var tempURL: URL {
-        fileManager.temporaryDirectory
-    }
-    
     init() {
         let defaults = UserDefaults.standard
         self.showSettingsModal = false
@@ -74,8 +78,9 @@ class UTMData: ObservableObject {
         self.busy = false
         self.virtualMachines = []
         self.selectedDiskImagesCache = [:]
+        self.pendingVMs = []
         if let files = defaults.array(forKey: "VMList") as? [String] {
-            for file in files {
+            for file in files.uniqued() {
                 let url = documentsURL.appendingPathComponent(file, isDirectory: true)
                 if let vm = UTMVirtualMachine(url: url) {
                     self.virtualMachines.append(vm)
@@ -316,21 +321,15 @@ class UTMData: ObservableObject {
     
     // MARK: - Export debug log
     
-    func exportDebugLog(forConfig: UTMQemuConfiguration) throws -> [URL] {
-        guard let path = forConfig.existingPath else {
+    func exportDebugLog(for config: UTMQemuConfiguration) throws -> VMShareItemModifier.ShareItem {
+        guard let path = config.existingPath else {
             throw NSLocalizedString("No log found!", comment: "UTMData")
         }
         let srcLogPath = path.appendingPathComponent(UTMQemuConfiguration.debugLogName())
-        let dstLogPath = tempURL.appendingPathComponent(UTMQemuConfiguration.debugLogName())
-        
-        if fileManager.fileExists(atPath: dstLogPath.path) {
-            try fileManager.removeItem(at: dstLogPath)
-        }
-        try fileManager.copyItem(at: srcLogPath, to: dstLogPath)
-        
-        return [dstLogPath]
+        return .debugLog(srcLogPath)
     }
     
+    // MARK: - Import and Download VMs
     func copyUTM(at: URL, to: URL, move: Bool = false) throws {
         if move {
             try fileManager.moveItem(at: at, to: to)
@@ -347,6 +346,7 @@ class UTMData: ObservableObject {
     }
     
     func importUTM(url: URL) throws {
+        guard url.isFileURL else { return }
         _ = url.startAccessingSecurityScopedResource()
         defer { url.stopAccessingSecurityScopedResource() }
         
@@ -377,6 +377,31 @@ class UTMData: ObservableObject {
         }
     }
     
+    func tryDownloadVM(_ components: URLComponents) {
+        if let urlParameter = components.queryItems?.first(where: { $0.name == "url" })?.value,
+           urlParameter.contains(".zip"), let url = URL(string: urlParameter) {
+            let task = UTMImportFromWebTask(data: self, url: url)
+            let pendingVM = task.startDownload()
+            /// wait a half second before showing the "pending VM" UI, in case of very small file
+            /// this prevents the UI from appearing and disappearing very quickly
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in
+                if !task.isDone {
+                    pendingVMs.append(pendingVM)
+                }
+            }
+        }
+    }
+    
+    func removePendingVM(_ pendingVM: UTMPendingVirtualMachine) {
+        if let index = pendingVMs.firstIndex(of: pendingVM) {
+            pendingVMs.remove(at: index)
+        }
+    }
+    
+    func cancelPendingVM(_ pendingVM: UTMPendingVirtualMachine) {
+        pendingVM.cancel()
+    }
+    
     // MARK: - Disk drive functions
     
     func importDrive(_ drive: URL, for config: UTMQemuConfiguration, imageType: UTMDiskImageType, on interface: String, copy: Bool) throws {
@@ -405,6 +430,12 @@ class UTMData: ObservableObject {
         }
         DispatchQueue.main.async {
             let name = self.newDefaultDriveName(for: config)
+            let interface: String
+            if let target = config.systemTarget, let architecture = config.systemArchitecture {
+                interface = UTMQemuConfiguration.defaultDriveInterface(forTarget: target, architecture: architecture, type: imageType)
+            } else {
+                interface = "none"
+            }
             config.newDrive(name, path: path, type: imageType, interface: interface)
         }
     }
@@ -412,8 +443,8 @@ class UTMData: ObservableObject {
     func importDrive(_ drive: URL, for config: UTMQemuConfiguration, copy: Bool = true) throws {
         let imageType: UTMDiskImageType = drive.pathExtension.lowercased() == "iso" ? .CD : .disk
         let interface: String
-        if let target = config.systemTarget {
-            interface = UTMQemuConfiguration.defaultDriveInterface(forTarget: target, type: imageType)
+        if let target = config.systemTarget, let arch = config.systemArchitecture {
+            interface = UTMQemuConfiguration.defaultDriveInterface(forTarget: target, architecture: arch, type: imageType)
         } else {
             interface = "none"
         }
@@ -578,4 +609,63 @@ class UTMData: ObservableObject {
         }
     }
     #endif
+    // MARK: - Automation Features
+    
+    func trySendText(_ vm: UTMVirtualMachine, urlComponents components: URLComponents) {
+        guard let qemuVm = vm as? UTMQemuVirtualMachine else { return } // FIXME: implement for Apple VM
+        guard let queryItems = components.queryItems else { return }
+        guard let text = queryItems.first(where: { $0.name == "text" })?.value else { return }
+        if qemuVm.qemuConfig.displayConsoleOnly {
+            qemuVm.sendInput(text)
+        } else {
+            #if os(macOS)
+            trySendTextSpice(vm: qemuVm, text: text)
+            #else
+            trySendTextSpice(text)
+            #endif
+        }
+    }
+    
+    func tryClickVM(_ vm: UTMVirtualMachine, urlComponents components: URLComponents) {
+        guard let qemuVm = vm as? UTMQemuVirtualMachine else { return } // FIXME: implement for Apple VM
+        guard !qemuVm.qemuConfig.displayConsoleOnly else { return }
+        guard let queryItems = components.queryItems else { return }
+        /// Parse targeted position
+        var x: CGFloat? = nil
+        var y: CGFloat? = nil
+        let nf = NumberFormatter()
+        nf.allowsFloats = false
+        if let xStr = components.queryItems?.first(where: { item in
+            item.name == "x"
+        })?.value {
+            x = nf.number(from: xStr) as? CGFloat
+        }
+        if let yStr = components.queryItems?.first(where: { item in
+            item.name == "y"
+        })?.value {
+            y = nf.number(from: yStr) as? CGFloat
+        }
+        guard let xPos = x, let yPos = y else { return }
+        let point = CGPoint(x: xPos, y: yPos)
+        /// Parse which button should be clicked
+        var button: CSInputButton = .left
+        if let buttonStr = queryItems.first(where: { $0.name == "button"})?.value {
+            switch buttonStr {
+            case "middle":
+                button = .middle
+                break
+            case "right":
+                button = .right
+                break
+            default:
+                break
+            }
+        }
+        /// All parameters parsed, perform the click
+        #if os(macOS)
+        tryClickAtPoint(vm: qemuVm, point: point, button: button)
+        #else
+        tryClickAtPoint(point: point, button: button)
+        #endif
+    }
 }

+ 9 - 0
Platform/UTMExtensions.swift

@@ -97,6 +97,15 @@ extension View {
 extension UTType {
     // SwiftUI BUG: exportedAs: "com.utmapp.utm" doesn't work
     static let UTM = UTType(exportedAs: "utm")
+    
+    static let appleLog = UTType(filenameExtension: "log")!
+}
+
+extension Sequence where Element: Hashable {
+    func uniqued() -> [Element] {
+        var set = Set<Element>()
+        return filter { set.insert($0).inserted }
+    }
 }
 
 #if !os(macOS)

+ 7 - 5
Platform/iOS/Display/VMDisplayTerminalViewController.m

@@ -189,11 +189,13 @@ NSString* const kVMSendTerminalSizeHandler = @"UTMSendTerminalSize";
     [dataString appendString:@"]"];
     //UTMLog(@"Array: %@", dataString);
     NSString* jsString = [NSString stringWithFormat: @"writeData(new Uint8Array(%@));", dataString];
-    [_webView evaluateJavaScript: jsString completionHandler:^(id _Nullable _, NSError * _Nullable error) {
-        if (error != nil) {
-            UTMLog(@"JS evaluation failed: %@", [error localizedDescription]);
-        }
-    }];
+    dispatch_async(dispatch_get_main_queue(), ^{
+        [_webView evaluateJavaScript: jsString completionHandler:^(id _Nullable _, NSError * _Nullable error) {
+            if (error != nil) {
+                UTMLog(@"JS evaluation failed: %@", [error localizedDescription]);
+            }
+        }];
+    });
 }
 
 #pragma mark - State transition

+ 13 - 0
Platform/iOS/Info.plist

@@ -33,6 +33,19 @@
 	<string>$(MARKETING_VERSION)</string>
 	<key>CFBundleVersion</key>
 	<string>$(CURRENT_PROJECT_VERSION)</string>
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleTypeRole</key>
+			<string>Editor</string>
+			<key>CFBundleURLName</key>
+			<string>com.utmapp.UTM</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>UTM</string>
+			</array>
+		</dict>
+	</array>
 	<key>ITSAppUsesNonExemptEncryption</key>
 	<false/>
 	<key>LSRequiresIPhoneOS</key>

+ 2 - 2
Platform/iOS/Legacy/VMConfigDriveDetailViewController.m

@@ -49,7 +49,7 @@
         [self showImagePathCell:!self.removable animated:NO];
     } else {
         self.imageType = UTMDiskImageTypeDisk;
-        self.driveInterfaceType = [UTMQemuConfiguration defaultDriveInterfaceForTarget:self.configuration.systemTarget type:UTMDiskImageTypeDisk];
+        self.driveInterfaceType = [UTMQemuConfiguration defaultDriveInterfaceForTarget:self.configuration.systemTarget architecture:self.configuration.systemArchitecture type:UTMDiskImageTypeDisk];
     }
     if (self.imageType == UTMDiskImageTypeDisk || self.imageType == UTMDiskImageTypeCD) {
         [self showDriveTypeOptions:YES animated:NO];
@@ -104,7 +104,7 @@
 - (void)imageTypeChanged {
     if (self.imageType == UTMDiskImageTypeDisk || self.imageType == UTMDiskImageTypeCD) {
         if (self.driveInterfaceType.length == 0) {
-            self.driveInterfaceType = [UTMQemuConfiguration defaultDriveInterfaceForTarget:self.configuration.systemTarget type:self.imageType];
+            self.driveInterfaceType = [UTMQemuConfiguration defaultDriveInterfaceForTarget:self.configuration.systemTarget architecture:self.configuration.systemArchitecture type:self.imageType];
         }
         [self showDriveTypeOptions:YES animated:NO];
     } else {

+ 48 - 0
Platform/iOS/Legacy/zh-Hans.lproj/Main.strings

@@ -31,15 +31,24 @@
 /* Class = "UILabel"; text = "Full Graphics"; ObjectID = "3Jg-sa-a1s"; */
 "3Jg-sa-a1s.text" = "图形界面";
 
+/* Class = "UITextField"; placeholder = "10.0.2.0.15"; ObjectID = "4dt-SP-XL1"; */
+"4dt-SP-XL1.placeholder" = "10.0.2.0.15";
+
 /* Class = "UILabel"; text = "Theme"; ObjectID = "4tC-xd-0a7"; */
 "4tC-xd-0a7.text" = "主题";
 
+/* Class = "UILabel"; text = "Default"; ObjectID = "4UG-gS-O4i"; */
+"4UG-gS-O4i.text" = "默认";
+
 /* Class = "UILabel"; text = "Title"; ObjectID = "4Xf-3m-yzE"; */
 "4Xf-3m-yzE.text" = "标题";
 
 /* Class = "UILabel"; text = "Enable Directory Sharing"; ObjectID = "5Ae-Pr-d2U"; */
 "5Ae-Pr-d2U.text" = "开启共享目录";
 
+/* Class = "UITextField"; placeholder = "fec0::3"; ObjectID = "5AX-Tr-D6a"; */
+"5AX-Tr-D6a.placeholder" = "fec0::3";
+
 /* Class = "UILabel"; text = "DHCP Start"; ObjectID = "5O8-6K-mxz"; */
 "5O8-6K-mxz.text" = "DHCP开始地址";
 
@@ -73,9 +82,15 @@
 /* Class = "UILabel"; text = "Architecture"; ObjectID = "9Nu-pG-ywd"; */
 "9Nu-pG-ywd.text" = "架构";
 
+/* Class = "UILabel"; text = "Device"; ObjectID = "49o-9F-Gw7"; */
+"49o-9F-Gw7.text" = "设备";
+
 /* Class = "UINavigationItem"; title = "Select Image"; ObjectID = "340-Mu-Hwt"; */
 "340-Mu-Hwt.title" = "选择镜像";
 
+/* Class = "UILabel"; text = "12"; ObjectID = "734-u9-PNx"; */
+"734-u9-PNx.text" = "12";
+
 /* Class = "UILabel"; text = "Name"; ObjectID = "a1E-lK-5Yg"; */
 "a1E-lK-5Yg.text" = "Name";
 
@@ -91,6 +106,9 @@
 /* Class = "UILabel"; text = "Enabled"; ObjectID = "aHx-sa-LGi"; */
 "aHx-sa-LGi.text" = "开启";
 
+/* Class = "UITextField"; placeholder = "stty cols $COLS rows $ROWS\\n"; ObjectID = "aIM-IA-HSe"; */
+"aIM-IA-HSe.placeholder" = "stty cols $COLS rows $ROWS\\n";
+
 /* Class = "UITableViewSection"; headerTitle = "Boot"; ObjectID = "aje-ee-mJi"; */
 "aje-ee-mJi.headerTitle" = "启动";
 
@@ -169,6 +187,9 @@
 /* Class = "UILabel"; text = "Total RAM"; ObjectID = "ETs-n1-aUr"; */
 "ETs-n1-aUr.text" = "可用内存";
 
+/* Class = "UILabel"; text = "rtl8139"; ObjectID = "exK-JR-7gh"; */
+"exK-JR-7gh.text" = "rtl8139";
+
 /* Class = "UITableViewSection"; headerTitle = "Advanced"; ObjectID = "F7q-42-AmC"; */
 "F7q-42-AmC.headerTitle" = "高级选项";
 
@@ -208,6 +229,9 @@
 /* Class = "UITableViewSection"; headerTitle = "Additional Options"; ObjectID = "hjJ-kU-VR1"; */
 "hjJ-kU-VR1.headerTitle" = "更多设置";
 
+/* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "Hlx-Yq-UZR"; */
+"Hlx-Yq-UZR.title" = "项目";
+
 /* Class = "UILabel"; text = "Memory"; ObjectID = "HRc-mq-43y"; */
 "HRc-mq-43y.text" = "内存";
 
@@ -232,9 +256,15 @@
 /* Class = "UITextField"; placeholder = "Default"; ObjectID = "Jk5-aT-3ap"; */
 "Jk5-aT-3ap.placeholder" = "默认大小";
 
+/* Class = "UILabel"; text = "ac97"; ObjectID = "Jp2-wl-rfZ"; */
+"Jp2-wl-rfZ.text" = "ac97";
+
 /* Class = "UILabel"; text = "Sharing"; ObjectID = "jv0-6r-NAJ"; */
 "jv0-6r-NAJ.text" = "共享";
 
+/* Class = "UILabel"; text = "Removable"; ObjectID = "K1N-PB-wfm"; */
+"K1N-PB-wfm.text" = "可移动的";
+
 /* Class = "UILabel"; text = "Default"; ObjectID = "KFq-K6-6KS"; */
 "KFq-K6-6KS.text" = "默认";
 
@@ -358,6 +388,9 @@
 /* Class = "UILabel"; text = "System"; ObjectID = "riB-R9-dTb"; */
 "riB-R9-dTb.text" = "系统";
 
+/* Class = "UITextField"; placeholder = "10.0.2.2"; ObjectID = "RTg-tj-6ja"; */
+"RTg-tj-6ja.placeholder" = "10.0.2.2";
+
 /* Class = "UITableViewSection"; footerTitle = "PS/2 has higher compatibility with older operating systems but does not support custom cursor settings."; ObjectID = "Rtj-zp-arL"; */
 "Rtj-zp-arL.footerTitle" = "PS / 2与较旧的操作系统具有更高的兼容性,但不支持自定义光标设置。";
 
@@ -370,6 +403,12 @@
 /* Class = "UITableViewSection"; headerTitle = "Image"; ObjectID = "SCF-Z9-xBk"; */
 "SCF-Z9-xBk.headerTitle" = "镜像";
 
+/* Class = "UITableViewSection"; footerTitle = "For i386/x86_64, use qxl-vga. For virt systems use virtio-ramfb."; ObjectID = "SNE-dA-z6i"; */
+"SNE-dA-z6i.footerTitle" = "对于 i386/x86_64,使用 qxl-vga。 对于 virt 系统,请使用 virtio-ramfb。";
+
+/* Class = "UITableViewSection"; headerTitle = "Display Card"; ObjectID = "SNE-dA-z6i"; */
+"SNE-dA-z6i.headerTitle" = "显卡";
+
 /* Class = "UITableViewSection"; footerTitle = "For most non-ARM targets, all CPUs will be emulated by a single CPU by default. Set 0 to use maximum supported number of CPU."; ObjectID = "spp-Cv-g7X"; */
 "spp-Cv-g7X.footerTitle" = "对于大多数非ARM架构的系统,默认情况下多CPU将由单个CPU模拟。当您设置内存过大时,可能会导致虚拟机无法启动。";
 
@@ -406,9 +445,15 @@
 /* Class = "UITextField"; placeholder = "QEMU Args"; ObjectID = "upL-Oh-a3A"; */
 "upL-Oh-a3A.placeholder" = "QEMU参数";
 
+/* Class = "UITextField"; placeholder = "fec0::/64"; ObjectID = "UuB-XR-fJS"; */
+"UuB-XR-fJS.placeholder" = "fec0::/64";
+
 /* Class = "UILabel"; text = "0"; ObjectID = "uy0-gI-CBW"; */
 "uy0-gI-CBW.text" = "0";
 
+/* Class = "UITextField"; placeholder = "fec0::2"; ObjectID = "V2H-Ti-2Wa"; */
+"V2H-Ti-2Wa.placeholder" = "fec0::2";
+
 /* Class = "UILabel"; text = "Default"; ObjectID = "vJY-9c-oS6"; */
 "vJY-9c-oS6.text" = "默认";
 
@@ -442,6 +487,9 @@
 /* Class = "UILabel"; text = "Additional QEMU Arguments"; ObjectID = "wvt-Dh-tQm"; */
 "wvt-Dh-tQm.text" = "其他QEMU参数";
 
+/* Class = "UILabel"; text = "0.0"; ObjectID = "x96-L4-Oag"; */
+"x96-L4-Oag.text" = "0.0";
+
 /* Class = "UILabel"; text = "System"; ObjectID = "XhT-dg-bb9"; */
 "XhT-dg-bb9.text" = "CPU";
 

+ 16 - 0
Platform/iOS/UTMDataExtension.swift

@@ -44,6 +44,7 @@ extension UTMData {
         }
         
         let vc = self.createDisplay(vm: vm)
+        self.vmVC = vc
         window.rootViewController = vc
         window.makeKeyAndVisible()
         let options: UIView.AnimationOptions = .transitionCrossDissolve
@@ -59,4 +60,19 @@ extension UTMData {
             }
         }
     }
+    
+    func tryClickAtPoint(point: CGPoint, button: CSInputButton) {
+        if let vc = vmVC as? VMDisplayMetalViewController, let input = vc.vmInput {
+            input.sendMouseButton(button, pressed: true, point: point)
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
+                input.sendMouseButton(button, pressed: false, point: point)
+            }
+        }
+    }
+    
+    func trySendTextSpice(_ text: String) {
+        if let vc = vmVC as? VMDisplayMetalViewController, let input = vc.vmInput {
+            vc.keyboardView?.insertText(text)
+        }
+    }
 }

+ 7 - 5
Platform/iOS/VMConfigDrivesView.swift

@@ -74,7 +74,7 @@ struct VMConfigDrivesView: View {
         )
         .fileImporter(isPresented: $importDrivePresented, allowedContentTypes: [.item], onCompletion: importDrive)
         .sheet(isPresented: $createDriveVisible) {
-            CreateDrive(target: config.systemTarget, onDismiss: newDrive)
+            CreateDrive(target: config.systemTarget, architecture: config.systemArchitecture, onDismiss: newDrive)
         }
         .actionSheet(item: $attemptDelete) { offsets in
             ActionSheet(title: Text("Confirm Delete"), message: Text("Are you sure you want to permanently delete this disk image?"), buttons: [.cancel(), .destructive(Text("Delete")) {
@@ -127,18 +127,20 @@ struct VMConfigDrivesView: View {
 @available(iOS 14, *)
 private struct CreateDrive: View {
     let target: String?
+    let architecture: String?
     let onDismiss: (VMDriveImage) -> Void
     @StateObject private var driveImage = VMDriveImage()
     @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
     
-    init(target: String?, onDismiss: @escaping (VMDriveImage) -> Void) {
+    init(target: String?, architecture: String?, onDismiss: @escaping (VMDriveImage) -> Void) {
         self.target = target
+        self.architecture = architecture
         self.onDismiss = onDismiss
     }
     
     var body: some View {
         NavigationView {
-            VMConfigDriveCreateView(target: target, driveImage: driveImage)
+            VMConfigDriveCreateView(target: target, architecture: architecture, driveImage: driveImage)
                 .navigationBarItems(leading: Button(action: cancel, label: {
                     Text("Cancel")
                 }), trailing: Button(action: done, label: {
@@ -146,7 +148,7 @@ private struct CreateDrive: View {
                 }))
         }.navigationViewStyle(StackNavigationViewStyle())
         .onAppear {
-            driveImage.reset(forSystemTarget: target, removable: false)
+            driveImage.reset(forSystemTarget: target, architecture: architecture, removable: false)
         }
     }
     
@@ -169,7 +171,7 @@ struct VMConfigDrivesView_Previews: PreviewProvider {
     static var previews: some View {
         Group {
             VMConfigDrivesView(config: config)
-            CreateDrive(target: nil) { _ in
+            CreateDrive(target: nil, architecture: nil) { _ in
                 
             }
         }.onAppear {

+ 18 - 0
Platform/iOS/zh-Hans.lproj/InfoPlist.strings

@@ -0,0 +1,18 @@
+/* Bundle name */
+"CFBundleName" = "UTM SE";
+
+/* Privacy - Location Always and When In Use Usage Description */
+"NSLocationAlwaysAndWhenInUseUsageDescription" = "虚拟机后台运行需要使用定位服务。 位置数据永远不会离开设备。";
+
+/* Privacy - Location Always Usage Description */
+"NSLocationAlwaysUsageDescription" = "虚拟机后台运行需要使用定位服务。 位置数据永远不会离开设备。";
+
+/* Privacy - Location When In Use Usage Description */
+"NSLocationWhenInUseUsageDescription" = "虚拟机后台运行需要使用定位服务。 位置数据永远不会离开设备。";
+
+/* Privacy - Microphone Usage Description */
+"NSMicrophoneUsageDescription" = "虚拟机需要访问麦克风。";
+
+/* (No Comment) */
+"UTM virtual machine" = "UTM虚拟机";
+

+ 39 - 0
Platform/macOS/AppDelegate.swift

@@ -15,7 +15,46 @@
 //
 
 class AppDelegate: NSObject, NSApplicationDelegate {
+    var data: UTMData?
+    
     func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
         true
     }
+    
+    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+        guard let data = data else {
+            return .terminateNow
+        }
+
+        let vmList = data.vmWindows.keys
+        if vmList.contains(where: { $0.state == .vmStarted }) { // There is at least 1 running VM
+            DispatchQueue.main.async {
+                let alert = NSAlert()
+                alert.alertStyle = .informational
+                alert.messageText = NSLocalizedString("Confirmation", comment: "VMDisplayWindowController")
+                alert.informativeText = NSLocalizedString("Quitting UTM will kill all running VMs.", comment: "VMDisplayMetalWindowController")
+                alert.addButton(withTitle: NSLocalizedString("OK", comment: "VMDisplayWindowController"))
+                alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayWindowController"))
+                let confirm = { (response: NSApplication.ModalResponse) in
+                    switch response {
+                    case .alertFirstButtonReturn:
+                        NSApplication.shared.reply(toApplicationShouldTerminate: true)
+                    default:
+                        NSApplication.shared.reply(toApplicationShouldTerminate: false)
+                    }
+                }
+                if let window = sender.keyWindow {
+                    alert.beginSheetModal(for: window, completionHandler: confirm)
+                } else {
+                    let response = alert.runModal()
+                    confirm(response)
+                }
+            }
+            return .terminateLater
+        } else if vmList.allSatisfy({ $0.state == .vmStopped || $0.state == .vmSuspended || $0.state == .vmError }) { // All VMs are stopped or in an error state
+            return .terminateNow
+        } else { // There could be some VMs in other states (starting, pausing, etc.)
+            return .terminateCancel
+        }
+    }
 }

+ 26 - 46
Platform/macOS/Display/VMDisplayMetalWindowController.swift

@@ -45,6 +45,7 @@ class VMDisplayMetalWindowController: VMDisplayWindowController {
     @Setting("DisplayFixed") private var isDisplayFixed: Bool = false
     @Setting("CtrlRightClick") private var isCtrlRightClick: Bool = false
     @Setting("NoUsbPrompt") private var isNoUsbPrompt: Bool = false
+    @Setting("AlternativeCaptureKey") private var isAlternativeCaptureKey: Bool = false
     private var settingObservations = [NSKeyValueObservation]()
     
     // MARK: - Init
@@ -60,6 +61,7 @@ class VMDisplayMetalWindowController: VMDisplayWindowController {
             return
         }
         displayView.addSubview(metalView)
+        window!.recalculateKeyViewLoop()
         renderer = UTMRenderer.init(metalKitView: metalView)
         guard let renderer = self.renderer else {
             showErrorAlert(NSLocalizedString("Internal error.", comment: "VMDisplayMetalWindowController"))
@@ -144,6 +146,7 @@ class VMDisplayMetalWindowController: VMDisplayWindowController {
 extension VMDisplayMetalWindowController: UTMSpiceIODelegate {
     func spiceDidChange(_ input: CSInput) {
         vmInput = input
+        vm.requestInputTablet(!(metalView?.isMouseCaptured ?? false))
     }
     
     func spiceDidCreateDisplay(_ display: CSDisplayMetal) {
@@ -303,30 +306,29 @@ extension VMDisplayMetalWindowController {
         isFullScreen = false
     }
     
-    func windowDidBecomeKey(_ notification: Notification) {
-        if let window = self.window {
-            _ = window.makeFirstResponder(metalView)
-        }
-    }
-    
-    func windowDidResignKey(_ notification: Notification) {
-        if let window = self.window {
-            _ = window.makeFirstResponder(nil)
-        }
+    override func windowDidResignKey(_ notification: Notification) {
+        releaseMouse()
+        super.windowDidResignKey(notification)
     }
 }
 
 // MARK: - Input events
 extension VMDisplayMetalWindowController: VMMetalViewInputDelegate {
-    private func captureMouse() {
+    var shouldUseCmdOptForCapture: Bool {
+        isAlternativeCaptureKey || NSWorkspace.shared.isVoiceOverEnabled
+    }
+    
+    func captureMouse() {
         let action = { () -> Void in
             self.vm.requestInputTablet(false)
             self.metalView?.captureMouse()
+            self.window?.subtitle = NSLocalizedString("Press \(self.shouldUseCmdOptForCapture ? "⌘+⌥" : "⌃+⌥") to release cursor", comment: "VMDisplayMetalWindowController")
+            self.window?.makeFirstResponder(self.metalView)
         }
         if isCursorCaptureAlertShown {
             let alert = NSAlert()
             alert.messageText = NSLocalizedString("Captured mouse", comment: "VMDisplayMetalWindowController")
-            alert.informativeText = NSLocalizedString("To release the mouse cursor, press ⌃+⌥ (Ctrl+Opt or Ctrl+Alt) at the same time.", comment: "VMDisplayMetalWindowController")
+            alert.informativeText = NSLocalizedString("To release the mouse cursor, press \(self.shouldUseCmdOptForCapture ? "⌘+⌥ (Cmd+Opt)" : "⌃+⌥ (Ctrl+Opt)") at the same time.", comment: "VMDisplayMetalWindowController")
             alert.showsSuppressionButton = true
             alert.beginSheetModal(for: window!) { _ in
                 if alert.suppressionButton?.state ?? .off == .on {
@@ -339,9 +341,10 @@ extension VMDisplayMetalWindowController: VMMetalViewInputDelegate {
         }
     }
     
-    private func releaseMouse() {
+    func releaseMouse() {
         vm.requestInputTablet(true)
         metalView?.releaseMouse()
+        self.window?.subtitle = ""
     }
     
     func mouseMove(absolutePoint: CGPoint, button: CSInputButton) {
@@ -398,26 +401,22 @@ extension VMDisplayMetalWindowController: VMMetalViewInputDelegate {
         }
     }
     
-    func keyDown(keyCode: Int) {
-        if (keyCode & 0xFF) == 0x1D { // Ctrl
+    func keyDown(scanCode: Int) {
+        if (scanCode & 0xFF) == 0x1D { // Ctrl
             ctrlKeyDown = true
         }
-        sendExtendedKey(.press, keyCode: keyCode)
+        sendExtendedKey(.press, keyCode: scanCode)
     }
     
-    func keyUp(keyCode: Int) {
-        if (keyCode & 0xFF) == 0x1D { // Ctrl
+    func keyUp(scanCode: Int) {
+        if (scanCode & 0xFF) == 0x1D { // Ctrl
             ctrlKeyDown = false
         }
-        sendExtendedKey(.release, keyCode: keyCode)
-    }
-    
-    func requestReleaseCapture() {
-        releaseMouse()
+        sendExtendedKey(.release, keyCode: scanCode)
     }
     
     private func handleCaptureKeys(for event: NSEvent) -> Bool {
-        // if captured we route all keyevents to view, even Cmd+Q and Cmd+W
+        // if captured we route all keyevents to view
         if let metalView = metalView, metalView.isMouseCaptured {
             if event.type == .keyDown {
                 metalView.keyDown(with: event)
@@ -426,30 +425,11 @@ extension VMDisplayMetalWindowController: VMMetalViewInputDelegate {
             }
             return true
         }
-        // otherwise, we confirm Cmd+Q and Cmd+W
-        if event.modifierFlags.contains(.command) && event.type == .keyDown {
-            if event.keyCode == kVK_ANSI_Q {
-                showConfirmAlert(NSLocalizedString("Quitting UTM will kill all running VMs.", comment: "VMDisplayMetalWindowController")) {
-                    NSApp.terminate(self)
-                }
-                return true
-            } else if event.keyCode == kVK_ANSI_W {
-                if vm.state == .vmStarted {
-                    showConfirmAlert(NSLocalizedString("Closing this window will kill the VM.", comment: "VMDisplayMetalWindowController")) {
-                        DispatchQueue.global(qos: .background).async {
-                            self.vm.quitVM()
-                        }
-                    }
-                } else if vm.state == .vmStopped || vm.state == .vmError {
-                    return false // we can close the window
-                } else {
-                    return true // do not close window when in progress
-                }
-            }
-        } else if event.modifierFlags.contains(.command) && event.type == .keyUp {
+        
+        if event.modifierFlags.contains(.command) && event.type == .keyUp {
             // for some reason, macOS doesn't like to send Cmd+KeyUp
             metalView.keyUp(with: event)
-            return true
+            return false
         }
         return false
     }

+ 6 - 3
Platform/macOS/Display/VMDisplayTerminalWindowController.swift

@@ -41,6 +41,7 @@ class VMDisplayTerminalWindowController: VMDisplayWindowController {
         webView.autoresizingMask = [.width, .height]
         webView.setValue(false, forKey: "drawsBackground")
         displayView.addSubview(webView)
+        window!.recalculateKeyViewLoop()
         
         // load terminal.html
         guard let resourceURL = Bundle.main.resourceURL else {
@@ -149,9 +150,11 @@ extension VMDisplayTerminalWindowController: UTMTerminalDelegate {
         }
         dataString = dataString + "]"
         let jsString = "writeData(new Uint8Array(\(dataString)));"
-        webView.evaluateJavaScript(jsString) { (_, err) in
-            if let error = err {
-                logger.error("JS evaluation failed: \(error)")
+        DispatchQueue.main.async {
+            self.webView.evaluateJavaScript(jsString) { (_, err) in
+                if let error = err {
+                    logger.error("JS evaluation failed: \(error)")
+                }
             }
         }
     }

+ 1 - 1
Platform/macOS/Display/VMDisplayWindow.xib

@@ -141,7 +141,7 @@
                             </connections>
                         </button>
                     </toolbarItem>
-                    <toolbarItem implicitItemIdentifier="E86439F7-239E-4570-B1FE-FFF2B4BA3F10" label="Capture Mouse" paletteLabel="Capture Mouse" toolTip="Capture mouse cursor (⌃+⌥ to release)" image="cursorarrow.rays" catalog="system" sizingBehavior="auto" id="FN7-zs-mWC">
+                    <toolbarItem implicitItemIdentifier="E86439F7-239E-4570-B1FE-FFF2B4BA3F10" label="Capture Mouse" paletteLabel="Capture Mouse" toolTip="Capture mouse cursor" image="cursorarrow.rays" catalog="system" sizingBehavior="auto" id="FN7-zs-mWC">
                         <button key="view" verticalHuggingPriority="750" id="Ge3-wo-FzQ">
                             <rect key="frame" x="30" y="14" width="28" height="23"/>
                             <autoresizingMask key="autoresizingMask"/>

+ 35 - 0
Platform/macOS/Display/VMDisplayWindowController.swift

@@ -128,6 +128,7 @@ class VMDisplayWindowController: NSWindowController {
         sharedFolderToolbarItem.isEnabled = vm.hasShareDirectoryEnabled
         usbToolbarItem.isEnabled = vm.hasUsbRedirection
         window!.title = vmQemuConfig!.name
+        window!.makeFirstResponder(displayView.subviews.first)
     }
     
     func enterSuspended(isBusy busy: Bool) {
@@ -151,6 +152,7 @@ class VMDisplayWindowController: NSWindowController {
         drivesToolbarItem.isEnabled = false
         sharedFolderToolbarItem.isEnabled = false
         usbToolbarItem.isEnabled = false
+        window!.makeFirstResponder(nil)
     }
     
     // MARK: - Alert
@@ -183,12 +185,45 @@ extension VMDisplayWindowController: NSWindowDelegate {
         return [.autoHideToolbar, .autoHideMenuBar, .fullScreen]
     }
     
+    func windowShouldClose(_ sender: NSWindow) -> Bool {
+        guard vm.state != .vmStopped && vm.state != .vmSuspended && vm.state != .vmError else {
+            return true
+        }
+        let alert = NSAlert()
+        alert.alertStyle = .informational
+        alert.messageText = NSLocalizedString("Confirmation", comment: "VMDisplayWindowController")
+        alert.informativeText = NSLocalizedString("Closing this window will kill the VM.", comment: "VMDisplayMetalWindowController")
+        alert.addButton(withTitle: NSLocalizedString("OK", comment: "VMDisplayWindowController"))
+        alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayWindowController"))
+        alert.beginSheetModal(for: sender) { response in
+            switch response {
+            case .alertFirstButtonReturn:
+                sender.close()
+            default:
+                return
+            }
+        }
+        return false
+    }
+    
     func windowWillClose(_ notification: Notification) {
         DispatchQueue.global(qos: .background).async {
             self.vm.quitVM(force: true)
         }
         onClose?(notification)
     }
+    
+    func windowDidBecomeKey(_ notification: Notification) {
+        if let window = self.window {
+            _ = window.makeFirstResponder(displayView.subviews.first)
+        }
+    }
+    
+    func windowDidResignKey(_ notification: Notification) {
+        if let window = self.window {
+            _ = window.makeFirstResponder(nil)
+        }
+    }
 }
 
 // MARK: - Toolbar

+ 177 - 156
Platform/macOS/Display/VMMetalView.swift

@@ -16,148 +16,62 @@
 
 import Carbon.HIToolbox
 
-private let macVkToScancode = [
-    kVK_ANSI_A: 0x1E,
-    kVK_ANSI_S: 0x1F,
-    kVK_ANSI_D: 0x20,
-    kVK_ANSI_F: 0x21,
-    kVK_ANSI_H: 0x23,
-    kVK_ANSI_G: 0x22,
-    kVK_ANSI_Z: 0x2C,
-    kVK_ANSI_X: 0x2D,
-    kVK_ANSI_C: 0x2E,
-    kVK_ANSI_V: 0x2F,
-    kVK_ANSI_B: 0x30,
-    kVK_ANSI_Q: 0x10,
-    kVK_ANSI_W: 0x11,
-    kVK_ANSI_E: 0x12,
-    kVK_ANSI_R: 0x13,
-    kVK_ANSI_Y: 0x15,
-    kVK_ANSI_T: 0x14,
-    kVK_ANSI_1: 0x02,
-    kVK_ANSI_2: 0x03,
-    kVK_ANSI_3: 0x04,
-    kVK_ANSI_4: 0x05,
-    kVK_ANSI_6: 0x07,
-    kVK_ANSI_5: 0x06,
-    kVK_ANSI_Equal: 0x0D,
-    kVK_ANSI_9: 0x0A,
-    kVK_ANSI_7: 0x08,
-    kVK_ANSI_Minus: 0x0C,
-    kVK_ANSI_8: 0x09,
-    kVK_ANSI_0: 0x0B,
-    kVK_ANSI_RightBracket: 0x1B,
-    kVK_ANSI_O: 0x18,
-    kVK_ANSI_U: 0x16,
-    kVK_ANSI_LeftBracket: 0x1A,
-    kVK_ANSI_I: 0x17,
-    kVK_ANSI_P: 0x19,
-    kVK_ANSI_L: 0x26,
-    kVK_ANSI_J: 0x24,
-    kVK_ANSI_Quote: 0x28,
-    kVK_ANSI_K: 0x25,
-    kVK_ANSI_Semicolon: 0x27,
-    kVK_ANSI_Backslash: 0x2B,
-    kVK_ANSI_Comma: 0x33,
-    kVK_ANSI_Slash: 0x35,
-    kVK_ANSI_N: 0x31,
-    kVK_ANSI_M: 0x32,
-    kVK_ANSI_Period: 0x34,
-    kVK_ANSI_Grave: 0x29,
-    kVK_ANSI_KeypadDecimal: 0x53,
-    kVK_ANSI_KeypadMultiply: 0x37,
-    kVK_ANSI_KeypadPlus: 0x4E,
-    kVK_ANSI_KeypadClear: 0x45,
-    kVK_ANSI_KeypadDivide: 0xE035,
-    kVK_ANSI_KeypadEnter: 0xE01C,
-    kVK_ANSI_KeypadMinus: 0x4A,
-    kVK_ANSI_KeypadEquals: 0x59,
-    kVK_ANSI_Keypad0: 0x52,
-    kVK_ANSI_Keypad1: 0x4F,
-    kVK_ANSI_Keypad2: 0x50,
-    kVK_ANSI_Keypad3: 0x51,
-    kVK_ANSI_Keypad4: 0x4B,
-    kVK_ANSI_Keypad5: 0x4C,
-    kVK_ANSI_Keypad6: 0x4D,
-    kVK_ANSI_Keypad7: 0x47,
-    kVK_ANSI_Keypad8: 0x48,
-    kVK_ANSI_Keypad9: 0x49,
-    kVK_Return: 0x1C,
-    kVK_Tab: 0x0F,
-    kVK_Space: 0x39,
-    kVK_Delete: 0x0E,
-    kVK_Escape: 0x01,
-    kVK_Command: 0xE05B,
-    kVK_Shift: 0x2A,
-    kVK_CapsLock: 0x3A,
-    kVK_Option: 0x38,
-    kVK_Control: 0x1D,
-    kVK_RightCommand: 0xE05C,
-    kVK_RightShift: 0x36,
-    kVK_RightOption: 0xE038,
-    kVK_RightControl: 0xE01D,
-    kVK_Function: 0x00,
-    kVK_F17: 0x68,
-    kVK_VolumeUp: 0xE030,
-    kVK_VolumeDown: 0xE02E,
-    kVK_Mute: 0xE020,
-    kVK_F18: 0x69,
-    kVK_F19: 0x6A,
-    kVK_F20: 0x6B,
-    kVK_F5: 0x3F,
-    kVK_F6: 0x40,
-    kVK_F7: 0x41,
-    kVK_F3: 0x3D,
-    kVK_F8: 0x42,
-    kVK_F9: 0x43,
-    kVK_F11: 0x57,
-    kVK_F13: 0x64,
-    kVK_F16: 0x67,
-    kVK_F14: 0x65,
-    kVK_F10: 0x44,
-    kVK_F12: 0x58,
-    kVK_F15: 0x66,
-    kVK_Help: 0x00,
-    kVK_Home: 0xE047,
-    kVK_PageUp: 0xE049,
-    kVK_ForwardDelete: 0xE053,
-    kVK_F4: 0x3E,
-    kVK_End: 0xE04F,
-    kVK_F2: 0x3C,
-    kVK_PageDown: 0xE051,
-    kVK_F1: 0x3B,
-    kVK_LeftArrow: 0xE04B,
-    kVK_RightArrow: 0xE04D,
-    kVK_DownArrow: 0xE050,
-    kVK_UpArrow: 0xE048,
-    kVK_ISO_Section: 0x00,
-    kVK_JIS_Yen: 0x7D,
-    kVK_JIS_Underscore: 0x73,
-    kVK_JIS_KeypadComma: 0x5C,
-    kVK_JIS_Eisu: 0x73,
-    kVK_JIS_Kana: 0x70,
-]
-
 class VMMetalView: MTKView {
     weak var inputDelegate: VMMetalViewInputDelegate?
     private var wholeTrackingArea: NSTrackingArea?
     private var lastModifiers = NSEvent.ModifierFlags()
+    private var lastKeyDown: Int?
     private(set) var isMouseCaptured = false
     private(set) var isFirstResponder = false
     private(set) var isMouseInWindow = false
     
+    /// On ISO keyboards we have to switch `kVK_ISO_Section` and `kVK_ANSI_Grave`
+    /// from: https://chromium.googlesource.com/chromium/src/+/lkgr/ui/events/keycodes/keyboard_code_conversion_mac.mm
+    private func convertToCurrentLayout(for keycode: Int) -> Int {
+        guard KBGetLayoutType(Int16(LMGetKbdType())) == kKeyboardISO else {
+            return keycode
+        }
+        switch keycode {
+        case kVK_ISO_Section:
+            return kVK_ANSI_Grave
+        case kVK_ANSI_Grave:
+            return kVK_ISO_Section
+        default:
+            return keycode
+        }
+    }
+    
+    /// Returns the scan code for the key code in the `event`, or `0` if scan code is unknown.
+    private func getScanCodeForEvent(_ event: NSEvent) -> Int {
+        if event.type == .keyDown || event.type == .keyUp {
+            let keycode = convertToCurrentLayout(for: Int(event.keyCode))
+            /// see KeyCodeMap file for explaination why the .down scan code is used for both key down and up
+            return Int(KeyCodeMap.keyCodeToScanCodes[keycode]?.down ?? 0)
+        } else {
+            return 0
+        }
+    }
+    
     override var acceptsFirstResponder: Bool { true }
     
     override func becomeFirstResponder() -> Bool {
         isFirstResponder = true
         if isMouseInWindow {
-            NSCursor.hide()
+            NSCursor.tryHide()
         }
         return super.becomeFirstResponder()
     }
     
     override func resignFirstResponder() -> Bool {
         isFirstResponder = false
+        NSCursor.tryUnhide()
+        if let lastKeyDown = lastKeyDown {
+            inputDelegate?.keyUp(scanCode: lastKeyDown)
+        }
+        if lastModifiers.containsSpecialKeys {
+            sendModifiers(lastModifiers, press: false)
+            lastModifiers = []
+        }
         return super.resignFirstResponder()
     }
     
@@ -167,7 +81,7 @@ class VMMetalView: MTKView {
         if let oldTrackingArea = wholeTrackingArea {
             logger.debug("remove old tracking area: \(oldTrackingArea.rect)")
             removeTrackingArea(oldTrackingArea)
-            NSCursor.unhide()
+            NSCursor.tryUnhide()
         }
         wholeTrackingArea = trackingArea
         addTrackingArea(trackingArea)
@@ -178,14 +92,14 @@ class VMMetalView: MTKView {
         logger.debug("mouse entered (first responder: \(isFirstResponder))")
         isMouseInWindow = true
         if isFirstResponder {
-            NSCursor.hide()
+            NSCursor.tryHide()
         }
     }
     
     override func mouseExited(with event: NSEvent) {
         logger.debug("mouse exited")
         isMouseInWindow = false
-        NSCursor.unhide()
+        NSCursor.tryUnhide()
     }
     
     override func mouseDown(with event: NSEvent) {
@@ -211,74 +125,93 @@ class VMMetalView: MTKView {
     override func keyDown(with event: NSEvent) {
         guard !event.isARepeat else { return }
         logger.trace("key down: \(event.keyCode)")
-        inputDelegate?.keyDown(keyCode: macVkToScancode[Int(event.keyCode)] ?? 0)
+        lastKeyDown = getScanCodeForEvent(event)
+        inputDelegate?.keyDown(scanCode: lastKeyDown!)
     }
     
     override func keyUp(with event: NSEvent) {
         logger.trace("key up: \(event.keyCode)")
-        inputDelegate?.keyUp(keyCode: macVkToScancode[Int(event.keyCode)] ?? 0)
+        lastKeyDown = nil
+        inputDelegate?.keyUp(scanCode: getScanCodeForEvent(event))
     }
     
     override func flagsChanged(with event: NSEvent) {
         let modifiers = event.modifierFlags
         logger.trace("modifers: \(modifiers)")
-        if modifiers.isSuperset(of: [.option, .control]) {
-            logger.trace("release cursor")
-            inputDelegate?.requestReleaseCapture()
+        if let shouldUseCmdOptForCapture = inputDelegate?.shouldUseCmdOptForCapture {
+            let captureKeyPressed: Bool
+            if shouldUseCmdOptForCapture {
+                captureKeyPressed = modifiers.isSuperset(of: [.command, .option])
+            } else {
+                captureKeyPressed = modifiers.isSuperset(of: [.control, .option])
+            }
+            if captureKeyPressed {
+                if isMouseCaptured {
+                    inputDelegate!.releaseMouse()
+                } else {
+                    inputDelegate!.captureMouse()
+                }
+            }
         }
         sendModifiers(lastModifiers.subtracting(modifiers), press: false)
         sendModifiers(modifiers.subtracting(lastModifiers), press: true)
         lastModifiers = modifiers
+        if !isMouseCaptured {
+            super.flagsChanged(with: event)
+        }
     }
     
     private func sendModifiers(_ modifier: NSEvent.ModifierFlags, press: Bool) {
         if modifier.contains(.capsLock) {
+            let sc = Int(KeyCodeMap.keyCodeToScanCodes[kVK_CapsLock]!.down)
             if press {
-                inputDelegate?.keyDown(keyCode: macVkToScancode[kVK_CapsLock]!)
+                inputDelegate?.keyDown(scanCode: sc)
             } else {
-                inputDelegate?.keyUp(keyCode: macVkToScancode[kVK_CapsLock]!)
+                inputDelegate?.keyUp(scanCode: sc)
             }
         }
-        if modifier.contains(.command) {
+        if !modifier.isDisjoint(with: [.command, .leftCommand, .rightCommand]) {
+            let vk = modifier.contains(.rightCommand) ? kVK_RightCommand : kVK_Command
+            let sc = Int(KeyCodeMap.keyCodeToScanCodes[vk]!.down)
             if press {
-                inputDelegate?.keyDown(keyCode: macVkToScancode[kVK_Command]!)
+                inputDelegate?.keyDown(scanCode: sc)
             } else {
-                inputDelegate?.keyUp(keyCode: macVkToScancode[kVK_Command]!)
+                inputDelegate?.keyUp(scanCode: sc)
             }
         }
-        if modifier.contains(.control) {
+        if !modifier.isDisjoint(with: [.control, .leftControl, .rightControl]) {
+            let vk = modifier.contains(.rightControl) ? kVK_RightControl : kVK_Control
+            let sc = Int(KeyCodeMap.keyCodeToScanCodes[vk]!.down)
             if press {
-                inputDelegate?.keyDown(keyCode: macVkToScancode[kVK_Control]!)
+                inputDelegate?.keyDown(scanCode: sc)
             } else {
-                inputDelegate?.keyUp(keyCode: macVkToScancode[kVK_Control]!)
+                inputDelegate?.keyUp(scanCode: sc)
             }
         }
         if modifier.contains(.function) {
+            let sc = Int(KeyCodeMap.keyCodeToScanCodes[kVK_Function]!.down)
             if press {
-                inputDelegate?.keyDown(keyCode: macVkToScancode[kVK_Function]!)
-            } else {
-                inputDelegate?.keyUp(keyCode: macVkToScancode[kVK_Function]!)
-            }
-        }
-        if modifier.contains(.help) {
-            if press {
-                inputDelegate?.keyDown(keyCode: macVkToScancode[kVK_Help]!)
+                inputDelegate?.keyDown(scanCode: sc)
             } else {
-                inputDelegate?.keyUp(keyCode: macVkToScancode[kVK_Help]!)
+                inputDelegate?.keyUp(scanCode: sc)
             }
         }
-        if modifier.contains(.option) {
+        if !modifier.isDisjoint(with: [.option, .leftOption, .rightOption]) {
+            let vk = modifier.contains(.rightOption) ? kVK_RightOption : kVK_Option
+            let sc = Int(KeyCodeMap.keyCodeToScanCodes[vk]!.down)
             if press {
-                inputDelegate?.keyDown(keyCode: macVkToScancode[kVK_Option]!)
+                inputDelegate?.keyDown(scanCode: sc)
             } else {
-                inputDelegate?.keyUp(keyCode: macVkToScancode[kVK_Option]!)
+                inputDelegate?.keyUp(scanCode: sc)
             }
         }
-        if modifier.contains(.shift) {
+        if !modifier.isDisjoint(with: [.shift, .leftShift, .rightShift]) {
+            let vk = modifier.contains(.rightShift) ? kVK_RightShift : kVK_Shift
+            let sc = Int(KeyCodeMap.keyCodeToScanCodes[vk]!.down)
             if press {
-                inputDelegate?.keyDown(keyCode: macVkToScancode[kVK_Shift]!)
+                inputDelegate?.keyDown(scanCode: sc)
             } else {
-                inputDelegate?.keyUp(keyCode: macVkToScancode[kVK_Shift]!)
+                inputDelegate?.keyUp(scanCode: sc)
             }
         }
     }
@@ -330,21 +263,75 @@ extension VMMetalView {
     }
     
     func captureMouse() {
+        logger.trace("capture cursor")
         CGAssociateMouseAndMouseCursorPosition(0)
         CGWarpMouseCursorPosition(screenCenter ?? .zero)
         isMouseCaptured = true
-        NSCursor.hide()
+        NSCursor.tryHide()
         CGSSetGlobalHotKeyOperatingMode(CGSMainConnectionID(), .disable)
     }
     
     func releaseMouse() {
+        logger.trace("release cursor")
         CGAssociateMouseAndMouseCursorPosition(1)
         isMouseCaptured = false
-        NSCursor.unhide()
+        if !isMouseInWindow {
+            NSCursor.tryUnhide()
+        }
         CGSSetGlobalHotKeyOperatingMode(CGSMainConnectionID(), .enable)
     }
 }
 
+extension VMMetalView: NSAccessibilityGroup {
+    override func accessibilityRole() -> NSAccessibility.Role? {
+        .group
+    }
+    
+    override func accessibilityRoleDescription() -> String? {
+        NSLocalizedString("Capture Input", comment: "VMMetalView")
+    }
+    
+    override func accessibilityLabel() -> String? {
+        NSLocalizedString("Virtual Machine", comment: "VMMetalView")
+    }
+    
+    override func accessibilityHelp() -> String? {
+        NSLocalizedString("To capture input or to release the capture, press Command and Option at the same time.", comment: "VMMetalView")
+    }
+    
+    override func isAccessibilityElement() -> Bool {
+        true
+    }
+    
+    override func isAccessibilityEnabled() -> Bool {
+        true
+    }
+}
+
+private extension NSCursor {
+    private static var isCursorHidden: Bool = false
+    
+    static func tryHide() {
+        if !NSCursor.isCursorHidden {
+            NSCursor.hide()
+            NSCursor.isCursorHidden = true
+        }
+    }
+    
+    static func tryUnhide() {
+        if NSCursor.isCursorHidden {
+            NSCursor.unhide()
+            NSCursor.isCursorHidden = false
+        }
+    }
+}
+
+private extension NSEvent.ModifierFlags {
+    var containsSpecialKeys: Bool {
+        !self.isDisjoint(with: [.capsLock, .command, .control, .function, .option, .shift])
+    }
+}
+
 private extension Int {
     func inputButtons() -> CSInputButton {
         var pressed = CSInputButton()
@@ -360,3 +347,37 @@ private extension Int {
         return pressed
     }
 }
+
+private extension NSEvent.ModifierFlags {
+    static var leftCommand: NSEvent.ModifierFlags {
+        NSEvent.ModifierFlags(rawValue: 0x8)
+    }
+    
+    static var rightCommand: NSEvent.ModifierFlags {
+        NSEvent.ModifierFlags(rawValue: 0x10)
+    }
+    
+    static var leftControl: NSEvent.ModifierFlags {
+        NSEvent.ModifierFlags(rawValue: 0x1)
+    }
+    
+    static var rightControl: NSEvent.ModifierFlags {
+        NSEvent.ModifierFlags(rawValue: 0x2000)
+    }
+    
+    static var leftOption: NSEvent.ModifierFlags {
+        NSEvent.ModifierFlags(rawValue: 0x20)
+    }
+    
+    static var rightOption: NSEvent.ModifierFlags {
+        NSEvent.ModifierFlags(rawValue: 0x40)
+    }
+    
+    static var leftShift: NSEvent.ModifierFlags {
+        NSEvent.ModifierFlags(rawValue: 0x2)
+    }
+    
+    static var rightShift: NSEvent.ModifierFlags {
+        NSEvent.ModifierFlags(rawValue: 0x4)
+    }
+}

+ 5 - 3
Platform/macOS/Display/VMMetalViewInputDelegate.swift

@@ -15,12 +15,14 @@
 //
 
 protocol VMMetalViewInputDelegate: class {
+    var shouldUseCmdOptForCapture: Bool { get }
     func mouseMove(absolutePoint: CGPoint, button: CSInputButton)
     func mouseMove(relativePoint: CGPoint, button: CSInputButton)
     func mouseDown(button: CSInputButton)
     func mouseUp(button: CSInputButton)
     func mouseScroll(dy: CGFloat, button: CSInputButton)
-    func keyDown(keyCode: Int)
-    func keyUp(keyCode: Int)
-    func requestReleaseCapture()
+    func keyDown(scanCode: Int)
+    func keyUp(scanCode: Int)
+    func captureMouse()
+    func releaseMouse()
 }

+ 13 - 0
Platform/macOS/Info.plist

@@ -37,6 +37,19 @@
 	<string>$(MARKETING_VERSION)</string>
 	<key>CFBundleVersion</key>
 	<string>$(CURRENT_PROJECT_VERSION)</string>
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleTypeRole</key>
+			<string>Editor</string>
+			<key>CFBundleURLName</key>
+			<string>com.utmapp.UTM</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>UTM</string>
+			</array>
+		</dict>
+	</array>
 	<key>ITSAppUsesNonExemptEncryption</key>
 	<false/>
 	<key>LSApplicationCategoryType</key>

+ 361 - 0
Platform/macOS/KeyCodeMap.swift

@@ -0,0 +1,361 @@
+//
+// 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 Foundation
+import Carbon.HIToolbox
+
+/// Based on https://stackoverflow.com/a/64344453/4236245
+/// Translated to Swift by conath
+class KeyCodeMap {
+    private static var keyMapDict: Dictionary<String, Dictionary<String, Int>>!
+    private static var modFlagDict: Dictionary<String, UInt>!
+    private static var modFlags: [UInt]!
+    
+    /// Creates the internal key map if needed. Must be called on the main queue!
+    static func createKeyMapIfNeeded() {
+        if keyMapDict == nil {
+            keyMapDict = makeKeyMap()
+        }
+    }
+    
+    static func characterToKeyCode(character: Character) -> Dictionary<String, Int>? {
+        createKeyMapIfNeeded()
+        
+        /*
+         The returned dictionary contains entries for the virtual key code and boolean flags
+         for modifier keys used for the character.
+         */
+        if let keyCodeDict = keyMapDict[String(character)] {
+            return keyCodeDict
+        } else {
+            return tryHandleSpecialChar(character)
+        }
+    }
+    
+    private static func makeKeyMap() -> Dictionary<String, Dictionary<String, Int>> {
+        var modifiers: UInt = 0
+        
+        // create dictionary of modifier names and keys.
+        if (modFlagDict == nil) {
+            modFlagDict = ["option":    NSEvent.ModifierFlags.option.rawValue,
+                           "shift":     NSEvent.ModifierFlags.shift.rawValue,
+                           "function":  NSEvent.ModifierFlags.function.rawValue,
+                           "control":   NSEvent.ModifierFlags.control.rawValue,
+                           "command":   NSEvent.ModifierFlags.command.rawValue]
+            modFlags = Array(modFlagDict.values)
+        }
+        var keyMapDict = Dictionary<String, Dictionary<String, Int>>()
+        
+        // run through 128 base key codes to see what they produce
+        for keyCode: UInt16 in 0..<128 {
+            // create dummy NSEvent from a CGEvent for a keypress
+            let coreEvent = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)!
+            let keyEvent = NSEvent(cgEvent: coreEvent)!
+            
+            if (keyEvent.type == .keyDown) {
+                // this repeat/while loop through every permutation of modifier keys for a given key code
+                repeat {
+                    var subDict = Dictionary<String, Int>()
+                    // cerate dictionary containing current modifier keys and virtual key code
+                    for key: String in modFlagDict.keys {
+                        let modKeyIsUsed = ((modFlagDict[key]! & modifiers) != 0)
+                        subDict[key] = NSNumber(booleanLiteral: modKeyIsUsed).intValue
+                    }
+                    subDict["virtKeyCode"] = NSNumber(value: keyCode).intValue
+                    
+                    // manipulate the NSEvent to get character produce by virtual key code and modifiers
+                    var character: String
+                    if modifiers == 0 {
+                        character = keyEvent.characters!
+                    } else {
+                        character = keyEvent.characters(byApplyingModifiers: NSEvent.ModifierFlags(rawValue: modifiers))!
+                    }
+                    
+                    // add sub-dictionary to main dictionary using character as key
+                    if keyMapDict[character] == nil {
+                        keyMapDict[character] = subDict
+                    }
+                    
+                    // permutate the modifiers
+                    modifiers = permutatateMods(modFlags: modFlags)
+                } while (modifiers != 0)
+            }
+        }
+        
+        return keyMapDict
+    }
+    
+    private static let idxSet = NSMutableIndexSet()
+    
+    private static func permutatateMods(modFlags: [UInt]) -> UInt {
+        var modifiers: UInt = 0
+        var idx: Int = 0
+        
+        /*
+         Starting at 0, if the index exists, remove it and move up; if the index doesn't exist, add it. Will
+         cycle through a standard binary progression. Indexes are then applied to the passed array, and the
+         selected elements are 'OR'ed together
+         */
+        var done = false
+        while !done {
+            if idxSet.contains([idx]) {
+                idxSet.remove([idx])
+                idx += 1
+                continue;
+            }
+            if idx < modFlags.count {
+                idxSet.add([idx])
+            } else {
+                idxSet.removeAllIndexes()
+            }
+            done = true
+        }
+        
+        let modArray = (modFlags as NSArray).objects(at: idxSet as IndexSet) as NSArray
+        
+        for modObj in modArray {
+            modifiers |= (modObj as! NSNumber).uintValue
+        }
+        
+        return modifiers
+    }
+    
+    /// Keyboard scan code for key down and up (which is usually `down + 0x80`)
+    struct ScanCodes {
+        let down: UInt16
+        let up: UInt8
+        
+        /// Construct a `ScanCodes` from a tuple of `Int`s
+        static func t(_ tuple: (down: UInt16, up: UInt8)) -> ScanCodes {
+            return ScanCodes(down: tuple.down, up: tuple.up)
+        }
+    }
+    
+    // Key Scan Codes mapping from https://www.cs.yale.edu/flint/cs422/doc/art-of-asm/pdf/CH20.PDF
+    // Page 1154, Table 72: PC Keyboard Scan Codes (in hex)
+    /// Converts macOS key code to IBM scan code for key up and down
+    /// The "up" scan codes are currently unused in UTM due to SPICE:
+    /// we instead send keyUp with the "down" scan code.
+    /// (See also `CSInput.sendKey:type:code`)
+    static let keyCodeToScanCodes: [Int:ScanCodes] = [
+        kVK_Escape:             .t((down: 0x01, up: 0x81)),
+        kVK_ANSI_1:             .t((down: 0x02, up: 0x82)),
+        kVK_ANSI_2:             .t((down: 0x03, up: 0x83)),
+        kVK_ANSI_3:             .t((down: 0x04, up: 0x84)),
+        kVK_ANSI_4:             .t((down: 0x05, up: 0x85)),
+        kVK_ANSI_5:             .t((down: 0x06, up: 0x86)),
+        kVK_ANSI_6:             .t((down: 0x07, up: 0x87)),
+        kVK_ANSI_7:             .t((down: 0x08, up: 0x88)),
+        kVK_ANSI_8:             .t((down: 0x09, up: 0x89)),
+        kVK_ANSI_9:             .t((down: 0x0a, up: 0x8a)),
+        kVK_ANSI_0:             .t((down: 0x0b, up: 0x8b)),
+        kVK_ANSI_Minus:         .t((down: 0x0c, up: 0x8c)),
+        kVK_ANSI_Equal:         .t((down: 0x0d, up: 0x8d)),
+        kVK_Delete:             .t((down: 0x0e, up: 0x8e)), /// IBM name is `backspace`
+        kVK_Tab:                .t((down: 0x0f, up: 0x8f)),
+        kVK_ANSI_Q:             .t((down: 0x10, up: 0x90)),
+        kVK_ANSI_W:             .t((down: 0x11, up: 0x91)),
+        kVK_ANSI_E:             .t((down: 0x12, up: 0x92)),
+        kVK_ANSI_R:             .t((down: 0x13, up: 0x93)),
+        kVK_ANSI_T:             .t((down: 0x14, up: 0x94)),
+        kVK_ANSI_Y:             .t((down: 0x15, up: 0x95)),
+        kVK_ANSI_U:             .t((down: 0x16, up: 0x96)),
+        kVK_ANSI_I:             .t((down: 0x17, up: 0x97)),
+        kVK_ANSI_O:             .t((down: 0x18, up: 0x98)),
+        kVK_ANSI_P:             .t((down: 0x19, up: 0x99)),
+        kVK_ANSI_LeftBracket:   .t((down: 0x1a, up: 0x9a)),
+        kVK_ANSI_RightBracket:  .t((down: 0x1b, up: 0x9b)),
+        kVK_Return:             .t((down: 0x1c, up: 0x9c)), /// IBM name is `enter`
+        kVK_Control:            .t((down: 0x1d, up: 0x9d)),
+        kVK_ANSI_A:             .t((down: 0x1e, up: 0x9e)),
+        kVK_ANSI_S:             .t((down: 0x1f, up: 0x9f)),
+        kVK_ANSI_D:             .t((down: 0x20, up: 0xa0)),
+        kVK_ANSI_F:             .t((down: 0x21, up: 0xa1)),
+        kVK_ANSI_G:             .t((down: 0x22, up: 0xa2)),
+        kVK_ANSI_H:             .t((down: 0x23, up: 0xa3)),
+        kVK_ANSI_J:             .t((down: 0x24, up: 0xa4)),
+        kVK_ANSI_K:             .t((down: 0x25, up: 0xa5)),
+        kVK_ANSI_L:             .t((down: 0x26, up: 0xa6)),
+        kVK_ANSI_Semicolon:     .t((down: 0x27, up: 0xa7)),
+        kVK_ANSI_Quote:         .t((down: 0x28, up: 0xa8)),
+        kVK_ANSI_Grave:         .t((down: 0x29, up: 0xa9)),
+        kVK_Shift:              .t((down: 0x2a, up: 0xaa)),
+        kVK_ANSI_Backslash:     .t((down: 0x2b, up: 0xab)),
+        kVK_ANSI_Z:             .t((down: 0x2c, up: 0xac)),
+        kVK_ANSI_X:             .t((down: 0x2d, up: 0xad)),
+        kVK_ANSI_C:             .t((down: 0x2e, up: 0xae)),
+        kVK_ANSI_V:             .t((down: 0x2f, up: 0xaf)),
+        kVK_ANSI_B:             .t((down: 0x30, up: 0xb0)),
+        kVK_ANSI_N:             .t((down: 0x31, up: 0xb1)),
+        kVK_ANSI_M:             .t((down: 0x32, up: 0xb2)),
+        kVK_ANSI_Comma:         .t((down: 0x33, up: 0xb3)),
+        kVK_ANSI_Period:        .t((down: 0x34, up: 0xb4)),
+        kVK_ANSI_Slash:         .t((down: 0x35, up: 0xb5)),
+        kVK_RightShift:         .t((down: 0x36, up: 0xb6)),
+        // Print screen not available in Carbon
+        kVK_Option:             .t((down: 0x38, up: 0xb8)), /// IBM name is `alt`
+        kVK_Space:              .t((down: 0x39, up: 0xb9)),
+        kVK_CapsLock:           .t((down: 0x3a, up: 0xba)),
+        kVK_F1:                 .t((down: 0x3b, up: 0xbb)),
+        kVK_F2:                 .t((down: 0x3c, up: 0xbc)),
+        kVK_F3:                 .t((down: 0x3d, up: 0xbd)),
+        kVK_F4:                 .t((down: 0x3e, up: 0xbe)),
+        kVK_F5:                 .t((down: 0x3f, up: 0xbf)),
+        kVK_F6:                 .t((down: 0x40, up: 0xc0)),
+        kVK_F7:                 .t((down: 0x41, up: 0xc1)),
+        kVK_F8:                 .t((down: 0x42, up: 0xc2)),
+        kVK_F9:                 .t((down: 0x43, up: 0xc3)),
+        kVK_F10:                .t((down: 0x44, up: 0xc4)),
+        // Numlock not available in Carbon
+        // Scroll lock not available in Carbon
+        // Number pad Home, up, pgUp not available in Carbon
+        kVK_ANSI_KeypadMinus:   .t((down: 0x4a, up: 0xca)),
+        // Number pad left, center, right not available in Carbon
+        kVK_ANSI_KeypadPlus:    .t((down: 0x4e, up: 0xce)),
+        // Number pad end, down, pgDown, insert not available in Carbon
+        kVK_ANSI_KeypadClear:   .t((down: 0x45, up: 0xC5)), /// in IBM this is num lock, so we send that
+        kVK_ANSI_KeypadDivide:  .t((down: 0xe035, up: 0xb5)),
+        kVK_ANSI_KeypadEnter:   .t((down: 0xe01c, up: 0x9c)),
+        kVK_ANSI_Keypad0:       .t((down: 0x52, up: 0xD2)),
+        kVK_ANSI_Keypad1:       .t((down: 0x4F, up: 0xCF)),
+        kVK_ANSI_Keypad2:       .t((down: 0x50, up: 0xD0)),
+        kVK_ANSI_Keypad3:       .t((down: 0x51, up: 0xD1)),
+        kVK_ANSI_Keypad4:       .t((down: 0x4B, up: 0xCB)),
+        kVK_ANSI_Keypad5:       .t((down: 0x4C, up: 0xCC)),
+        kVK_ANSI_Keypad6:       .t((down: 0x4D, up: 0xCD)),
+        kVK_ANSI_Keypad7:       .t((down: 0x47, up: 0xC7)),
+        kVK_ANSI_Keypad8:       .t((down: 0x48, up: 0xC8)),
+        kVK_ANSI_Keypad9:       .t((down: 0x49, up: 0xC9)),
+        kVK_ANSI_KeypadDecimal: .t((down: 0x53, up: 0xD3)),
+        kVK_ANSI_KeypadEquals:  .t((down: 0x00, up: 0x00)), /// Not found on IBM
+        kVK_ANSI_KeypadMultiply:.t((down: 0x37, up: 0xB7)),
+        kVK_F11:                .t((down: 0x57, up: 0xd7)),
+        kVK_F12:                .t((down: 0x58, up: 0xd8)),
+        // Insert not available in Carbon
+        kVK_ForwardDelete:      .t((down: 0xe053, up: 0xd3)), /// IBM name is `delete`
+        kVK_Home:               .t((down: 0xe047, up: 0xc7)),
+        kVK_End:                .t((down: 0xe04f, up: 0xcf)),
+        kVK_PageUp:             .t((down: 0xe049, up: 0xc9)),
+        kVK_PageDown:           .t((down: 0xe051, up: 0xd1)),
+        kVK_LeftArrow:          .t((down: 0xe04b, up: 0xcb)),
+        kVK_RightArrow:         .t((down: 0xe04d, up: 0xcd)),
+        kVK_UpArrow:            .t((down: 0xe048, up: 0xc8)),
+        kVK_DownArrow:          .t((down: 0xe050, up: 0xd0)),
+        kVK_RightOption:        .t((down: 0xe038, up: 0xb8)), /// IBM name is `right alt`
+        kVK_RightControl:       .t((down: 0xe01d, up: 0x9d)),
+        // Pause not available in Carbon
+        /* Additional non-IBM keys */
+        kVK_Command:            .t((down: 0xe05b, up: 0xdb)),
+        kVK_RightCommand:       .t((down: 0xe05c, up: 0xdc)),
+        kVK_ISO_Section:        .t((down: 0x56, up: 0xD6)),
+        kVK_VolumeUp:           .t((down: 0xe030, up: 0xb0)),
+        kVK_VolumeDown:         .t((down: 0xe02e, up: 0xae)),
+        kVK_Mute:               .t((down: 0xE020, up: 0xa0)),
+        kVK_F13:                .t((down: 0x64, up: 0xe4)),
+        kVK_F14:                .t((down: 0x65, up: 0xe5)),
+        kVK_F15:                .t((down: 0x66, up: 0xe6)),
+        kVK_F16:                .t((down: 0x67, up: 0xe7)),
+        kVK_F17:                .t((down: 0x68, up: 0xe8)),
+        kVK_F18:                .t((down: 0x69, up: 0xe9)),
+        kVK_F19:                .t((down: 0x6a, up: 0xea)),
+        kVK_F20:                .t((down: 0x6b, up: 0xeb)),
+        kVK_JIS_Yen:            .t((down: 0x7d, up: 0xfd)),
+        kVK_JIS_Underscore:     .t((down: 0x73, up: 0xf3)),
+        kVK_JIS_KeypadComma:    .t((down: 0x5c, up: 0xdc)),
+        kVK_JIS_Eisu:           .t((down: 0x73, up: 0xf3)),
+        kVK_JIS_Kana:           .t((down: 0x70, up: 0xf0)),
+        /* The Function and help keys doesn't have a scan code */
+        kVK_Function:           .t((down: 0x00, up: 0x00)),
+        kVK_Help:               .t((down: 0x00, up: 0x00))
+    ]
+}
+
+extension KeyCodeMap {
+    /// Support ASCII control characters
+    /// https://jkorpela.fi/chars/c0.html
+    fileprivate static func tryHandleSpecialChar(_ character: Character) -> Dictionary<String, Int>? {
+        if let ascii = character.asciiValue {
+            var virtKeyCode: Int?
+            if ascii <= 31 {
+                /// Control held
+                switch ascii {
+                case 1: virtKeyCode = kVK_ANSI_A
+                case 2: virtKeyCode = kVK_ANSI_B
+                case 3: virtKeyCode = kVK_ANSI_C
+                case 4: virtKeyCode = kVK_ANSI_D
+                case 5: virtKeyCode = kVK_ANSI_E
+                case 6: virtKeyCode = kVK_ANSI_F
+                case 7: virtKeyCode = kVK_ANSI_G
+                case 8: virtKeyCode = kVK_ANSI_H
+                case 9: virtKeyCode = kVK_ANSI_I
+                case 10: virtKeyCode = kVK_ANSI_J
+                case 11: virtKeyCode = kVK_ANSI_K
+                case 12: virtKeyCode = kVK_ANSI_L
+                case 13: virtKeyCode = kVK_ANSI_M
+                case 14: virtKeyCode = kVK_ANSI_N
+                case 15: virtKeyCode = kVK_ANSI_O
+                case 16: virtKeyCode = kVK_ANSI_P
+                case 17: virtKeyCode = kVK_ANSI_Q
+                case 18: virtKeyCode = kVK_ANSI_R
+                case 19: virtKeyCode = kVK_ANSI_S
+                case 20: virtKeyCode = kVK_ANSI_T
+                case 21: virtKeyCode = kVK_ANSI_U
+                case 22: virtKeyCode = kVK_ANSI_V
+                case 23: virtKeyCode = kVK_ANSI_W
+                case 24: virtKeyCode = kVK_ANSI_Y
+                case 25: virtKeyCode = kVK_ANSI_X
+                case 26: virtKeyCode = kVK_ANSI_Z
+                case 27: virtKeyCode = kVK_ANSI_LeftBracket
+                case 28: virtKeyCode = kVK_ANSI_Backslash
+                case 29: virtKeyCode = kVK_ANSI_RightBracket
+                case 30:
+                    if var dict = characterToKeyCode(character: "^") {
+                        dict["control"] = 1
+                        return dict
+                    } else { return nil }
+                case 31:
+                    if var dict = characterToKeyCode(character: "_") {
+                        dict["control"] = 1
+                        return dict
+                    } else { return nil }
+                default:
+                    virtKeyCode = nil
+                }
+                if let virtKeyCode = virtKeyCode {
+                    return [
+                        "option": 0,
+                        "shift": 0,
+                        "function": 0,
+                        "control": 1,
+                        "command": 0,
+                        "virtKeyCode": virtKeyCode
+                    ]
+                }
+            } else if ascii == 127 {
+                /// Delete key
+                return [
+                    "option": 0,
+                    "shift": 0,
+                    "function": 0,
+                    "control": 1,
+                    "command": 0,
+                    "virtKeyCode": kVK_Delete
+                ]
+            }
+        }
+        return nil
+    }
+}

+ 105 - 0
Platform/macOS/SavePanel.swift

@@ -0,0 +1,105 @@
+//
+// 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
+import UniformTypeIdentifiers
+
+@available(macOS 11, *)
+struct SavePanel: NSViewRepresentable {
+    @EnvironmentObject private var data: UTMData
+    @Binding var isPresented: Bool
+    var shareItem: VMShareItemModifier.ShareItem?
+
+    func makeNSView(context: Context) -> some NSView {
+        return NSView()
+    }
+
+    func updateNSView(_ nsView: NSViewType, context: Context) {
+        if isPresented {
+            guard let shareItem = shareItem else {
+                return
+            }
+            
+            guard let window = nsView.window else {
+                return
+            }
+            
+            // Initializing the SavePanel and setting it's properties
+            let savePanel = NSSavePanel()
+            if let downloadsUrl = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first {
+                savePanel.directoryURL = downloadsUrl
+            }
+            
+            switch shareItem {
+            case .debugLog:
+                savePanel.title = "Select where to save debug log:"
+                savePanel.nameFieldStringValue = "debug"
+                savePanel.allowedContentTypes = [.appleLog]
+            case let .utmVm(sourceUrl):
+                savePanel.title = "Select where to save UTM Virtual Machine:"
+                savePanel.nameFieldStringValue = sourceUrl.deletingPathExtension().lastPathComponent
+                savePanel.allowedContentTypes = [.UTM]
+            case .qemuCommand:
+                savePanel.title = "Select where to export QEMU command:"
+                savePanel.nameFieldStringValue = "command"
+                savePanel.allowedContentTypes = [.plainText]
+            }
+            
+            // Calling savePanel.begin with the appropriate completion handlers
+            switch shareItem {
+            case .debugLog(let sourceUrl), .utmVm(let sourceUrl):
+                savePanel.beginSheetModal(for: window) { result in
+                    if result == .OK {
+                        if let destUrl = savePanel.url {
+                            do {
+                                let fileManager = FileManager.default
+                                
+                                // All this mess is because FileManager.replaceItemAt deletes the source item
+                                let tempUrl = fileManager.temporaryDirectory.appendingPathComponent(sourceUrl.lastPathComponent)
+                                if fileManager.fileExists(atPath: tempUrl.path) {
+                                    try fileManager.removeItem(at: tempUrl)
+                                }
+                                try fileManager.copyItem(at: sourceUrl, to: tempUrl)
+                                
+                                _ = try fileManager.replaceItemAt(destUrl, withItemAt: tempUrl)
+                            } catch {
+                                DispatchQueue.main.async {
+                                    data.alertMessage = AlertMessage(error.localizedDescription)
+                                }
+                            }
+                        }
+                    }
+                }
+            case .qemuCommand(let command):
+                savePanel.beginSheetModal(for: window) { result in
+                    if result == .OK {
+                        if let destUrl = savePanel.url {
+                            do {
+                                try command.write(to: destUrl, atomically: true, encoding: .utf8)
+                            } catch {
+                                DispatchQueue.main.async {
+                                    data.alertMessage = AlertMessage(error.localizedDescription)
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+
+            isPresented = false
+        }
+    }
+}

+ 5 - 0
Platform/macOS/SettingsView.swift

@@ -24,6 +24,7 @@ struct SettingsView: View {
     @AppStorage("CtrlRightClick") var isCtrlRightClick = false
     @AppStorage("NoUsbPrompt") var isNoUsbPrompt = false
     @AppStorage("UseOnlyPcores") var isUseOnlyPcores = false
+    @AppStorage("AlternativeCaptureKey") var isAlternativeCaptureKey = false
     
     var body: some View {
         Form {
@@ -47,6 +48,9 @@ struct SettingsView: View {
                 Toggle(isOn: $isCtrlRightClick, label: {
                     Text("Hold Control (⌃) for right click")
                 })
+                Toggle(isOn: $isAlternativeCaptureKey, label: {
+                    Text("Use Command+Opt (⌘+⌥) for input capture/release")
+                })
             }
             Section(header: Text("USB")) {
                 Toggle(isOn: $isNoUsbPrompt, label: {
@@ -66,6 +70,7 @@ extension UserDefaults {
     @objc dynamic var CtrlRightClick: Bool { false }
     @objc dynamic var NoUsbPrompt: Bool { false }
     @objc dynamic var UseOnlyPcores: Bool { false }
+    @objc dynamic var AlternativeCaptureKey: Bool { false }
 }
 
 @available(macOS 11, *)

+ 0 - 67
Platform/macOS/SharingServicePicker.swift

@@ -1,67 +0,0 @@
-//
-// Copyright © 2020 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
-
-// https://stackoverflow.com/a/60955909/13914748
-@available(macOS 11, *)
-struct SharingsPicker: NSViewRepresentable {
-    @Binding var isPresented: Bool
-    var sharingItems: [Any] = []
-
-    func makeNSView(context: Context) -> NSView {
-        let view = NSView()
-        return view
-    }
-
-    func updateNSView(_ nsView: NSView, context: Context) {
-        if isPresented {
-            if let _ = nsView.window {
-                let picker = NSSharingServicePicker(items: sharingItems)
-                picker.delegate = context.coordinator
-
-                // !! MUST BE CALLED IN ASYNC, otherwise blocks update
-                DispatchQueue.main.async {
-                    picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
-                }
-            } else {
-                DispatchQueue.main.async {
-                    isPresented = false
-                }
-            }
-        }
-    }
-
-    func makeCoordinator() -> Coordinator {
-        Coordinator(owner: self)
-    }
-
-    class Coordinator: NSObject, NSSharingServicePickerDelegate {
-        let owner: SharingsPicker
-
-        init(owner: SharingsPicker) {
-            self.owner = owner
-        }
-
-        func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService?) {
-
-            // do here whatever more needed here with selected service
-
-            sharingServicePicker.delegate = nil   // << cleanup
-            self.owner.isPresented = false        // << dismiss
-        }
-    }
-}

+ 106 - 0
Platform/macOS/UTMDataExtension.swift

@@ -15,6 +15,7 @@
 //
 
 import Foundation
+import Carbon.HIToolbox
 
 @available(macOS 11, *)
 extension UTMData {
@@ -51,4 +52,109 @@ extension UTMData {
             }
         }
     }
+    
+    func trySendTextSpice(vm: UTMQemuVirtualMachine, text: String) {
+        guard text.count > 0 else { return }
+        if let vc = vmWindows[vm] as? VMDisplayMetalWindowController {
+            KeyCodeMap.createKeyMapIfNeeded()
+            
+            func sleep() {
+                Thread.sleep(forTimeInterval: 0.05)
+            }
+            func keyDown(keyCode: Int) {
+                if let scanCodes = KeyCodeMap.keyCodeToScanCodes[keyCode] {
+                    vc.keyDown(scanCode: Int(scanCodes.down))
+                    sleep()
+                }
+            }
+            func keyUp(keyCode: Int) {
+                /// Due to how Spice works we need to send keyUp for the .down scan code
+                /// instead of sending the key down for the scan code that indicates key up.
+                if let scanCodes = KeyCodeMap.keyCodeToScanCodes[keyCode] {
+                    vc.keyUp(scanCode: Int(scanCodes.down))
+                    sleep()
+                }
+            }
+            func press(keyCode: Int) {
+                keyDown(keyCode: keyCode)
+                keyUp(keyCode: keyCode)
+            }
+            
+            func simulateKeyPress(_ keyCodeDict: [String: Int]) {
+                /// Press modifier keys if necessary
+                let optionUsed = keyCodeDict["option"] == 1
+                if optionUsed {
+                    keyDown(keyCode: kVK_Option)
+                    sleep()
+                }
+                let shiftUsed = keyCodeDict["shift"] == 1
+                if shiftUsed {
+                    keyDown(keyCode: kVK_Shift)
+                    sleep()
+                }
+                let fnUsed = keyCodeDict["function"] == 1
+                if fnUsed {
+                    keyDown(keyCode: kVK_Function)
+                    sleep()
+                }
+                let ctrlUsed = keyCodeDict["control"] == 1
+                if ctrlUsed {
+                    keyDown(keyCode: kVK_Control)
+                    sleep()
+                }
+                let cmdUsed = keyCodeDict["command"] == 1
+                if cmdUsed {
+                    keyDown(keyCode: kVK_Command)
+                    sleep()
+                }
+                /// Press the key now
+                let keyCode = keyCodeDict["virtKeyCode"]!
+                press(keyCode: keyCode)
+                /// Release modifiers
+                if optionUsed {
+                    keyUp(keyCode: kVK_Option)
+                    sleep()
+                }
+                if shiftUsed {
+                    keyUp(keyCode: kVK_Shift)
+                    sleep()
+                }
+                if fnUsed {
+                    keyUp(keyCode: kVK_Function)
+                    sleep()
+                }
+                if ctrlUsed {
+                    keyUp(keyCode: kVK_Control)
+                    sleep()
+                }
+                if cmdUsed {
+                    keyUp(keyCode: kVK_Command)
+                    sleep()
+                }
+            }
+            DispatchQueue.global(qos: .userInitiated).async {
+                text.enumerated().forEach { stringItem in
+                    let char = stringItem.element
+                    /// drop unknown chars
+                    if let keyCodeDict = KeyCodeMap.characterToKeyCode(character: char) {
+                        simulateKeyPress(keyCodeDict)
+                    } else {
+                        logger.warning("SendText dropping unknown char: \(char)")
+                    }
+                }
+            }
+        }
+    }
+    
+    func tryClickAtPoint(vm: UTMQemuVirtualMachine, point: CGPoint, button: CSInputButton) {
+        if let vc = vmWindows[vm] as? VMDisplayMetalWindowController {
+            vc.mouseMove(absolutePoint: point, button: [])
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
+                vc.mouseDown(button: button)
+                DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
+                    vc.mouseUp(button: button)
+                }
+            }
+        }
+    }
 }

+ 2 - 2
Platform/macOS/VMConfigDrivesButtons.swift

@@ -48,7 +48,7 @@ struct VMConfigDrivesButtons<Config: ObservableObject & UTMConfigurable>: View {
             .onChange(of: newDrivePopover, perform: { showPopover in
                 if showPopover {
                     if let qemuConfig = config as? UTMQemuConfiguration {
-                    newQemuDrive.reset(forSystemTarget: qemuConfig.systemTarget, removable: false)
+                        newQemuDrive.reset(forSystemTarget: qemuConfig.systemTarget, architecture: qemuConfig.systemArchitecture, removable: false)
                     } else if let _ = config as? UTMAppleConfiguration {
                         newAppleDriveSize = 10240
                     }
@@ -57,7 +57,7 @@ struct VMConfigDrivesButtons<Config: ObservableObject & UTMConfigurable>: View {
             .popover(isPresented: $newDrivePopover, arrowEdge: .top) {
                 VStack {
                     if let qemuConfig = config as? UTMQemuConfiguration {
-                        VMConfigDriveCreateView(target: qemuConfig.systemTarget, driveImage: newQemuDrive)
+                        VMConfigDriveCreateView(target: qemuConfig.systemTarget, architecture: qemuConfig.systemArchitecture, driveImage: newQemuDrive)
                     } else if #available(macOS 12, *), let _ = config as? UTMAppleConfiguration {
                         VMConfigAppleDriveCreateView(driveSize: $newAppleDriveSize)
                     }

+ 6 - 0
Platform/macOS/zh-Hans.lproj/InfoPlist.strings

@@ -0,0 +1,6 @@
+/* Bundle name */
+"CFBundleName" = "UTM";
+
+/* (No Comment) */
+"UTM virtual machine" = "UTM虚拟机";
+

+ 168 - 74
Platform/zh-Hans.lproj/Localizable.strings

@@ -1,14 +1,20 @@
-/* VMConfigDriveCreateViewController */
-"A file already exists for this name, if you proceed, it will be replaced." = "已存在同名的项目,如果您继续操作,正在创建的项目将会替换原有的同名项目。";
-
 /* No comment provided by engineer. */
-"Do you want to duplicate this VM and all its data?" = "是否要复制该虚拟机及其所有数据?";
+"-" = "-";
+
+/* A removable drive that has no image file inserted. */
+"(empty)" = "(empty)";
 
 /* No comment provided by engineer. */
-"Do you want to delete this VM and all its data?" = "是否删除该虚拟机及其所有数据?";
+"0.0.0.0" = "0.0.0.0";
 
 /* No comment provided by engineer. */
-"Do you want to force stop this VM and lose all unsaved data?" = "是否要强制关闭该虚拟机并丢失所有未保存的数据?";
+"127.0.0.1" = "127.0.0.1";
+
+/* VMConfigDriveCreateViewController */
+"A file already exists for this name, if you proceed, it will be replaced." = "已存在同名的项目,如果您继续操作,正在创建的项目将会替换原有的同名项目";
+
+/* VMListViewController */
+"A VM already exists with this name." = "已存在使用此名称的 VM";
 
 /* No comment provided by engineer. */
 "Acceleration" = "加速";
@@ -62,10 +68,13 @@
 "BIOS" = "BIOS";
 
 /* No comment provided by engineer. */
-"Blinking Cursor" = "Blinking Cursor";
+"Blinking Cursor" = "闪烁光标";
+
+/* UTMConfiguration */
+"Bridged (Advanced)" = "桥接(高级)";
 
 /* No comment provided by engineer. */
-"Browse" = "浏览";
+"Bridged Interface" = "桥接接口";
 
 /* VMConfigSharingViewController */
 "Browse..." = "浏览";
@@ -79,6 +88,9 @@
 /* VMConfigDriveCreateViewController */
 "Cannot create directory for disk image." = "无法为磁盘映像创建目录。";
 
+/* VMDisplayTerminalWindowController */
+"Cannot find bundle resources." = "找不到绑定的资源";
+
 /* VMListViewController */
 "Cannot find VM." = "未能找到VM";
 
@@ -91,27 +103,30 @@
 /* Configuration boot device */
 "CD/DVD" = "CD/DVD";
 
-/* UTMQemuConfiguration */
-"CD/DVD Image" = "CD/DVD 镜像";
+/* UTMConfiguration */
+"CD/DVD (ISO) Image" = "CD/DVD (ISO) 镜像";
 
 /* VMRemovableDrivesViewController */
 "Change" = "变化";
 
-/* No comment provided by engineer. */
-"Clear" = "清除";
-
 /* No comment provided by engineer. */
 "Clipboard Sharing" = "剪贴板共享";
 
 /* Clone context menu */
 "Clone" = "复制";
 
+/* VMDisplayMetalWindowController */
+"Closing this window will kill the VM." = "关闭此窗口将终止 VM";
+
 /* No comment provided by engineer. */
 "Command to send when resizing the console. Placeholder $COLS is the number of columns and $ROWS is the number of rows." = "调整控制台大小时要发送的命令。占位符 $COLS 是列数, $ROWS 是行数。";
 
 /* UTMVirtualMachine */
 "Config format incorrect." = "配置格式不正确。";
 
+/* VMDisplayMetalWindowController */
+"Confirm" = "确认";
+
 /* No comment provided by engineer. */
 "Confirm Delete" = "确认删除";
 
@@ -122,22 +137,16 @@
 "Console Only" = "仅限控制台";
 
 /* No comment provided by engineer. */
-"CPU Count" = "CPU核心数";
-
-/* No comment provided by engineer. */
-"CPU Cores" = "CPU核心数";
+"Cores" = "核心数量";
 
 /* No comment provided by engineer. */
-"Boot Options" = "启动选项";
+"CPU" = "CPU";
 
 /* No comment provided by engineer. */
-"These settings are unavailable in console display mode" = "这些设置在控制台视图下不可用";
-
-/* No comment provided by engineer. */
-"Default" = "默认";
+"CPU Cores" = "CPU核心数";
 
 /* No comment provided by engineer. */
-"Blinking Cursor" = "闪烁光标";
+"CPU Flags" = "CPU Flags";
 
 /* Create button */
 "Create" = "创建";
@@ -151,61 +160,47 @@
 /* No comment provided by engineer. */
 "Debug Logging" = "调试日志记录";
 
-/* No comment provided by engineer. */
-"Default" = "默认";
-
 /* Delete button
    Delete context menu
    VMConfigDirectoryPickerViewController */
 "Delete" = "删除";
 
-/* No comment provided by engineer. */
-"Do you want to duplicate this VM and all its data?" = "是否要复制该虚拟机及其所有数据?";
-
-/* No comment provided by engineer. */
-"Do you want to delete this VM and all its data?" = "是否删除该虚拟机及其所有数据?";
-
-/* No comment provided by engineer. */
-"Do you want to force stop this VM and lose all unsaved data?" = "是否要关闭该虚拟机并丢失所有未保存的数据?";
-
 /* VMConfigDrivesViewController */
 "Delete Data" = "删除数据";
 
 /* Delete VM overlay */
 "Deleting %@..." = "正在删除 %@...";
 
-/* No comment provided by engineer. */
-"DHCP Domain Name" = "DHCP域名";
-
-/* No comment provided by engineer. */
-"DHCP Host" = "DHCP服务器";
-
-/* No comment provided by engineer. */
-"DHCP Start" = "起始DHCP";
-
 /* VMConfigDirectoryPickerViewController */
 "Directory Name" = "目录名称";
 
 /* VMDisplayTerminalViewController */
 "Disable this bar in Settings -> General -> Keyboards -> Shortcuts" = "在 设置 -> 通用 -> 键盘 -> 快捷键 中禁用此栏";
 
-/* VMConfigDriveCreateViewController */
+/* UTMData
+   VMConfigDriveCreateViewController */
 "Disk creation failed." = "磁盘镜像创建失败";
 
 /* UTMQemuConfiguration */
 "Disk Image" = "磁盘镜像";
 
+/* VMDisplayMetalWindowController */
+"Do Not Show Again" = "不再显示";
+
 /* No comment provided by engineer. */
-"DNS Search Domains" = "DNS搜索域";
+"Do not show prompt when USB device is plugged in" = "插入 USB 设备时不显示提示";
+
+/* VMConfigDrivesViewController */
+"Do you want to also delete the disk image data? If yes, the data will be lost. Otherwise, you can create a new drive with the existing data." = "您是否还要删除磁盘映像数据?如果是,数据将丢失。否则,您可以使用现有数据创建一个新驱动器。";
 
 /* No comment provided by engineer. */
-"DNS Server" = "DNS服务器(IPv4)";
+"Do you want to delete this VM and all its data?" = "是否删除该虚拟机及其所有数据?";
 
 /* No comment provided by engineer. */
-"DNS Server (IPv6)" = "DNS服务器(IPv6)";
+"Do you want to duplicate this VM and all its data?" = "是否要复制该虚拟机及其所有数据?";
 
-/* VMConfigDrivesViewController */
-"Do you want to also delete the disk image data? If yes, the data will be lost. Otherwise, you can create a new drive with the existing data." = "您是否还要删除磁盘映像数据?如果是,数据将丢失。否则,您可以使用现有数据创建一个新驱动器。";
+/* No comment provided by engineer. */
+"Do you want to force stop this VM and lose all unsaved data?" = "您是否想强制停止此VM并丢失所有未保存的数据?";
 
 /* VMConfigDirectoryPickerViewController
    VMConfigPortForwardingViewController */
@@ -223,9 +218,18 @@
 /* No comment provided by engineer. */
 "Emulated Audio Card" = "模拟声卡";
 
+/* No comment provided by engineer. */
+"Emulated Display Card" = "模拟显卡";
+
 /* No comment provided by engineer. */
 "Emulated Network Card" = "模拟网卡";
 
+/* UTMConfiguration */
+"Emulated VLAN" = "模拟VLAN";
+
+/* No comment provided by engineer. */
+"en0" = "en0";
+
 /* No comment provided by engineer. */
 "Enable Clipboard Sharing" = "剪贴板共享";
 
@@ -244,8 +248,14 @@
 /* VMConfigDriveCreateViewController */
 "Error renaming file" = "重命名失败";
 
+/* UTMVirtualMachine */
+"Error trying to restore removable drives: %@" = "尝试还原可移动驱动器时出错:%@";
+
+/* UTMVirtualMachine */
+"Error trying to start shared directory: %@" = "尝试启动共享目录时出错:%@";
+
 /* UTMVirtualMachine+Drives */
-"Failed create bookmark." = "创建书签失败。";
+"Failed create bookmark." = "创建书签失败";
 
 /* UTMVirtualMachine+Drives */
 "Failed to access drive image path." = "无法访问驱动器映像路径";
@@ -259,6 +269,9 @@
 /* UTMSpiceIO */
 "Failed to connect to SPICE server." = "无法连接到SPICE服务器";
 
+/* UTMDataExtension */
+"Failed to delete saved state." = "无法删除保存的状态";
+
 /* VMRemovableDrivesViewController */
 "Failed to get VM object." = "无法获取VM对象";
 
@@ -287,13 +300,13 @@
 "Force Multicore" = "强制多核";
 
 /* No comment provided by engineer. */
-"Full Graphics" = "完整图形";
+"Force slower emulation even when hypervisor is available" = "即使程序可用,也只能强制执行较慢的仿真";
 
 /* No comment provided by engineer. */
-"Gesture and Cursor Settings" = "更多图形和光标设置";
+"Full Graphics" = "完整图形";
 
 /* No comment provided by engineer. */
-"Go to Gallery" = "转到图库";
+"Gesture and Cursor Settings" = "更多图形和光标设置";
 
 /* No comment provided by engineer. */
 "Guest Address" = "客户机地址";
@@ -301,12 +314,6 @@
 /* VMConfigPortForwardingViewController */
 "Guest address (optional)" = "客户机地址(可选)";
 
-/* No comment provided by engineer. */
-"Guest Network" = "客户机网络";
-
-/* No comment provided by engineer. */
-"Guest Network (IPv6)" = "客户机网络(IPv6)";
-
 /* UTMQemuManager */
 "Guest panic" = "Guest panic";
 
@@ -322,14 +329,17 @@
 /* No comment provided by engineer. */
 "Hardware" = "硬件";
 
+/* No comment provided by engineer. */
+"Hide Unused Flags..." = "隐藏未使用的Flags...";
+
 /* VMDisplayViewController */
 "Hint: To show the toolbar again, use a three-finger swipe down on the screen." = "提示:要再次显示工具栏,请使用三指在屏幕从上向下滑动。";
 
 /* No comment provided by engineer. */
-"Host Address" = "主机地址";
+"Hold Control (⌃) for right click" = "按住 Control (⌃) 并进行右键单击";
 
 /* No comment provided by engineer. */
-"Host Address (IPv6)" = "主机地址(IPv6)";
+"Host Address" = "主机地址";
 
 /* VMConfigPortForwardingViewController */
 "Host address (optional)" = "主机地址(可选)";
@@ -349,9 +359,15 @@
 /* Import button */
 "Import" = "导入";
 
+/* No comment provided by engineer. */
+"Import Virtual Machine..." = "导入虚拟机...";
+
 /* Save VM overlay */
 "Importing %@..." = "导入 %@...";
 
+/* No comment provided by engineer. */
+"Input" = "输入";
+
 /* No comment provided by engineer. */
 "Interface" = "接口";
 
@@ -412,7 +428,10 @@
 /* No comment provided by engineer. */
 "Legacy (PS/2) Mode" = "输入(PS/2)模式";
 
-/* UTMQemuConfiguration */
+/* No comment provided by engineer. */
+"License" = "许可";
+
+/* UTMConfiguration */
 "Linear" = "线性";
 
 /* UTMQemuConfiguration */
@@ -430,6 +449,9 @@
 /* UTMQemuManager */
 "Manager being deallocated, killing pending RPC." = "正在释放管理器,正在终止挂起的RPC。";
 
+/* No comment provided by engineer. */
+"Maximum Shared USB Devices" = "最大的共享 USB 设备数量";
+
 /* No comment provided by engineer. */
 "MB" = "MB";
 
@@ -454,6 +476,9 @@
 /* UTMQemuConfiguration */
 "Nearest Neighbor" = "近邻取样";
 
+/* No comment provided by engineer. */
+"Network Mode" = "网络模式";
+
 /* No comment provided by engineer. */
 "New" = "新建";
 
@@ -461,7 +486,7 @@
 "New port forward" = "新建端口转发";
 
 /* No comment provided by engineer. */
-"New VM" = "新建VM";
+"New Virtual Machine" = "新建虚拟机";
 
 /* Clone VM name prompt message */
 "New VM name" = "输入新的虚拟机的名字";
@@ -489,11 +514,17 @@
 /* UTMData */
 "No log found!" = "找不到日志!";
 
+/* VMDisplayMetalWindowController */
+"No USB devices detected." = "未检测到 USB 设备";
+
 /* UTMDrive */
 "none" = "空";
 
+/* UTMConfiguration */
+"None" = "无";
+
 /* No comment provided by engineer. */
-"None" = "空";
+"Note: Boot order is as listed." = "注意:启动顺序如列表所列顺序";
 
 /* No comment provided by engineer. */
 "Note: select the path to share from the main screen." = "注意:请从主屏幕选取文件共享的路径。";
@@ -532,12 +563,22 @@
 /* VMDisplayWindowController */
 "Querying drives status..." = "正在查询驱动器状态...";
 
+/* VMDisplayMetalWindowController */
+"Querying USB devices..." = "正在查询 USB 设备...";
+
+/* VMDisplayMetalWindowController */
+"Quitting UTM will kill all running VMs." = "退出 UTM 将终止所有正在运行的 VM";
+
 /* No comment provided by engineer. */
 "Read Only" = "只读";
 
 /* No comment provided by engineer. */
 "Removable" = "可扩展";
 
+/* VMConfigDrivesView
+   VMConfigDrivesViewController */
+"Removable Drive" = "可移动驱动器";
+
 /* No comment provided by engineer. */
 "Requires SPICE guest agent tools to be installed." = "需要安装SPICE代理工具。";
 
@@ -559,9 +600,6 @@
 /* VMDisplayViewController */
 "Running low on memory! UTM might soon be killed by iOS. You can prevent this by decreasing the amount of memory and/or JIT cache assigned to this VM" = "内存不足! UTM可能很快会被iOS强制终止 您可以通过减少给该虚拟机分配的内存或JIT缓存来防止这种情况。";
 
-/* No comment provided by engineer. */
-"Running qemu-img more than once is unimplemented. Restart the app to create another disk." = "同时运行多个虚拟机的功能还未完成,请点左上角X重新启动UTM后再试。";
-
 /* No comment provided by engineer. */
 "Save" = "保存";
 
@@ -592,6 +630,9 @@
 /* No comment provided by engineer. */
 "Shared Directory" = "共享目录";
 
+/* UTMConfiguration */
+"Shared Network" = "共享网络";
+
 /* VMConfigSharingViewController */
 "Shared path has moved. Please re-choose." = "共享目录已被移动,请重新选择。";
 
@@ -601,15 +642,21 @@
 /* No comment provided by engineer. */
 "Show Advanced Settings" = "高级设置";
 
+/* No comment provided by engineer. */
+"Show All Flags..." = "显示所有Flags...";
+
 /* No comment provided by engineer. */
 "Size" = "大小";
 
 /* No comment provided by engineer. */
-"Start from Scratch" = "从头开始";
+"Stop" = "终止";
 
 /* No comment provided by engineer. */
 "Style" = "方式";
 
+/* No comment provided by engineer. */
+"Support" = "支持";
+
 /* No comment provided by engineer. */
 "System" = "系统";
 
@@ -625,17 +672,23 @@
 /* No comment provided by engineer. */
 "The Last Tab" = "最后一个选项卡";
 
+/* No comment provided by engineer. */
+"The selected architecture is unsupported in this version of UTM." = "此版本的 UTM 不支持所选架构";
+
 /* VMConfigSystemViewController */
 "The total memory usage is close to your device's limit. iOS will kill the VM if it consumes too much memory." = "此内存接近您的设备可用内存极限。消耗过大内存的虚拟机会被iOS系统强制终止。";
 
 /* No comment provided by engineer. */
 "Theme" = "主题";
 
+/* No comment provided by engineer. */
+"These settings are unavailable in console display mode." = "这些设置在控制台显示模式下不可用";
+
 /* VMDisplayWindowController */
 "This may corrupt the VM and any unsaved changes will be lost. To quit safely, shut down from the guest." = "这可能会损坏虚拟机,任何未保存的更改都将丢失。为了安全退出,请关闭来宾账户。";
 
-/* UTMVirtualMachine+Drives */
-"This version of UTM does not allow file access outside of UTM's Documents directory." = "此版本的UTM不允许访问UTM文档目录之外的文件。";
+/* No comment provided by engineer. */
+"This virtual machine has been deleted." = "此虚拟机已被删除";
 
 /* VMDisplayWindowController */
 "This will reset the VM and any unsaved state will be lost." = "这将重置虚拟机,任何未保存的更改都将丢失。";
@@ -647,7 +700,7 @@
 "To release the mouse cursor, press ⌃+⌥ (Ctrl+Opt or Ctrl+Alt) at the same time." = "要释放鼠标光标,请同时按⌃+⌥(Ctrl+Opt或Ctrl+Alt)。";
 
 /* No comment provided by engineer. */
-"Try to use hardware hypervisor when available" = "如果可用,请尝试使用硬件管理程序";
+"Tweaks" = "调整";
 
 /* No comment provided by engineer. */
 "Type" = "格式";
@@ -655,12 +708,39 @@
 /* VMConfigPortForwardingViewController */
 "UDP Forward" = "UDP";
 
-/* UTMQemuConfigurationExtension */
+/* UTMQemuSystem */
+"UEFI is not supported with this architecture." = "此架构不支持 UEFI。";
+
+/* UTMConfigurationExtension */
 "Unknown" = "未知";
 
 /* No comment provided by engineer. */
 "Upscaling" = "粗化";
 
+/* No comment provided by engineer. */
+"USB" = "USB";
+
+/* No comment provided by engineer. */
+"USB 3.0 (XHCI) Support" = "USB 3.0 (XHCI) 支持";
+
+/* VMDisplayMetalWindowController */
+"USB Device" = "USB设备";
+
+/* No comment provided by engineer. */
+"USB not supported in console display mode." = "控制台显示模式不支持 USB";
+
+/* No comment provided by engineer. */
+"USB not supported in this build of UTM." = "此版本的UTM不支持USB";
+
+/* No comment provided by engineer. */
+"USB Sharing" = "USB共享";
+
+/* No comment provided by engineer. */
+"Use only performance cores by default" = "默认情况下仅使用性能核心";
+
+/* No comment provided by engineer. */
+"Virtual Machine Gallery" = "虚拟机库";
+
 /* No comment provided by engineer. */
 "VM display size is fixed" = "VM显示大小是固定的";
 
@@ -676,6 +756,12 @@
 /* Startup message */
 "Welcome to UTM! Due to a bug in iOS, if you force kill this app, the system will be unstable and you cannot launch UTM again until you reboot. The recommended way to terminate this app is the button on the top left." = "欢迎使用UTM!因为iOS的一个Bug,如果您强制关闭UTM,系统将变得不稳定,您必须重新启动您的设备才能再次使用UTM。我们建议点击左上方的按钮来退出UTM。";
 
+/* VMDisplayMetalWindowController */
+"Would you like to connect '(usbDevice.name ?? usbDevice.description)' to this virtual machine?" = "您想将'(usbDevice.name ?? usbDevice.description)'连接到此虚拟机吗?";
+
+/* VMDisplayMetalWindowController */
+"Would you like to connect '%@' to this virtual machine?" = "要将“%@”连接到此虚拟机吗?";
+
 /* VMConfigDrivePickerViewController */
 "Would you like to import an existing disk image or create a new one?" = "您想导入一个磁盘镜像还是创建新的空白磁盘镜像?";
 
@@ -687,13 +773,21 @@
 /* No comment provided by engineer. */
 "You can download an existing VM configuration for popular operating systems from the UTM gallery or start from scratch." = "您可以从UTM库下载流行操作系统的现有VM配置,也可以从头开始。";
 
+/* UTMData
+   VMConfigDrivePickerViewController */
+"You cannot import a .utm package as a drive. Did you mean to open the package with UTM?" = "您不能将 .utm 文件作为驱动器导入。 你的意思是用UTM 打开此文件吗?";
+
+/* UTMData
+   VMConfigDrivePickerViewController */
+"You cannot import a directory as a drive." = "您不能将目录作为驱动器导入";
+
 /* VMConfigDriveDetailsViewController */
 "You must select a disk image." = "必须选择磁盘。";
 
 /* VMDisplayViewController */
 "You must terminate the running VM before you can import a new VM." = "必须先终止正在运行的虚拟机,然后才能导入新的虚拟机。";
 
-/* No comment provided by engineer. */
+/* ContentView */
 "Your version of iOS does not support running VMs while unmodified. You must either run UTM while jailbroken or with a remote debugger attached." = "您的iOS版本不支持在未越狱的情况下运行虚拟机。您必须在越狱时运行UTM,或者附加远程调试器。";
 
 /* No comment provided by engineer. */

+ 9 - 0
QEMUHelper/zh-Hans.lproj/InfoPlist.strings

@@ -0,0 +1,9 @@
+/* Bundle display name */
+"CFBundleDisplayName" = "QEMUHelper";
+
+/* Bundle name */
+"CFBundleName" = "QEMUHelper";
+
+/* Copyright (human-readable) */
+"NSHumanReadableCopyright" = "版权所有 © 2020 osy。 保留所有权利。";
+

+ 9 - 0
QEMUHelper/zh-Hans.lproj/Localizable.strings

@@ -0,0 +1,9 @@
+/* QEMUHelper */
+"Cannot find QEMU support libraries." = "找不到 QEMU 支持库";
+
+/* QEMUHelper */
+"Error starting QEMU." = "启动 QEMU 时出错";
+
+/* QEMUHelper */
+"QEMU exited unexpectedly." = "QEMU 意外退出";
+

+ 74 - 7
UTM.xcodeproj/project.pbxproj

@@ -29,6 +29,20 @@
 		2CE8EB0A2572E173000E2EBB /* qapi-visit-block-export.c in Sources */ = {isa = PBXBuildFile; fileRef = 2CE8EB082572E173000E2EBB /* qapi-visit-block-export.c */; };
 		2CE8EB41257811E8000E2EBB /* UTMQemuConfiguration+Defaults.m in Sources */ = {isa = PBXBuildFile; fileRef = 2CE8EB40257811E8000E2EBB /* UTMQemuConfiguration+Defaults.m */; };
 		2CE8EB42257811E8000E2EBB /* UTMQemuConfiguration+Defaults.m in Sources */ = {isa = PBXBuildFile; fileRef = 2CE8EB40257811E8000E2EBB /* UTMQemuConfiguration+Defaults.m */; };
+		53A0BDD726D79FE40010EDC5 /* SavePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A0BDD426D79FE40010EDC5 /* SavePanel.swift */; };
+		83034C0726AB630F006B4BAF /* UTMPendingVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83034C0626AB630F006B4BAF /* UTMPendingVMView.swift */; };
+		83034C0826AB630F006B4BAF /* UTMPendingVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83034C0626AB630F006B4BAF /* UTMPendingVMView.swift */; };
+		83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83034C0626AB630F006B4BAF /* UTMPendingVMView.swift */; };
+		835AA7B126AB7C85007A0411 /* UTMPendingVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */; };
+		835AA7B226AB7C85007A0411 /* UTMPendingVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */; };
+		835AA7B326AB7C85007A0411 /* UTMPendingVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */; };
+		836A40D526AA2A03002068F8 /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 836A40D426AA2A03002068F8 /* Zip */; };
+		836A40D726AA2A2C002068F8 /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 836A40D626AA2A2C002068F8 /* Zip */; };
+		836A40D926AA2A30002068F8 /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 836A40D826AA2A30002068F8 /* Zip */; };
+		83A004B926A8CC95001AC09E /* UTMImportFromWebTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A004B826A8CC95001AC09E /* UTMImportFromWebTask.swift */; };
+		83A004BA26A8CC95001AC09E /* UTMImportFromWebTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A004B826A8CC95001AC09E /* UTMImportFromWebTask.swift */; };
+		83A004BB26A8CC95001AC09E /* UTMImportFromWebTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A004B826A8CC95001AC09E /* UTMImportFromWebTask.swift */; };
+		83C15C5F26CC441500ADFD45 /* KeyCodeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C15C5E26CC441000ADFD45 /* KeyCodeMap.swift */; };
 		8401FD71269BEB2B00265F0D /* main.c in Sources */ = {isa = PBXBuildFile; fileRef = CE6B240A25F1F3CE0020D43E /* main.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, ); }; };
@@ -685,7 +699,6 @@
 		CE8813D324CD230300532628 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8813D224CD230300532628 /* ActivityView.swift */; };
 		CE8813D524CD265700532628 /* VMShareFileModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8813D424CD265700532628 /* VMShareFileModifier.swift */; };
 		CE8813D624CD265700532628 /* VMShareFileModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8813D424CD265700532628 /* VMShareFileModifier.swift */; };
-		CE8813D824CD2A8B00532628 /* SharingServicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8813D724CD2A8B00532628 /* SharingServicePicker.swift */; };
 		CE8813DB24D1290600532628 /* UTMQemuConfiguration+ConstantsGenerated.m in Sources */ = {isa = PBXBuildFile; fileRef = CE8813D924D1290600532628 /* UTMQemuConfiguration+ConstantsGenerated.m */; };
 		CE8813DC24D1290600532628 /* UTMQemuConfiguration+ConstantsGenerated.m in Sources */ = {isa = PBXBuildFile; fileRef = CE8813D924D1290600532628 /* UTMQemuConfiguration+ConstantsGenerated.m */; };
 		CE900B9E25FC2869007533FD /* CSUSBManager.m in Sources */ = {isa = PBXBuildFile; fileRef = CE900B9D25FC2869007533FD /* CSUSBManager.m */; };
@@ -1514,6 +1527,11 @@
 		52873FD8247F5B1B0063E4C8 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Main.strings"; sourceTree = "<group>"; };
 		52873FD9247F5B1B0063E4C8 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
 		52873FDA247F5B1B0063E4C8 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
+		53A0BDD426D79FE40010EDC5 /* SavePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePanel.swift; sourceTree = "<group>"; };
+		83034C0626AB630F006B4BAF /* UTMPendingVMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMPendingVMView.swift; sourceTree = "<group>"; };
+		835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMPendingVirtualMachine.swift; sourceTree = "<group>"; };
+		83A004B826A8CC95001AC09E /* UTMImportFromWebTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMImportFromWebTask.swift; sourceTree = "<group>"; };
+		83C15C5E26CC441000ADFD45 /* KeyCodeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeMap.swift; 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>"; };
 		8401FD62269BE9C500265F0D /* QEMULauncher.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QEMULauncher.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -1537,6 +1555,10 @@
 		84FCABB8268CE05E0036196C /* UTMQemuVirtualMachine.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMQemuVirtualMachine.h; sourceTree = "<group>"; };
 		84FCABB9268CE05E0036196C /* UTMQemuVirtualMachine.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UTMQemuVirtualMachine.m; sourceTree = "<group>"; };
 		84FCABBD268CE4080036196C /* UTMVirtualMachine-Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UTMVirtualMachine-Private.h"; sourceTree = "<group>"; };
+		C03453AD2709E35100AD51AD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
+		C03453AE2709E35100AD51AD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
+		C03453AF2709E35100AD51AD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
+		C03453B02709E35200AD51AD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
 		C8958B6C243634DA002D86B4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Main.strings; sourceTree = "<group>"; };
 		C8958B6D243634DA002D86B4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
 		CE020BA224AEDC7C00B44AB6 /* UTMData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMData.swift; sourceTree = "<group>"; };
@@ -2007,7 +2029,6 @@
 		CE7D972B24B2B17D0080CB69 /* BusyOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusyOverlay.swift; sourceTree = "<group>"; };
 		CE8813D224CD230300532628 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = "<group>"; };
 		CE8813D424CD265700532628 /* VMShareFileModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMShareFileModifier.swift; sourceTree = "<group>"; };
-		CE8813D724CD2A8B00532628 /* SharingServicePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServicePicker.swift; sourceTree = "<group>"; };
 		CE8813D924D1290600532628 /* UTMQemuConfiguration+ConstantsGenerated.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UTMQemuConfiguration+ConstantsGenerated.m"; sourceTree = "<group>"; };
 		CE8E6620227E5DF2003B9903 /* UTMQemuManagerDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMQemuManagerDelegate.h; sourceTree = "<group>"; };
 		CE900B9C25FC2869007533FD /* CSUSBManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CSUSBManager.h; sourceTree = "<group>"; };
@@ -2214,6 +2235,7 @@
 				CE2D934F24AD46670059923A /* phodav-2.0.0.framework in Frameworks */,
 				CEA9059025FC6A1700801E7C /* usbredirparser.1.framework in Frameworks */,
 				CE0E9B87252FD06B0026E02B /* SwiftUI.framework in Frameworks */,
+				836A40D726AA2A2C002068F8 /* Zip in Frameworks */,
 				CE2D935024AD46670059923A /* gstcontroller-1.0.0.framework in Frameworks */,
 				CE2D935124AD46670059923A /* gstaudio-1.0.0.framework in Frameworks */,
 				CE2D935224AD46670059923A /* gpg-error.0.framework in Frameworks */,
@@ -2300,6 +2322,7 @@
 				CE03D08924D90F0700F76B84 /* glib-2.0.0.framework in Frameworks */,
 				CE0B6EC224AD677200FE012D /* libgstvideorate.a in Frameworks */,
 				CE0B6F0C24AD677200FE012D /* gstreamer-1.0.0.framework in Frameworks */,
+				836A40D526AA2A03002068F8 /* Zip in Frameworks */,
 				CEF83F89250094A400557D15 /* png16.16.framework in Frameworks */,
 				CE0B6F0224AD677200FE012D /* libgstjpeg.a in Frameworks */,
 				CE0B6EFC24AD677200FE012D /* libgstaudiotestsrc.a in Frameworks */,
@@ -2373,6 +2396,7 @@
 				CEA45F59263519B5002FA97D /* gstpbutils-1.0.0.framework in Frameworks */,
 				CEA45F5A263519B5002FA97D /* gstallocators-1.0.0.framework in Frameworks */,
 				CEA45F5B263519B5002FA97D /* gstcheck-1.0.0.framework in Frameworks */,
+				836A40D926AA2A30002068F8 /* Zip in Frameworks */,
 				CEA45F5C263519B5002FA97D /* iconv.2.framework in Frameworks */,
 				CEA45F5D263519B5002FA97D /* gstsdp-1.0.0.framework in Frameworks */,
 				CEA45F5E263519B5002FA97D /* ssl.1.1.framework in Frameworks */,
@@ -2550,7 +2574,6 @@
 				CE1BD9FA24F4825C0022A468 /* Display */,
 				CEECE13B25E47D9500A2AAB8 /* AppDelegate.swift */,
 				CEEC811A24E48EC600ACB0B3 /* SettingsView.swift */,
-				CE8813D724CD2A8B00532628 /* SharingServicePicker.swift */,
 				CEBBF1A624B5730F00C15049 /* UTMDataExtension.swift */,
 				CE93759524BB7E9F0074066F /* UTMTabViewController.swift */,
 				8401FD9F269D266E00265F0D /* VMConfigAppleBootView.swift */,
@@ -2567,10 +2590,12 @@
 				84C584E2268F8AE7000FCABF /* VMQEMUSettingsView.swift */,
 				CE2D953D24AD4F980059923A /* VMSettingsView.swift */,
 				CEF0300526A25A6900667B63 /* VMWizardView.swift */,
+				53A0BDD426D79FE40010EDC5 /* SavePanel.swift */,
 				CE2D954124AD4F980059923A /* Info.plist */,
 				FFB02A8E266CB09C006CD71A /* InfoPlist.strings */,
 				CE2D953F24AD4F980059923A /* macOS.entitlements */,
 				CEF6F5EA26DDD60500BC434D /* macOS-unsigned.entitlements */,
+				83C15C5E26CC441000ADFD45 /* KeyCodeMap.swift */,
 			);
 			path = macOS;
 			sourceTree = "<group>";
@@ -3041,6 +3066,7 @@
 				CE0B6D8324AD5ADE00FE012D /* UTMScreenshot.h */,
 				CE0B6D8424AD5ADE00FE012D /* UTMScreenshot.m */,
 				CE020BB524B14F8400B44AB6 /* UTMVirtualMachineExtension.swift */,
+				835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */,
 			);
 			path = Managers;
 			sourceTree = "<group>";
@@ -3159,6 +3185,8 @@
 				CEF0305926A2AFDE00667B63 /* VMWizardStartView.swift */,
 				CEF0305526A2AFDD00667B63 /* VMWizardState.swift */,
 				CEBE820A26A4C8E0007AAB12 /* VMWizardSummaryView.swift */,
+				83A004B826A8CC95001AC09E /* UTMImportFromWebTask.swift */,
+				83034C0626AB630F006B4BAF /* UTMPendingVMView.swift */,
 			);
 			path = Shared;
 			sourceTree = "<group>";
@@ -3302,6 +3330,7 @@
 			packageProductDependencies = (
 				CE020BA624AEDEF000B44AB6 /* Logging */,
 				CE93759824BB821F0074066F /* IQKeyboardManagerSwift */,
+				836A40D626AA2A2C002068F8 /* Zip */,
 			);
 			productName = UTM;
 			productReference = CE2D93BE24AD46670059923A /* UTM.app */;
@@ -3326,6 +3355,7 @@
 			name = macOS;
 			packageProductDependencies = (
 				CE020BA824AEDF3000B44AB6 /* Logging */,
+				836A40D426AA2A03002068F8 /* Zip */,
 			);
 			productName = UTM;
 			productReference = CE2D951C24AD48BE0059923A /* UTM.app */;
@@ -3367,6 +3397,7 @@
 			packageProductDependencies = (
 				CEA45E20263519B5002FA97D /* Logging */,
 				CEA45E22263519B5002FA97D /* IQKeyboardManagerSwift */,
+				836A40D826AA2A30002068F8 /* Zip */,
 			);
 			productName = UTM;
 			productReference = CEA45FB9263519B5002FA97D /* UTM SE.app */;
@@ -3434,6 +3465,7 @@
 			packageReferences = (
 				CE020BA524AEDEF000B44AB6 /* XCRemoteSwiftPackageReference "swift-log" */,
 				CE93759724BB821F0074066F /* XCRemoteSwiftPackageReference "IQKeyboardManager" */,
+				836A40D326AA2A03002068F8 /* XCRemoteSwiftPackageReference "Zip" */,
 			);
 			productRefGroup = CE550BCA225947990063E575 /* Products */;
 			projectDirPath = "";
@@ -3651,6 +3683,7 @@
 				CE2D928A24AD46670059923A /* qapi-visit-qdev.c in Sources */,
 				CE2D955724AD4F980059923A /* VMConfigDisplayView.swift in Sources */,
 				CEF0306126A2AFDF00667B63 /* VMWizardOSWindowsView.swift in Sources */,
+				83A004B926A8CC95001AC09E /* UTMImportFromWebTask.swift in Sources */,
 				CE2D928B24AD46670059923A /* qapi-visit-core.c in Sources */,
 				CE2D928C24AD46670059923A /* qapi-visit-rdma.c in Sources */,
 				CE2D928D24AD46670059923A /* qapi-types-trace.c in Sources */,
@@ -3701,6 +3734,7 @@
 				CEF83ECC24FB382B00557D15 /* UTMQemuVirtualMachine+Terminal.m in Sources */,
 				CEB63A9224F46E6E00CAF323 /* VMConfigInputViewController.m in Sources */,
 				CE4EF2702506DBFD00E9D33B /* VMRemovableDrivesViewController.swift in Sources */,
+				83034C0726AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
 				CE2D92AA24AD46670059923A /* UTMSpiceIO.m in Sources */,
 				CE2D92AB24AD46670059923A /* qapi-events-machine-target.c in Sources */,
 				CE2D92AC24AD46670059923A /* qapi-types-misc.c in Sources */,
@@ -3729,6 +3763,8 @@
 				CE2D92BA24AD46670059923A /* qapi-events-dump.c in Sources */,
 				CE2D92BB24AD46670059923A /* qapi-types-tpm.c in Sources */,
 				CE2D92BC24AD46670059923A /* UTMQemuConfiguration+Drives.m in Sources */,
+				835AA7B126AB7C85007A0411 /* UTMPendingVirtualMachine.swift in Sources */,
+				CE2D92BC24AD46670059923A /* UTMQemuConfiguration+Drives.m in Sources */,
 				CE2D92BD24AD46670059923A /* qapi-visit-char.c in Sources */,
 				CE2D92BE24AD46670059923A /* qapi-types-error.c in Sources */,
 				CE2D92BF24AD46670059923A /* qapi-commands-authz.c in Sources */,
@@ -3831,7 +3867,6 @@
 				CEB63A8124F46E5700CAF323 /* VMConfigTextField.m in Sources */,
 				CEB63A9524F4747900CAF323 /* VMListViewCell.m in Sources */,
 				CE2D930124AD46670059923A /* qapi-events-common.c in Sources */,
-				84C584E7268F958B000FCABF /* RAMSlider.swift in Sources */,
 				CE2D930224AD46670059923A /* UTMTerminalIO.m in Sources */,
 				CE2D955D24AD4F990059923A /* VMConfigSoundView.swift in Sources */,
 				CE2D930324AD46670059923A /* qapi-events-crypto.c in Sources */,
@@ -3891,6 +3926,7 @@
 				CE0B6D0024AD56AE00FE012D /* UTMVirtualMachine.m in Sources */,
 				CEB63A7B24F469E300CAF323 /* UTMJailbreak.m in Sources */,
 				CE0B6D0E24AD56E500FE012D /* UTMShaders.metal in Sources */,
+				83A004BB26A8CC95001AC09E /* UTMImportFromWebTask.swift in Sources */,
 				CE0B6D3924AD57FD00FE012D /* qapi-commands-job.c in Sources */,
 				CE0B6D3724AD57FD00FE012D /* qapi-events-block-core.c in Sources */,
 				CE0B6D5E24AD584D00FE012D /* qapi-visit-authz.c in Sources */,
@@ -3960,6 +3996,7 @@
 				CE0B6D4E24AD584C00FE012D /* qapi-types-tpm.c in Sources */,
 				CE0B6D7524AD584D00FE012D /* qapi-visit-ui.c in Sources */,
 				CE0B6D1824AD57FC00FE012D /* qapi-events-audio.c in Sources */,
+				835AA7B326AB7C85007A0411 /* UTMPendingVirtualMachine.swift in Sources */,
 				CE0B6D7224AD584D00FE012D /* qapi-types-crypto.c in Sources */,
 				CEF0306926A2AFDF00667B63 /* VMWizardOSMacView.swift in Sources */,
 				CE0B6D8124AD584D00FE012D /* qapi-events.c in Sources */,
@@ -3971,6 +4008,7 @@
 				CE928C3126ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift in Sources */,
 				CE0B6CEF24AD566D00FE012D /* CSDisplayMetal.m in Sources */,
 				CE0B6D6724AD584D00FE012D /* qapi-types-ui.c in Sources */,
+				83C15C5F26CC441500ADFD45 /* KeyCodeMap.swift in Sources */,
 				CE0B6D6824AD584D00FE012D /* qapi-visit-machine.c in Sources */,
 				CE0B6D4A24AD584C00FE012D /* qapi-types-error.c in Sources */,
 				CE0B6D2124AD57FC00FE012D /* qapi-commands-error.c in Sources */,
@@ -4100,17 +4138,16 @@
 				CE0B6D8224AD584D00FE012D /* qapi-types-block-core.c in Sources */,
 				2CE8EAFF2572E14D000E2EBB /* qapi-types-block-export.c in Sources */,
 				CE0B6D8724AD5ADE00FE012D /* UTMScreenshot.m in Sources */,
-				84C584E9268F958B000FCABF /* RAMSlider.swift in Sources */,
 				CEDF83FA258AE24E0030E4AC /* UTMPasteboard.swift in Sources */,
 				CE0B6D1124AD57C700FE012D /* qapi-builtin-visit.c in Sources */,
 				CE0B6D3F24AD584C00FE012D /* qapi-visit-qom.c in Sources */,
 				CE2D958A24AD4F990059923A /* VMConfigSystemView.swift in Sources */,
-				CE8813D824CD2A8B00532628 /* SharingServicePicker.swift in Sources */,
 				CEBBF1A724B5730F00C15049 /* UTMDataExtension.swift in Sources */,
 				84C584E3268F8AE7000FCABF /* VMQEMUSettingsView.swift in Sources */,
 				CE0B6D3524AD57FC00FE012D /* qapi-events-authz.c in Sources */,
 				CE2D956424AD4F990059923A /* VMConfigNetworkPortForwardView.swift in Sources */,
 				CE612AC624D3B50700FA6300 /* VMDisplayWindowController.swift in Sources */,
+				53A0BDD726D79FE40010EDC5 /* SavePanel.swift in Sources */,
 				CE0B6D0724AD56AE00FE012D /* UTMQemuManager.m in Sources */,
 				CE0B6D3E24AD584C00FE012D /* qapi-visit-crypto.c in Sources */,
 				CE0B6D6C24AD584D00FE012D /* qapi-visit-common.c in Sources */,
@@ -4121,6 +4158,7 @@
 				CE0B6D1724AD57FC00FE012D /* qapi-commands-misc.c in Sources */,
 				CE0B6D2924AD57FC00FE012D /* qapi-commands-machine-target.c in Sources */,
 				CEF0306F26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
+				83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
 				CE0B6D7324AD584D00FE012D /* qapi-events-migration.c in Sources */,
 				CE020BA424AEDC7C00B44AB6 /* UTMData.swift in Sources */,
 				CE0B6D1A24AD57FC00FE012D /* qapi-commands-qdev.c in Sources */,
@@ -4160,6 +4198,7 @@
 				CEA45E2E263519B5002FA97D /* qapi-events-misc.c in Sources */,
 				CEA45E2F263519B5002FA97D /* VMConfigSystemArgumentsViewController.m in Sources */,
 				CEA45E30263519B5002FA97D /* WKWebView+Workarounds.m in Sources */,
+				83A004BA26A8CC95001AC09E /* UTMImportFromWebTask.swift in Sources */,
 				CEA45E31263519B5002FA97D /* qapi-visit-crypto.c in Sources */,
 				CEA45E32263519B5002FA97D /* qapi-visit-tpm.c in Sources */,
 				CEA45E34263519B5002FA97D /* UTMQemuConfiguration+Defaults.m in Sources */,
@@ -4219,7 +4258,6 @@
 				CEA45E69263519B5002FA97D /* VMConfigQEMUView.swift in Sources */,
 				CEA45E6A263519B5002FA97D /* VMDisplayMetalViewController+Touch.m in Sources */,
 				CEA45E6B263519B5002FA97D /* UTMQemuConfiguration+Display.m in Sources */,
-				84C584E8268F958B000FCABF /* RAMSlider.swift in Sources */,
 				CEA45E6C263519B5002FA97D /* UTMVirtualMachineExtension.swift in Sources */,
 				CEA45E6D263519B5002FA97D /* qapi-visit-block-core.c in Sources */,
 				CEA45E6E263519B5002FA97D /* VMConfigTogglePickerCell.m in Sources */,
@@ -4339,6 +4377,7 @@
 				CEA45ED6263519B5002FA97D /* VMConfigStringPicker.swift in Sources */,
 				CEA45ED7263519B5002FA97D /* qapi-commands-dump.c in Sources */,
 				CEF0307226A2B04400667B63 /* VMWizardView.swift in Sources */,
+				83034C0826AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
 				CEA45ED8263519B5002FA97D /* VMKeyboardButton.m in Sources */,
 				CEA45ED9263519B5002FA97D /* qapi-commands-introspect.c in Sources */,
 				CEA45EDA263519B5002FA97D /* qapi-types-sockets.c in Sources */,
@@ -4373,6 +4412,7 @@
 				CEA45EF6263519B5002FA97D /* UTMQemuConfiguration.m in Sources */,
 				CEA45EF7263519B5002FA97D /* UTMQemuVirtualMachine+SPICE.m in Sources */,
 				CEA45EF8263519B5002FA97D /* VMDetailsView.swift in Sources */,
+				835AA7B226AB7C85007A0411 /* UTMPendingVirtualMachine.swift in Sources */,
 				CEA45EF9263519B5002FA97D /* VMDisplayMetalViewController.m in Sources */,
 				CEA45EFA263519B5002FA97D /* qapi-commands-misc.c in Sources */,
 				CEA45EFB263519B5002FA97D /* qapi-events-acpi.c in Sources */,
@@ -4495,6 +4535,7 @@
 			isa = PBXVariantGroup;
 			children = (
 				FFB02A8B266CB09C006CD71A /* zh-Hant */,
+				C03453AD2709E35100AD51AD /* zh-Hans */,
 			);
 			name = InfoPlist.strings;
 			sourceTree = "<group>";
@@ -4503,6 +4544,7 @@
 			isa = PBXVariantGroup;
 			children = (
 				FFB02A8F266CB09C006CD71A /* zh-Hant */,
+				C03453AE2709E35100AD51AD /* zh-Hans */,
 			);
 			name = InfoPlist.strings;
 			sourceTree = "<group>";
@@ -4511,6 +4553,7 @@
 			isa = PBXVariantGroup;
 			children = (
 				FFB02A92266CB09C006CD71A /* zh-Hant */,
+				C03453AF2709E35100AD51AD /* zh-Hans */,
 			);
 			name = InfoPlist.strings;
 			sourceTree = "<group>";
@@ -4519,6 +4562,7 @@
 			isa = PBXVariantGroup;
 			children = (
 				FFB02A95266CB09C006CD71A /* zh-Hant */,
+				C03453B02709E35200AD51AD /* zh-Hans */,
 			);
 			name = Localizable.strings;
 			sourceTree = "<group>";
@@ -5063,6 +5107,14 @@
 /* End XCConfigurationList section */
 
 /* Begin XCRemoteSwiftPackageReference section */
+		836A40D326AA2A03002068F8 /* XCRemoteSwiftPackageReference "Zip" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "https://github.com/marmelroy/Zip.git";
+			requirement = {
+				kind = upToNextMinorVersion;
+				minimumVersion = 2.1.1;
+			};
+		};
 		CE020BA524AEDEF000B44AB6 /* XCRemoteSwiftPackageReference "swift-log" */ = {
 			isa = XCRemoteSwiftPackageReference;
 			repositoryURL = "https://github.com/apple/swift-log";
@@ -5098,6 +5150,21 @@
 /* End XCRemoteSwiftPackageReference section */
 
 /* Begin XCSwiftPackageProductDependency section */
+		836A40D426AA2A03002068F8 /* Zip */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 836A40D326AA2A03002068F8 /* XCRemoteSwiftPackageReference "Zip" */;
+			productName = Zip;
+		};
+		836A40D626AA2A2C002068F8 /* Zip */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 836A40D326AA2A03002068F8 /* XCRemoteSwiftPackageReference "Zip" */;
+			productName = Zip;
+		};
+		836A40D826AA2A30002068F8 /* Zip */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 836A40D326AA2A03002068F8 /* XCRemoteSwiftPackageReference "Zip" */;
+			productName = Zip;
+		};
 		CE020BA624AEDEF000B44AB6 /* Logging */ = {
 			isa = XCSwiftPackageProductDependency;
 			package = CE020BA524AEDEF000B44AB6 /* XCRemoteSwiftPackageReference "swift-log" */;

+ 179 - 0
patches/qemu-6.1.0-utm.patch

@@ -1490,3 +1490,182 @@ index 0bb210a2c2..a6afc87be8 100644
 -- 
 2.28.0
 
+From cec8d31d7a48c216e83e3505c41d9ac1aa493159 Mon Sep 17 00:00:00 2001
+From: osy <50960678+osy@users.noreply.github.com>
+Date: Sun, 26 Sep 2021 15:36:00 -0700
+Subject: [PATCH] resolv: fix IPv6 resolution on Darwin
+
+res_sockaddr_union() has a field for IPv4 and a field for IPv6. When we
+used `&servers[i].sin.sin_addr`, it does not return the right address
+for IPv6.
+---
+ src/slirp.c | 18 ++++++++++++------
+ 1 file changed, 12 insertions(+), 6 deletions(-)
+
+diff --git a/subprojects/libslirp/src/slirp.c b/subprojects/libslirp/src/slirp.c
+index a669b45..0583e5b 100644
+--- a/subprojects/libslirp/src/slirp.c
++++ b/subprojects/libslirp/src/slirp.c
+@@ -143,6 +143,10 @@ static int get_dns_addr_libresolv(int af, void *pdns_addr, void *cached_addr,
+     union res_sockaddr_union servers[NI_MAXSERV];
+     int count;
+     int found;
++    void *addr;
++
++    // we only support IPv4 and IPv4, we assume it's one or the other
++    assert(af == AF_INET || af == AF_INET6);
+ 
+     if (res_ninit(&state) != 0) {
+         return -1;
+@@ -155,11 +159,16 @@ static int get_dns_addr_libresolv(int af, void *pdns_addr, void *cached_addr,
+         if (af == servers[i].sin.sin_family) {
+             found++;
+         }
++        if (af == AF_INET) {
++            addr = &servers[i].sin.sin_addr;
++        } else { // af == AF_INET6
++            addr = &servers[i].sin6.sin6_addr;
++        }
+ 
+         // we use the first found entry
+         if (found == 1) {
+-            memcpy(pdns_addr, &servers[i].sin.sin_addr, addrlen);
+-            memcpy(cached_addr, &servers[i].sin.sin_addr, addrlen);
++            memcpy(pdns_addr, addr, addrlen);
++            memcpy(cached_addr, addr, addrlen);
+             if (scope_id) {
+                 *scope_id = 0;
+             }
+@@ -171,10 +180,7 @@ static int get_dns_addr_libresolv(int af, void *pdns_addr, void *cached_addr,
+             break;
+         } else if (slirp_debug & DBG_MISC) {
+             char s[INET6_ADDRSTRLEN];
+-            const char *res = inet_ntop(servers[i].sin.sin_family,
+-                                        &servers[i].sin.sin_addr,
+-                                        s,
+-                                        sizeof(s));
++            const char *res = inet_ntop(af, addr, s, sizeof(s));
+             if (!res) {
+                 res = "  (string conversion error)";
+             }
+-- 
+2.28.0
+
+From patchwork Tue Oct 26 07:12:41 2021
+Content-Type: text/plain; charset="utf-8"
+MIME-Version: 1.0
+Content-Transfer-Encoding: 8bit
+X-Patchwork-Submitter: Alexander Graf <agraf@csgraf.de>
+X-Patchwork-Id: 12584125
+Return-Path: 
+ <SRS0=K6hM=PO=nongnu.org=qemu-devel-bounces+qemu-devel=archiver.kernel.org@kernel.org>
+X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on
+	aws-us-west-2-korg-lkml-1.web.codeaurora.org
+Received: from mail.kernel.org (mail.kernel.org [198.145.29.99])
+	by smtp.lore.kernel.org (Postfix) with ESMTP id 49FD2C433EF
+	for <qemu-devel@archiver.kernel.org>; Tue, 26 Oct 2021 08:08:16 +0000 (UTC)
+Received: from lists.gnu.org (lists.gnu.org [209.51.188.17])
+	(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
+	(No client certificate requested)
+	by mail.kernel.org (Postfix) with ESMTPS id BA335610A0
+	for <qemu-devel@archiver.kernel.org>; Tue, 26 Oct 2021 08:08:15 +0000 (UTC)
+DMARC-Filter: OpenDMARC Filter v1.4.1 mail.kernel.org BA335610A0
+Authentication-Results: mail.kernel.org;
+ dmarc=none (p=none dis=none) header.from=csgraf.de
+Authentication-Results: mail.kernel.org; spf=pass smtp.mailfrom=nongnu.org
+Received: from localhost ([::1]:36942 helo=lists1p.gnu.org)
+	by lists.gnu.org with esmtp (Exim 4.90_1)
+	(envelope-from
+ <qemu-devel-bounces+qemu-devel=archiver.kernel.org@nongnu.org>)
+	id 1mfHVG-0004hG-Ru
+	for qemu-devel@archiver.kernel.org; Tue, 26 Oct 2021 04:08:14 -0400
+Received: from eggs.gnu.org ([2001:470:142:3::10]:57414)
+ by lists.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
+ (Exim 4.90_1) (envelope-from <agraf@csgraf.de>) id 1mfGdf-00045B-Qy
+ for qemu-devel@nongnu.org; Tue, 26 Oct 2021 03:12:51 -0400
+Received: from mail.csgraf.de ([85.25.223.15]:49104
+ helo=zulu616.server4you.de)
+ by eggs.gnu.org with esmtp (Exim 4.90_1)
+ (envelope-from <agraf@csgraf.de>) id 1mfGdZ-0008O2-40
+ for qemu-devel@nongnu.org; Tue, 26 Oct 2021 03:12:49 -0400
+Received: from localhost.localdomain
+ (dynamic-077-007-071-240.77.7.pool.telefonica.de [77.7.71.240])
+ by csgraf.de (Postfix) with ESMTPSA id A8F486080126;
+ Tue, 26 Oct 2021 09:12:42 +0200 (CEST)
+From: Alexander Graf <agraf@csgraf.de>
+To: Cameron Esfahani <dirty@apple.com>
+Subject: [PATCH v2] hvf: arm: Ignore cache operations on MMIO
+Date: Tue, 26 Oct 2021 09:12:41 +0200
+Message-Id: <20211026071241.74889-1-agraf@csgraf.de>
+X-Mailer: git-send-email 2.30.1 (Apple Git-130)
+MIME-Version: 1.0
+Received-SPF: pass client-ip=85.25.223.15; envelope-from=agraf@csgraf.de;
+ helo=zulu616.server4you.de
+X-Spam_score_int: -18
+X-Spam_score: -1.9
+X-Spam_bar: -
+X-Spam_report: (-1.9 / 5.0 requ) BAYES_00=-1.9, SPF_HELO_NONE=0.001,
+ SPF_PASS=-0.001 autolearn=ham autolearn_force=no
+X-Spam_action: no action
+X-BeenThere: qemu-devel@nongnu.org
+X-Mailman-Version: 2.1.23
+Precedence: list
+List-Id: <qemu-devel.nongnu.org>
+List-Unsubscribe: <https://lists.nongnu.org/mailman/options/qemu-devel>,
+ <mailto:qemu-devel-request@nongnu.org?subject=unsubscribe>
+List-Archive: <https://lists.nongnu.org/archive/html/qemu-devel>
+List-Post: <mailto:qemu-devel@nongnu.org>
+List-Help: <mailto:qemu-devel-request@nongnu.org?subject=help>
+List-Subscribe: <https://lists.nongnu.org/mailman/listinfo/qemu-devel>,
+ <mailto:qemu-devel-request@nongnu.org?subject=subscribe>
+Cc: kettenis@openbsd.org, Richard Henderson <richard.henderson@linaro.org>,
+	=?utf-8?q?Philippe_Mathieu-Daud=C3=A9?= <f4bug@amsat.org>,
+ qemu-devel@nongnu.org, Roman Bolshakov <r.bolshakov@yadro.com>,
+ Paolo Bonzini <pbonzini@redhat.com>
+Errors-To: qemu-devel-bounces+qemu-devel=archiver.kernel.org@nongnu.org
+Sender: "Qemu-devel"
+ <qemu-devel-bounces+qemu-devel=archiver.kernel.org@nongnu.org>
+
+Apple's Hypervisor.Framework forwards cache operations as MMIO traps
+into user space. For MMIO however, these have no meaning: There is no
+cache attached to them.
+
+So let's just treat cache data exits as nops.
+
+This fixes OpenBSD booting as guest.
+
+Signed-off-by: Alexander Graf <agraf@csgraf.de>
+Reported-by: AJ Barris <AwlsomeAlex@github.com>
+Reference: https://github.com/utmapp/UTM/issues/3197
+Reviewed-by: Philippe Mathieu-Daudé <f4bug@amsat.org>
+Reviewed-by: Mark Kettenis <kettenis@openbsd.org>
+Reviewed-by: Richard Henderson <richard.henderson@linaro.org>
+Reviewed-by: Richard Henderson <richard.henderson@linaro.org>
+---
+ target/arm/hvf/hvf.c | 7 +++++++
+ 1 file changed, 7 insertions(+)
+
+diff --git a/target/arm/hvf/hvf.c b/target/arm/hvf/hvf.c
+index bff3e0cde7..0dc96560d3 100644
+--- a/target/arm/hvf/hvf.c
++++ b/target/arm/hvf/hvf.c
+@@ -1150,12 +1150,19 @@ int hvf_vcpu_exec(CPUState *cpu)
+         uint32_t sas = (syndrome >> 22) & 3;
+         uint32_t len = 1 << sas;
+         uint32_t srt = (syndrome >> 16) & 0x1f;
++        uint32_t cm = (syndrome >> 8) & 0x1;
+         uint64_t val = 0;
+ 
+         trace_hvf_data_abort(env->pc, hvf_exit->exception.virtual_address,
+                              hvf_exit->exception.physical_address, isv,
+                              iswrite, s1ptw, len, srt);
+ 
++        if (cm) {
++            /* We don't cache MMIO regions */
++            advance_pc = true;
++            break;
++        }
++
+         assert(isv);
+ 
+         if (iswrite) {

+ 12 - 0
scripts/const-gen.py

@@ -56,6 +56,9 @@ DEFAULTS = {
 }
 
 AUDIO_SCREAMER = Device('screamer', 'macio', '', 'Screamer (Mac99 only)')
+DISPLAY_TCX = Device('tcx', 'none', '', 'Sun TCX')
+DISPLAY_CG3 = Device('cg3', 'none', '', 'Sun cgthree')
+NETWORK_LANCE = Device('lance', 'none', '', 'Lance (Am7990)')
 
 ADD_DEVICES = {
     "ppc": {
@@ -68,6 +71,15 @@ ADD_DEVICES = {
             AUDIO_SCREAMER
         ])
     },
+    "sparc": {
+        "Display devices": set([
+            DISPLAY_TCX,
+            DISPLAY_CG3
+        ]),
+        "Network devices": set([
+            NETWORK_LANCE
+        ])
+    },
 }
 
 HEADER = '''//