diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ba365a3..a3067293 100644 --- a/.github/workflows/build.yml +++ b/.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 diff --git a/Build.xcconfig b/Build.xcconfig index 58b45d87..16847988 100644 --- a/Build.xcconfig +++ b/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" diff --git a/CocoaSpice/CSConnection.m b/CocoaSpice/CSConnection.m index 3cf73e50..96f2e64b 100644 --- a/CocoaSpice/CSConnection.m +++ b/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 */ diff --git a/CocoaSpice/CSDisplayMetal.m b/CocoaSpice/CSDisplayMetal.m index a9a5175c..96864470 100644 --- a/CocoaSpice/CSDisplayMetal.m +++ b/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; } } diff --git a/Configuration/UTMQemuConfiguration+ConstantsGenerated.m b/Configuration/UTMQemuConfiguration+ConstantsGenerated.m index 1a7420d5..412336e7 100644 --- a/Configuration/UTMQemuConfiguration+ConstantsGenerated.m +++ b/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": @[ diff --git a/Configuration/UTMQemuConfiguration+Defaults.h b/Configuration/UTMQemuConfiguration+Defaults.h index 72d803ba..8dc50277 100644 --- a/Configuration/UTMQemuConfiguration+Defaults.h +++ b/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 diff --git a/Configuration/UTMQemuConfiguration+Defaults.m b/Configuration/UTMQemuConfiguration+Defaults.m index d372cb7c..97f4f5c3 100644 --- a/Configuration/UTMQemuConfiguration+Defaults.m +++ b/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 *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 *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 *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"; } diff --git a/Configuration/UTMQemuConfiguration+Networking.h b/Configuration/UTMQemuConfiguration+Networking.h index a3e2e8a8..7b75789c 100644 --- a/Configuration/UTMQemuConfiguration+Networking.h +++ b/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; diff --git a/Configuration/UTMQemuConfiguration+Networking.m b/Configuration/UTMQemuConfiguration+Networking.m index 4ecef2f7..4a7ce041 100644 --- a/Configuration/UTMQemuConfiguration+Networking.m +++ b/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++) { diff --git a/Icons/windows-11.png b/Icons/windows-11.png new file mode 100644 index 00000000..a17b2ea9 Binary files /dev/null and b/Icons/windows-11.png differ diff --git a/Icons/windows-9x.png b/Icons/windows-9x.png new file mode 100644 index 00000000..c2d56a24 Binary files /dev/null and b/Icons/windows-9x.png differ diff --git a/Icons/windows-xp.png b/Icons/windows-xp.png new file mode 100644 index 00000000..5fed261a Binary files /dev/null and b/Icons/windows-xp.png differ diff --git a/Managers/UTMJSONStream.m b/Managers/UTMJSONStream.m index 5d785139..f0f81afb 100644 --- a/Managers/UTMJSONStream.m +++ b/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: { diff --git a/Managers/UTMPendingVirtualMachine.swift b/Managers/UTMPendingVirtualMachine.swift new file mode 100644 index 00000000..06d2d47b --- /dev/null +++ b/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) + } +} diff --git a/Managers/UTMQemuManager.m b/Managers/UTMQemuManager.m index 79b34b7b..8403d998 100644 --- a/Managers/UTMQemuManager.m +++ b/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); diff --git a/Managers/UTMQemuSystem.m b/Managers/UTMQemuSystem.m index 4433135a..af40dafb 100644 --- a/Managers/UTMQemuSystem.m +++ b/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]; } diff --git a/Managers/UTMScreenshot.h b/Managers/UTMScreenshot.h index 6009f46b..33557f4e 100644 --- a/Managers/UTMScreenshot.h +++ b/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; diff --git a/Managers/UTMScreenshot.m b/Managers/UTMScreenshot.m index ccfef2c7..f20da7cf 100644 --- a/Managers/UTMScreenshot.m +++ b/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]; } diff --git a/Managers/UTMTerminal.m b/Managers/UTMTerminal.m index b8244330..2a4dc3e9 100644 --- a/Managers/UTMTerminal.m +++ b/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, ^{ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt.png index a30af49d..4c705d2d 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt@2x.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt@2x.png index 1e98a3e8..3366a92a 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt@2x.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_128pt@2x.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt.png index 76f9e08d..b076e0a9 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt@2x.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt@2x.png index ffb502fe..366fd250 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt@2x.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_16pt@2x.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt.png index a0c56640..3366a92a 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt@2x.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt@2x.png index 8568be0a..3aeb4e1f 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt@2x.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_256pt@2x.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt.png index e05f0c3e..366fd250 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt@2x.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt@2x.png index 4b1bc141..9c05c07b 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt@2x.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_32pt@2x.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt.png index 1ffd5c63..3aeb4e1f 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt.png differ diff --git a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt@2x.png b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt@2x.png index 0b08c3bb..c05c8e59 100644 Binary files a/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt@2x.png and b/Platform/Assets.xcassets/AppIcon-macOS.appiconset/icon_512pt@2x.png differ diff --git a/Platform/Shared/ContentView.swift b/Platform/Shared/ContentView.swift index 0023ecdf..38f5a535 100644 --- a/Platform/Shared/ContentView.swift +++ b/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) diff --git a/Platform/Shared/HTerm/libapps b/Platform/Shared/HTerm/libapps index 7017f155..75f66ba9 160000 --- a/Platform/Shared/HTerm/libapps +++ b/Platform/Shared/HTerm/libapps @@ -1 +1 @@ -Subproject commit 7017f15511d623c0db95f218968650919544efa3 +Subproject commit 75f66ba9a035fc80b5f927b56f0cea26e8d29bc8 diff --git a/Platform/Shared/HTerm/terminal.js b/Platform/Shared/HTerm/terminal.js index 904041a4..ef6d4f4f 100644 --- a/Platform/Shared/HTerm/terminal.js +++ b/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(); }; diff --git a/Platform/Shared/UTMImportFromWebTask.swift b/Platform/Shared/UTMImportFromWebTask.swift new file mode 100644 index 00000000..eddf7558 --- /dev/null +++ b/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[.. [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] { diff --git a/Platform/Shared/VMContextMenuModifier.swift b/Platform/Shared/VMContextMenuModifier.swift index fff5a94f..e53f3d39 100644 --- a/Platform/Shared/VMContextMenuModifier.swift +++ b/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) { diff --git a/Platform/Shared/VMDetailsView.swift b/Platform/Shared/VMDetailsView.swift index 2e5b1bc1..38643d5d 100644 --- a/Platform/Shared/VMDetailsView.swift +++ b/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 diff --git a/Platform/Shared/VMDriveImage.swift b/Platform/Shared/VMDriveImage.swift index cb56f658..6dea4431 100644 --- a/Platform/Shared/VMDriveImage.swift +++ b/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 } } diff --git a/Platform/Shared/VMShareFileModifier.swift b/Platform/Shared/VMShareFileModifier.swift index d61d9d41..074a6d0e 100644 --- a/Platform/Shared/VMShareFileModifier.swift +++ b/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 + } + } + } } diff --git a/Platform/Shared/VMToolbarModifier.swift b/Platform/Shared/VMToolbarModifier.swift index 18dbfe4c..a79c5a14 100644 --- a/Platform/Shared/VMToolbarModifier.swift +++ b/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 #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() + }) } } diff --git a/Platform/Shared/VMWizardState.swift b/Platform/Shared/VMWizardState.swift index dfd317b9..32af6900 100644 --- a/Platform/Shared/VMWizardState.swift +++ b/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 } diff --git a/Platform/UTMApp.swift b/Platform/UTMApp.swift index d48f297a..18a4c45e 100644 --- a/Platform/UTMApp.swift +++ b/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 { diff --git a/Platform/UTMData.swift b/Platform/UTMData.swift index 5bb88094..8a912391 100644 --- a/Platform/UTMData.swift +++ b/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 + } } diff --git a/Platform/UTMExtensions.swift b/Platform/UTMExtensions.swift index 05416c5d..d9c3f256 100644 --- a/Platform/UTMExtensions.swift +++ b/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() + return filter { set.insert($0).inserted } + } } #if !os(macOS) diff --git a/Platform/iOS/Display/VMDisplayTerminalViewController.m b/Platform/iOS/Display/VMDisplayTerminalViewController.m index 10ac6bef..a9918faa 100644 --- a/Platform/iOS/Display/VMDisplayTerminalViewController.m +++ b/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 diff --git a/Platform/iOS/Info.plist b/Platform/iOS/Info.plist index c853e6ef..532c1275 100644 --- a/Platform/iOS/Info.plist +++ b/Platform/iOS/Info.plist @@ -33,6 +33,19 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.utmapp.UTM + CFBundleURLSchemes + + UTM + + + ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/Platform/iOS/Legacy/VMConfigDriveDetailViewController.m b/Platform/iOS/Legacy/VMConfigDriveDetailViewController.m index 0fa64d59..a1d25818 100644 --- a/Platform/iOS/Legacy/VMConfigDriveDetailViewController.m +++ b/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 { diff --git a/Platform/iOS/Legacy/zh-Hans.lproj/Main.strings b/Platform/iOS/Legacy/zh-Hans.lproj/Main.strings index 6016a29b..dcc9b74a 100644 --- a/Platform/iOS/Legacy/zh-Hans.lproj/Main.strings +++ b/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"; diff --git a/Platform/iOS/UTMDataExtension.swift b/Platform/iOS/UTMDataExtension.swift index 2481b24c..9e651d59 100644 --- a/Platform/iOS/UTMDataExtension.swift +++ b/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) + } + } } diff --git a/Platform/iOS/VMConfigDrivesView.swift b/Platform/iOS/VMConfigDrivesView.swift index 3f490e62..300357b5 100644 --- a/Platform/iOS/VMConfigDrivesView.swift +++ b/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 - 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 { diff --git a/Platform/iOS/zh-Hans.lproj/InfoPlist.strings b/Platform/iOS/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 00000000..72b59d4e --- /dev/null +++ b/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虚拟机"; + diff --git a/Platform/macOS/AppDelegate.swift b/Platform/macOS/AppDelegate.swift index 74c66976..e514d824 100644 --- a/Platform/macOS/AppDelegate.swift +++ b/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 + } + } } diff --git a/Platform/macOS/Display/VMDisplayMetalWindowController.swift b/Platform/macOS/Display/VMDisplayMetalWindowController.swift index fa73a264..2e630c7a 100644 --- a/Platform/macOS/Display/VMDisplayMetalWindowController.swift +++ b/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 } diff --git a/Platform/macOS/Display/VMDisplayTerminalWindowController.swift b/Platform/macOS/Display/VMDisplayTerminalWindowController.swift index d114e00c..f0960cd3 100644 --- a/Platform/macOS/Display/VMDisplayTerminalWindowController.swift +++ b/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)") + } } } } diff --git a/Platform/macOS/Display/VMDisplayWindow.xib b/Platform/macOS/Display/VMDisplayWindow.xib index 0b3767e7..4f2795bc 100644 --- a/Platform/macOS/Display/VMDisplayWindow.xib +++ b/Platform/macOS/Display/VMDisplayWindow.xib @@ -141,7 +141,7 @@ - +