Files
osy 3359543d2c ios: run qemu-img in a helper extension on iOS 26
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
2026-09-23 23:10:06 -05:00

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