mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
QEMU tools cannot run in the app process on iOS: a process may load QEMU only once and qemu-img calls exit(). So disk snapshots of a stopped VM, reclaiming space, compression, resizing and conversion on import were macOS-only. iOS 26 brings ExtensionKit, so qemu-img now runs in an ExtensionKit extension (iOSHelper, and iOSHelper-SE for UTM SE since an extension's bundle identifier must be prefixed by its host's). The helper speaks the same QEMUHelperProtocol as the macOS XPC helper but loads the tool into its own process, one tool per process. UTMProcess accepts an injected connection so the remote path is shared with macOS, including file access through bookmarks, whose implicit security scope grants the resolving process access. UTMHelperProcess launches the helper and, because the system hands a process that is still exiting to a launch that comes too soon, tears it down with a handshake on the interruption callback and runs helpers one at a time. UTMQemuImage now reads the tool's output until it ends instead of racing the exit message, resumes its continuations off the XPC reply queue (a call made from that queue blocks in NSXPC), and stops the process explicitly since the connection's exported object kept it from ever being deallocated on macOS. The last stderr line becomes the error text when the tool gives none. Assisted-by: Claude:claude-fable-5-1
392 lines
13 KiB
Objective-C
392 lines
13 KiB
Objective-C
//
|
|
// Copyright © 2019 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 "UTMProcess.h"
|
|
#import "UTMLogging.h"
|
|
#import "QEMUHelperDelegate.h"
|
|
#import "QEMUHelperProtocol.h"
|
|
#import <dlfcn.h>
|
|
#import <pthread.h>
|
|
#import <TargetConditionals.h>
|
|
|
|
extern NSString *const kUTMErrorDomain;
|
|
|
|
@interface UTMProcess ()
|
|
|
|
@property (nonatomic) dispatch_queue_t completionQueue;
|
|
@property (nonatomic) dispatch_semaphore_t done;
|
|
@property (nonatomic, nullable) NSString *processName;
|
|
|
|
@end
|
|
|
|
@implementation UTMProcess {
|
|
NSMutableArray<NSString *> *_argv;
|
|
NSMutableArray<NSURL *> *_urls;
|
|
NSXPCConnection *_connection;
|
|
}
|
|
|
|
static void *startProcess(void *args) {
|
|
UTMProcess *self = (__bridge_transfer UTMProcess *)args;
|
|
NSArray<NSString *> *processArgv = self.argv;
|
|
NSMutableArray<NSString *> *environment = [NSMutableArray arrayWithCapacity:self.environment.count];
|
|
|
|
/* set up environment variables */
|
|
[self.environment enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, BOOL *stop) {
|
|
NSString *combined = [NSString stringWithFormat:@"%@=%@", key, value];
|
|
[environment addObject:combined];
|
|
setenv(key.UTF8String, value.UTF8String, 1);
|
|
}];
|
|
NSUInteger envc = environment.count;
|
|
const char *envp[envc + 1];
|
|
for (NSUInteger i = 0; i < envc; i++) {
|
|
envp[i] = environment[i].UTF8String;
|
|
}
|
|
envp[envc] = NULL;
|
|
setenv("TMPDIR", NSFileManager.defaultManager.temporaryDirectory.path.UTF8String, 1);
|
|
|
|
const char *currentDirectoryPath = self.currentDirectoryUrl.path.UTF8String;
|
|
if (currentDirectoryPath) {
|
|
chdir(currentDirectoryPath);
|
|
}
|
|
|
|
int argc = (int)processArgv.count + 1;
|
|
const char *argv[argc];
|
|
argv[0] = [self.processName cStringUsingEncoding:NSUTF8StringEncoding];
|
|
if (!argv[0]) {
|
|
argv[0] = "process";
|
|
}
|
|
for (int i = 0; i < processArgv.count; i++) {
|
|
argv[i+1] = [processArgv[i] UTF8String];
|
|
}
|
|
self.status = self.entry(self, argc, argv, envp);
|
|
dispatch_semaphore_signal(self.done);
|
|
return NULL;
|
|
}
|
|
|
|
static int defaultEntry(UTMProcess *self, int argc, const char *argv[], const char *envp[]) {
|
|
return -1;
|
|
}
|
|
|
|
#pragma mark - Properties
|
|
|
|
@synthesize argv = _argv;
|
|
|
|
- (NSURL *)libraryURL {
|
|
NSURL *bundleURL = [[NSBundle mainBundle] bundleURL];
|
|
#if TARGET_OS_IPHONE
|
|
NSURL *contentsURL = bundleURL;
|
|
#else
|
|
NSURL *contentsURL = [bundleURL URLByAppendingPathComponent:@"Contents" isDirectory:YES];
|
|
#endif
|
|
NSURL *frameworksURL = [contentsURL URLByAppendingPathComponent:@"Frameworks" isDirectory:YES];
|
|
return frameworksURL;
|
|
}
|
|
|
|
- (BOOL)hasRemoteProcess {
|
|
return _connection != nil;
|
|
}
|
|
|
|
- (NSString *)arguments {
|
|
NSString *args = @"";
|
|
for (NSString *arg in _argv) {
|
|
if ([arg containsString:@" "]) {
|
|
args = [args stringByAppendingFormat:@" \"%@\"", arg];
|
|
} else {
|
|
args = [args stringByAppendingFormat:@" %@", arg];
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
#pragma mark - Construction
|
|
|
|
- (instancetype)init {
|
|
return [self initWithArguments:[NSArray<NSString *> array]];
|
|
}
|
|
|
|
- (instancetype)initWithArguments:(NSArray<NSString *> *)arguments {
|
|
if (self = [super init]) {
|
|
if (![self setupWithArguments:arguments connection:nil]) {
|
|
return nil;
|
|
}
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (instancetype)initWithArguments:(NSArray<NSString *> *)arguments connection:(nullable NSXPCConnection *)connection {
|
|
if (self = [super init]) {
|
|
if (![self setupWithArguments:arguments connection:connection]) {
|
|
return nil;
|
|
}
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (BOOL)setupWithArguments:(NSArray<NSString *> *)arguments connection:(nullable NSXPCConnection *)connection {
|
|
_argv = [arguments mutableCopy];
|
|
_urls = [NSMutableArray<NSURL *> array];
|
|
if (![self setupXpcWithConnection:connection]) {
|
|
return NO;
|
|
}
|
|
dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, QOS_MIN_RELATIVE_PRIORITY);
|
|
self.completionQueue = dispatch_queue_create("QEMU Completion Queue", attr);
|
|
self.entry = defaultEntry;
|
|
self.done = dispatch_semaphore_create(0);
|
|
return YES;
|
|
}
|
|
|
|
- (void)dealloc {
|
|
[self stopProcess];
|
|
}
|
|
|
|
#pragma mark - Methods
|
|
|
|
- (BOOL)setupXpcWithConnection:(nullable NSXPCConnection *)connection {
|
|
#if TARGET_OS_OSX
|
|
if (!connection) {
|
|
NSString *helperIdentifier = NSBundle.mainBundle.infoDictionary[@"HelperIdentifier"];
|
|
if (!helperIdentifier) {
|
|
helperIdentifier = @"com.utmapp.QEMUHelper";
|
|
}
|
|
connection = [[NSXPCConnection alloc] initWithServiceName:helperIdentifier];
|
|
if (!connection) {
|
|
return NO;
|
|
}
|
|
}
|
|
#else // runs in a thread unless a helper is provided
|
|
if (!connection) {
|
|
return YES;
|
|
}
|
|
#endif
|
|
_connection = connection;
|
|
_connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(QEMUHelperProtocol)];
|
|
_connection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(QEMUHelperDelegate)];
|
|
_connection.exportedObject = self;
|
|
[_connection resume];
|
|
return YES;
|
|
}
|
|
|
|
- (void)pushArgv:(nullable NSString *)arg {
|
|
NSAssert(arg, @"Cannot push null argument!");
|
|
[_argv addObject:arg];
|
|
}
|
|
|
|
- (void)clearArgv {
|
|
[_argv removeAllObjects];
|
|
}
|
|
|
|
- (BOOL)didLoadDylib:(void *)handle {
|
|
return YES;
|
|
}
|
|
|
|
- (void)startDylibThread:(nonnull NSString *)dylib completion:(nonnull void (^)(NSError * _Nullable))completion {
|
|
void *dlctx;
|
|
__block pthread_t qemu_thread;
|
|
pthread_attr_t qosAttribute;
|
|
__weak typeof(self) wself = self;
|
|
|
|
NSAssert(self.entry != NULL, @"entry is NULL!");
|
|
self.status = self.fatal = 0;
|
|
UTMLog(@"Loading %@", dylib);
|
|
dlctx = dlopen([dylib UTF8String], RTLD_LOCAL);
|
|
if (dlctx == NULL) {
|
|
NSString *err = [NSString stringWithUTF8String:dlerror()];
|
|
completion([self errorWithMessage:err]);
|
|
return;
|
|
}
|
|
if (![self didLoadDylib:dlctx]) {
|
|
NSString *err = [NSString stringWithUTF8String:dlerror()];
|
|
dlclose(dlctx);
|
|
completion([self errorWithMessage:err]);
|
|
return;
|
|
}
|
|
if (atexit_b(^{
|
|
if (pthread_self() == qemu_thread) {
|
|
__strong typeof(self) sself = wself;
|
|
if (sself) {
|
|
sself->_fatal = 1;
|
|
dispatch_semaphore_signal(sself->_done);
|
|
}
|
|
pthread_exit(NULL);
|
|
}
|
|
}) != 0) {
|
|
completion([self errorWithMessage:NSLocalizedString(@"Internal error has occurred.", @"UTMProcess")]);
|
|
return;
|
|
}
|
|
pthread_attr_init(&qosAttribute);
|
|
pthread_attr_set_qos_class_np(&qosAttribute, QOS_CLASS_USER_INTERACTIVE, 0);
|
|
pthread_create(&qemu_thread, &qosAttribute, startProcess, (__bridge_retained void *)self);
|
|
dispatch_async(self.completionQueue, ^{
|
|
if (dispatch_semaphore_wait(self.done, DISPATCH_TIME_FOREVER)) {
|
|
dlclose(dlctx);
|
|
[self processHasExited:-1 message:NSLocalizedString(@"Internal error has occurred.", @"UTMProcess")];
|
|
} else {
|
|
if (dlclose(dlctx) < 0) {
|
|
NSString *err = [NSString stringWithUTF8String:dlerror()];
|
|
[self processHasExited:-1 message:err];
|
|
} else if (self.fatal || self.status) {
|
|
[self processHasExited:-1 message:nil];
|
|
} else {
|
|
[self processHasExited:0 message:nil];
|
|
}
|
|
}
|
|
});
|
|
completion(nil);
|
|
}
|
|
|
|
- (void)startQemuRemote:(nonnull NSString *)name completion:(nonnull void (^)(NSError * _Nullable))completion {
|
|
NSError *error;
|
|
NSData *libBookmark = [self.libraryURL bookmarkDataWithOptions:0
|
|
includingResourceValuesForKeys:nil
|
|
relativeToURL:nil
|
|
error:&error];
|
|
if (!libBookmark) {
|
|
completion(error);
|
|
return;
|
|
}
|
|
__weak typeof(self) _self = self;
|
|
NSFileHandle *standardOutput = self.standardOutput.fileHandleForWriting;
|
|
NSFileHandle *standardError = self.standardError.fileHandleForWriting;
|
|
[_connection.remoteObjectProxy setEnvironment:self.environment];
|
|
[_connection.remoteObjectProxy setCurrentDirectoryPath:self.currentDirectoryUrl.path];
|
|
// this is needed to prevent XNU from terminating an idle XPC helper
|
|
[_connection.remoteObjectProxy assertActiveWithToken:^(BOOL ignored) {
|
|
// do nothing
|
|
}];
|
|
[[_connection remoteObjectProxyWithErrorHandler:^(NSError * _Nonnull error) {
|
|
if (error.domain == NSCocoaErrorDomain && error.code == NSXPCConnectionInvalid) {
|
|
// inhibit this error since we always see it on quit
|
|
[_self processHasExited:0 message:nil];
|
|
} else {
|
|
[_self processHasExited:error.code message:error.localizedDescription];
|
|
}
|
|
}] startQemu:name standardOutput:standardOutput standardError:standardError libraryBookmark:libBookmark argv:self.argv completion:^(BOOL success, NSString *msg){
|
|
if (!success) {
|
|
completion([self errorWithMessage:msg]);
|
|
} else {
|
|
completion(nil);
|
|
}
|
|
}];
|
|
}
|
|
|
|
- (void)startProcess:(nonnull NSString *)name completion:(nonnull void (^)(NSError * _Nullable))completion {
|
|
#if TARGET_OS_IPHONE
|
|
NSString *base = @"";
|
|
#else
|
|
NSString *base = @"Versions/A/";
|
|
#endif
|
|
NSString *dylib = [NSString stringWithFormat:@"%@.framework/%@%@", name, base, name];
|
|
self.processName = name;
|
|
if (_connection) {
|
|
[self startQemuRemote:dylib completion:completion];
|
|
} else {
|
|
[self startDylibThread:dylib completion:completion];
|
|
}
|
|
}
|
|
|
|
- (void)stopProcess {
|
|
if (_connection) {
|
|
[[_connection remoteObjectProxy] terminate];
|
|
[_connection invalidate];
|
|
}
|
|
for (NSURL *url in _urls) {
|
|
[url stopAccessingSecurityScopedResource];
|
|
}
|
|
}
|
|
|
|
- (void)accessDataWithBookmarkThread:(NSData *)bookmark securityScoped:(BOOL)securityScoped completion:(void(^)(BOOL, NSData * _Nullable, NSString * _Nullable))completion {
|
|
BOOL stale = NO;
|
|
NSError *err;
|
|
NSURL *url = [NSURL URLByResolvingBookmarkData:bookmark
|
|
options:0
|
|
relativeToURL:nil
|
|
bookmarkDataIsStale:&stale
|
|
error:&err];
|
|
if (!url) {
|
|
UTMLog(@"Failed to access bookmark data.");
|
|
completion(NO, nil, nil);
|
|
return;
|
|
}
|
|
if (stale || !securityScoped) {
|
|
bookmark = [url bookmarkDataWithOptions:NSURLBookmarkCreationMinimalBookmark
|
|
includingResourceValuesForKeys:nil
|
|
relativeToURL:nil
|
|
error:&err];
|
|
if (!bookmark) {
|
|
UTMLog(@"Failed to create new bookmark!");
|
|
completion(NO, bookmark, url.path);
|
|
return;
|
|
}
|
|
}
|
|
if ([url startAccessingSecurityScopedResource]) {
|
|
[_urls addObject:url];
|
|
} else {
|
|
UTMLog(@"Failed to access security scoped resource for: %@", url);
|
|
}
|
|
completion(YES, bookmark, url.path);
|
|
}
|
|
|
|
- (void)accessDataWithBookmark:(NSData *)bookmark {
|
|
[self accessDataWithBookmark:bookmark securityScoped:NO completion:^(BOOL success, NSData *bookmark, NSString *path) {
|
|
if (!success) {
|
|
UTMLog(@"Access bookmark failed for: %@", path);
|
|
}
|
|
}];
|
|
}
|
|
|
|
- (void)accessDataWithBookmark:(NSData *)bookmark securityScoped:(BOOL)securityScoped completion:(void(^)(BOOL, NSData * _Nullable, NSString * _Nullable))completion {
|
|
if (_connection) {
|
|
[[_connection remoteObjectProxyWithErrorHandler:^(NSError *error) {
|
|
UTMLog(@"Failed to pass bookmark to the helper: %@", error);
|
|
completion(NO, nil, nil);
|
|
}] accessDataWithBookmark:bookmark securityScoped:securityScoped completion:completion];
|
|
} else {
|
|
[self accessDataWithBookmarkThread:bookmark securityScoped:securityScoped completion:completion];
|
|
}
|
|
}
|
|
|
|
- (void)stopAccessingPathThread:(nullable NSString *)path {
|
|
if (!path) {
|
|
return;
|
|
}
|
|
for (NSURL *url in _urls) {
|
|
if ([url.path isEqualToString:path]) {
|
|
[url stopAccessingSecurityScopedResource];
|
|
[_urls removeObject:url];
|
|
return;
|
|
}
|
|
}
|
|
UTMLog(@"Cannot find '%@' in existing scoped access.", path);
|
|
}
|
|
|
|
- (void)stopAccessingPath:(nullable NSString *)path {
|
|
if (_connection) {
|
|
[[_connection remoteObjectProxy] stopAccessingPath:path];
|
|
} else {
|
|
[self stopAccessingPathThread:path];
|
|
}
|
|
}
|
|
|
|
- (NSError *)errorWithMessage:(nullable NSString *)message {
|
|
return [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: message}];
|
|
}
|
|
|
|
- (void)processHasExited:(NSInteger)exitCode message:(nullable NSString *)message {
|
|
UTMLog(@"QEMU has exited with code %ld and message %@", exitCode, message);
|
|
}
|
|
|
|
@end
|