Files
UTM/iOSHelper/iOSHelper.swift
T
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

171 lines
6.4 KiB
Swift

//
// Copyright © 2026 Turing Software, LLC. 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 ExtensionFoundation
/// Runs a QEMU tool such as qemu-img for the app, out of the app's process.
///
/// Like the macOS `QEMUHelper`, this speaks `QEMUHelperProtocol` and reaches the app's files
/// through the bookmarks it is passed, but QEMU is loaded into the helper itself as iOS cannot
/// spawn processes. A QEMU tool can run once per process, so the helper exits when its
/// connection ends and the app launches a new one for the next tool.
@main
final class iOSHelper: AppExtension {
required init() {
}
var configuration: some AppExtensionConfiguration {
ConnectionHandler { connection in
connection.exportedInterface = NSXPCInterface(with: QEMUHelperProtocol.self)
connection.exportedObject = QEMUHelperService(connection: connection)
connection.remoteObjectInterface = NSXPCInterface(with: QEMUHelperDelegate.self)
connection.invalidationHandler = {
_exit(0)
}
connection.resume()
return true
}
}
}
final class QEMUHelperService: NSObject, QEMUHelperProtocol {
typealias MainFunction = @convention(c) (Int32, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
var environment: [String: String]?
var currentDirectoryPath: String?
private weak var connection: NSXPCConnection?
private var urls = [URL]()
private var activeToken: tokenCallback_t?
private var isStarted = false
init(connection: NSXPCConnection) {
self.connection = connection
}
func accessData(withBookmark bookmark: Data, securityScoped: Bool, completion: @escaping (Bool, Data?, String?) -> Void) {
var isStale = false
let url: URL
do {
url = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale)
} catch {
NSLog("Failed to access bookmark data: \(error)")
completion(false, nil, nil)
return
}
if url.startAccessingSecurityScopedResource() {
urls.append(url)
} else {
NSLog("Failed to access security scoped resource for: \(url)")
}
completion(true, bookmark, url.path)
}
func stopAccessingPath(_ path: String?) {
guard let path = path, let index = urls.firstIndex(where: { $0.path == path }) else {
return
}
urls[index].stopAccessingSecurityScopedResource()
urls.remove(at: index)
}
func startQemu(_ binName: String, standardOutput: FileHandle, standardError: FileHandle, libraryBookmark libBookmark: Data, argv: [String], completion: @escaping (Bool, String?) -> Void) {
guard !isStarted else {
completion(false, NSLocalizedString("The helper is already in use.", comment: "iOSHelper"))
return
}
isStarted = true
var isStale = false
guard let libraryURL = try? URL(resolvingBookmarkData: libBookmark, bookmarkDataIsStale: &isStale),
FileManager.default.fileExists(atPath: libraryURL.path) else {
NSLog("Cannot resolve library path")
completion(false, NSLocalizedString("Cannot find QEMU support libraries.", comment: "iOSHelper"))
return
}
// the tool writes to the app's pipes as if it were a process of its own
dup2(standardOutput.fileDescriptor, STDOUT_FILENO)
dup2(standardError.fileDescriptor, STDERR_FILENO)
try? standardOutput.close()
try? standardError.close()
let dylibURL = libraryURL.appendingPathComponent(binName)
let thread = Thread { [self] in
runQemu(dylib: dylibURL, name: dylibURL.lastPathComponent, argv: argv)
}
thread.qualityOfService = .userInitiated
thread.start()
completion(true, nil)
}
func terminate() {
_exit(0)
}
func assertActive(token: @escaping tokenCallback_t) {
activeToken?(false)
activeToken = token
}
private func runQemu(dylib: URL, name: String, argv: [String]) {
guard let handle = dlopen(dylib.path, RTLD_LOCAL | RTLD_FIRST) else {
processHasExited(-1, message: String(cString: dlerror()))
return
}
guard let symbol = dlsym(handle, "main") else {
processHasExited(-1, message: String(cString: dlerror()))
return
}
let main = unsafeBitCast(symbol, to: MainFunction.self)
for (key, value) in environment ?? [:] {
setenv(key, value, 1)
}
setenv("TMPDIR", FileManager.default.temporaryDirectory.path, 1)
if let currentDirectoryPath = currentDirectoryPath {
chdir(currentDirectoryPath)
}
// a tool that calls exit() is reported as a failure instead of taking the helper down silently
let qemuThread = pthread_self()
atexit_b { [self] in
if pthread_equal(pthread_self(), qemuThread) != 0 {
endOutput()
processHasExited(-1, message: nil)
pthread_exit(nil)
}
}
var cArgv = ([name] + argv).map { strdup($0) } + [nil]
let status = main(Int32(argv.count + 1), &cArgv)
endOutput()
processHasExited(Int(status), message: nil)
}
/// Gives the app's pipes back so it sees the end of the output
private func endOutput() {
fflush(nil)
let null = open("/dev/null", O_WRONLY)
if null >= 0 {
dup2(null, STDOUT_FILENO)
dup2(null, STDERR_FILENO)
close(null)
}
}
private func processHasExited(_ exitCode: Int, message: String?) {
(connection?.remoteObjectProxy as? QEMUHelperDelegate)?.processHasExited(exitCode, message: message)
activeToken?(true)
activeToken = nil
}
}