mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
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
This commit is contained in:
@@ -23,13 +23,11 @@ struct VMConfigDriveDetailsView: View {
|
||||
private enum ConfirmItem: Identifiable {
|
||||
case reclaim(URL)
|
||||
case compress(URL)
|
||||
case resize(URL)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .reclaim(_): return 1
|
||||
case .compress(_): return 2
|
||||
case .resize(_): return 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +39,6 @@ struct VMConfigDriveDetailsView: View {
|
||||
|
||||
@State private var confirmAlert: ConfirmItem?
|
||||
@State private var isResizePopoverShown: Bool = false
|
||||
@State private var proposedSizeMib: Int = 0
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -103,50 +100,58 @@ struct VMConfigDriveDetailsView: View {
|
||||
DefaultTextField("Size", text: .constant(ByteCountFormatter.string(fromByteCount: Int64(config.sizeMib) * bytesInMib, countStyle: .binary))).disabled(true)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
if let imageUrl = config.imageURL, FileManager.default.fileExists(atPath: imageUrl.path) {
|
||||
Button {
|
||||
confirmAlert = .reclaim(imageUrl)
|
||||
} label: {
|
||||
Label("Reclaim Space", systemImage: "arrow.3.trianglepath")
|
||||
}.help("Reclaim disk space by re-converting the disk image.")
|
||||
|
||||
Button {
|
||||
confirmAlert = .compress(imageUrl)
|
||||
} label: {
|
||||
Label("Compress", systemImage: "arrowtriangle.right.and.line.vertical.and.arrowtriangle.left")
|
||||
}.help("Compress by re-converting the disk image and compressing the data.")
|
||||
|
||||
Button {
|
||||
isResizePopoverShown.toggle()
|
||||
} label: {
|
||||
Label("Resize…", systemImage: "arrowtriangle.left.and.line.vertical.and.arrowtriangle.right")
|
||||
}.help("Increase the size of the disk image.")
|
||||
.popover(isPresented: $isResizePopoverShown) {
|
||||
ResizePopoverView(imageURL: imageUrl, proposedSizeMib: $proposedSizeMib) {
|
||||
confirmAlert = .resize(imageUrl)
|
||||
}.padding()
|
||||
.frame(minHeight: 100)
|
||||
}
|
||||
}
|
||||
}.alert(item: $confirmAlert) { item in
|
||||
switch item {
|
||||
case .reclaim(let imageURL):
|
||||
return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space? Note this will require enough temporary space to perform the conversion. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: false) }, secondaryButton: .cancel())
|
||||
case .compress(let imageURL):
|
||||
return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space and apply compression? Note this will require enough temporary space to perform the conversion. Compression only applies to existing data and new data will still be written uncompressed. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: true) }, secondaryButton: .cancel())
|
||||
case .resize(let imageURL):
|
||||
return Alert(title: Text("Resizing is experimental and could result in data loss. You are strongly encouraged to back-up this VM before proceeding. Would you like to resize to \(proposedSizeMib / mibInGib) GiB?"), primaryButton: .destructive(Text("Resize")) {
|
||||
resizeDrive(for: imageURL, sizeInMib: proposedSizeMib)
|
||||
}, secondaryButton: .cancel())
|
||||
#if !WITH_REMOTE
|
||||
if UTMQemuImage.isSupported, let imageUrl = config.imageURL, FileManager.default.fileExists(atPath: imageUrl.path) {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
imageTools(for: imageUrl)
|
||||
}
|
||||
#else
|
||||
imageTools(for: imageUrl)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if !WITH_REMOTE
|
||||
.alert(item: $confirmAlert) { item in
|
||||
switch item {
|
||||
case .reclaim(let imageURL):
|
||||
return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space? Note this will require enough temporary space to perform the conversion. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: false) }, secondaryButton: .cancel())
|
||||
case .compress(let imageURL):
|
||||
return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space and apply compression? Note this will require enough temporary space to perform the conversion. Compression only applies to existing data and new data will still be written uncompressed. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: true) }, secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
#if !WITH_REMOTE
|
||||
@ViewBuilder
|
||||
private func imageTools(for imageUrl: URL) -> some View {
|
||||
Button {
|
||||
confirmAlert = .reclaim(imageUrl)
|
||||
} label: {
|
||||
Label("Reclaim Space", systemImage: "arrow.3.trianglepath")
|
||||
}.help("Reclaim disk space by re-converting the disk image.")
|
||||
|
||||
Button {
|
||||
confirmAlert = .compress(imageUrl)
|
||||
} label: {
|
||||
Label("Compress", systemImage: "arrowtriangle.right.and.line.vertical.and.arrowtriangle.left")
|
||||
}.help("Compress by re-converting the disk image and compressing the data.")
|
||||
|
||||
Button {
|
||||
isResizePopoverShown.toggle()
|
||||
} label: {
|
||||
Label("Resize…", systemImage: "arrowtriangle.left.and.line.vertical.and.arrowtriangle.right")
|
||||
}.help("Increase the size of the disk image.")
|
||||
.popover(isPresented: $isResizePopoverShown) {
|
||||
ResizePopoverView(imageURL: imageUrl) { sizeInMib in
|
||||
resizeDrive(for: imageUrl, sizeInMib: sizeInMib)
|
||||
}.padding()
|
||||
.frame(minHeight: 100)
|
||||
}
|
||||
}
|
||||
|
||||
private func reclaimSpace(for driveUrl: URL, withCompression isCompressed: Bool) {
|
||||
data.busyWorkAsync {
|
||||
try await data.reclaimSpace(for: driveUrl, withCompression: isCompressed)
|
||||
@@ -161,14 +166,17 @@ struct VMConfigDriveDetailsView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
#if !WITH_REMOTE
|
||||
/// Asks for the new size and confirms it here, as an alert on the form under this view
|
||||
/// is dropped on iOS when it is set while the sheet closes.
|
||||
private struct ResizePopoverView: View {
|
||||
let imageURL: URL
|
||||
@Binding var proposedSizeMib: Int
|
||||
let onConfirm: () -> Void
|
||||
let onConfirm: (Int) -> Void
|
||||
@EnvironmentObject private var data: UTMData
|
||||
|
||||
@State private var currentSize: Int64?
|
||||
@State private var proposedSizeMib: Int = 0
|
||||
@State private var isConfirming: Bool = false
|
||||
|
||||
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
|
||||
|
||||
@@ -192,14 +200,20 @@ private struct ResizePopoverView: View {
|
||||
SizeTextField($proposedSizeMib, minSizeMib: minSizeMib)
|
||||
Button("Resize") {
|
||||
if proposedSizeMib > minSizeMib {
|
||||
onConfirm()
|
||||
isConfirming = true
|
||||
} else {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ProgressView("Calculating current size...")
|
||||
}
|
||||
}.alert(isPresented: $isConfirming) {
|
||||
Alert(title: Text("Resizing is experimental and could result in data loss. You are strongly encouraged to back-up this VM before proceeding. Would you like to resize to \(proposedSizeMib / mibInGib) GiB?"), primaryButton: .destructive(Text("Resize")) {
|
||||
onConfirm(proposedSizeMib)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}, secondaryButton: .cancel())
|
||||
}.onAppear {
|
||||
Task { @MainActor in
|
||||
currentSize = await data.qcow2DriveSize(for: imageURL)
|
||||
|
||||
@@ -904,7 +904,7 @@ enum AlertItem: Identifiable {
|
||||
|
||||
// MARK: - Reclaim space
|
||||
|
||||
#if os(macOS)
|
||||
#if !WITH_REMOTE
|
||||
/// Reclaim empty space in a file by (re)-converting it to QCOW2
|
||||
///
|
||||
/// This will overwrite driveUrl with the converted file on success!
|
||||
@@ -943,7 +943,9 @@ enum AlertItem: Identifiable {
|
||||
let bytesinMib = 1048576
|
||||
try await UTMQemuImage.resize(image: driveUrl, size: UInt64(sizeInMib * bytesinMib))
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
@available(macOS 14, *)
|
||||
func appleDriveInfo(for driveUrl: URL) -> (format: String?, size: Int64?) {
|
||||
guard let info = try? UTMAppleDiskImage.info(for: driveUrl) else {
|
||||
|
||||
Reference in New Issue
Block a user