Browse Source

asif: implement resize and image info

Also works with RAW images.

Resolves #6891
osy 3 weeks ago
parent
commit
fcb537f52c

+ 7 - 3
Configuration/UTMConfigurationDrive.swift

@@ -78,6 +78,9 @@ extension UTMConfigurationDrive {
             if isRawImage {
                 #if os(macOS)
                 if let appleDrive = self as? UTMAppleConfigurationDrive, appleDrive.isASIF {
+                    guard #available(macOS 13, *) else {
+                        throw UTMAppleConfigurationError.featureNotSupported
+                    }
                     try await createAsifImage(at: newURL, size: sizeMib)
                 } else {
                     try await createRawImage(at: newURL, size: sizeMib)
@@ -120,7 +123,9 @@ extension UTMConfigurationDrive {
         }.value
         #endif
     }
-    
+
+    #if os(macOS)
+    @available(macOS 13, *)
     private func createAsifImage(at newURL: URL, size sizeMib: Int) async throws {
         let numBlocks = sizeMib * Int(bytesInMib) / 512
         guard let asif = UTMASIFImage.sharedInstance() else {
@@ -130,8 +135,7 @@ extension UTMConfigurationDrive {
             try asif.createBlank(with: newURL, numBlocks: numBlocks)
         }.value
     }
-    
-    #if os(macOS)
+
     private func convertQcow2Image(at sourceURL: URL, to destFolderURL: URL) async throws -> URL {
         let destQcow2 = UTMData.newImage(from: sourceURL,
                                          to: destFolderURL,

+ 25 - 0
Platform/UTMData.swift

@@ -908,6 +908,31 @@ enum AlertItem: Identifiable {
         let bytesinMib = 1048576
         try await UTMQemuImage.resize(image: driveUrl, size: UInt64(sizeInMib * bytesinMib))
     }
+
+    @available(macOS 14, *)
+    func appleDriveInfo(for driveUrl: URL) -> (format: String?, size: Int64?) {
+        var format: String? = nil
+        var size: Int64? = nil
+        guard let info = try? UTMASIFImage.sharedInstance()?.retrieveInfo(driveUrl) else {
+            return (format, size)
+        }
+        if let _format = info["Image Format"] as? String {
+            format = _format
+        }
+        if let _sizeInfo = info["Size Info"] as? [String: Any] {
+            if let _totalBytes = _sizeInfo["Total Bytes"] as? Int64 {
+                size = _totalBytes
+            }
+        }
+        return (format, size)
+    }
+
+    @available(macOS 14, *)
+    func resizeAppleDrive(for driveUrl: URL, sizeInMib: Int) throws {
+        let bytesinMib = 1048576
+        let size = Int(sizeInMib * bytesinMib)
+        try UTMASIFImage.sharedInstance()!.resize(with: driveUrl, size: size)
+    }
     #endif
     
     // MARK: - UUID migration

+ 109 - 7
Platform/macOS/VMConfigAppleDriveDetailsView.swift

@@ -16,10 +16,29 @@
 
 import SwiftUI
 
+private let bytesInMib: Int64 = 1024 * 1024
+private let mibInGib: Int = 1024
+
 struct VMConfigAppleDriveDetailsView: View {
+    private enum ConfirmItem: Identifiable {
+        case resize(URL)
+
+        var id: Int {
+            switch self {
+            case .resize(_): return 3
+            }
+        }
+    }
+
     @Binding var config: UTMAppleConfigurationDrive
     @Binding var requestDriveDelete: UTMAppleConfigurationDrive?
 
+    @EnvironmentObject private var data: UTMData
+    
+    @State private var confirmAlert: ConfirmItem?
+    @State private var isResizePopoverShown: Bool = false
+    @State private var proposedSizeMib: Int = 0
+
     var body: some View {
         Form {
             Toggle(isOn: $config.isExternal, label: {
@@ -34,13 +53,96 @@ struct VMConfigAppleDriveDetailsView: View {
                     Text("Use NVMe Interface")
                 }).help("If checked, use NVMe instead of virtio as the disk interface, available on macOS 14+ for Linux guests only. This interface is slower but less likely to encounter filesystem errors.")
             }
-            if #unavailable(macOS 12) {
-                Button {
-                    requestDriveDelete = config
-                } label: {
-                    Label("Delete Drive", systemImage: "externaldrive.badge.minus")
-                        .foregroundColor(.red)
-                }.help("Delete this drive.")
+            DefaultTextField("Size", text: .constant(config.sizeString)).disabled(true)
+            HStack {
+                if #unavailable(macOS 12) {
+                    Button {
+                        requestDriveDelete = config
+                    } label: {
+                        Label("Delete Drive", systemImage: "externaldrive.badge.minus")
+                            .foregroundColor(.red)
+                    }.help("Delete this drive.")
+                }
+
+                if #available(macOS 14, *), let imageUrl = config.imageURL, FileManager.default.fileExists(atPath: imageUrl.path) {
+                    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()
+                    }
+                }
+            }.alert(item: $confirmAlert) { item in
+                switch item {
+                case .resize(let imageURL):
+                    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())
+                }
+            }
+        }
+    }
+
+    private func resizeDrive(for driveUrl: URL, sizeInMib: Int) {
+        if #available(macOS 14, *) {
+            data.busyWorkAsync {
+                try await data.resizeAppleDrive(for: driveUrl, sizeInMib: sizeInMib)
+            }
+        }
+    }
+}
+
+@available(macOS 14, *)
+private struct ResizePopoverView: View {
+    let imageURL: URL
+    @Binding var proposedSizeMib: Int
+    let onConfirm: () -> Void
+    @EnvironmentObject private var data: UTMData
+
+    @State private var currentSize: Int64?
+    @State private var imageFormat: String?
+
+    @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
+
+    private var sizeString: String? {
+        if let currentSize = currentSize {
+            return ByteCountFormatter.string(fromByteCount: currentSize, countStyle: .binary)
+        } else {
+            return nil
+        }
+    }
+
+    private var minSizeMib: Int {
+        Int((currentSize! + bytesInMib - 1) / bytesInMib)
+    }
+
+    var body: some View {
+        VStack {
+            if let sizeString = sizeString {
+                if let imageFormat = imageFormat {
+                    Text("Image format: \(imageFormat)")
+                }
+                Text("Minimum size: \(sizeString)")
+                Form {
+                    SizeTextField($proposedSizeMib, minSizeMib: minSizeMib)
+                    Button("Resize") {
+                        if proposedSizeMib > minSizeMib {
+                            onConfirm()
+                        }
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            } else {
+                ProgressView("Calculating current size...")
+            }
+        }.onAppear {
+            Task { @MainActor in
+                (imageFormat, currentSize) = data.appleDriveInfo(for: imageURL)
+                proposedSizeMib = minSizeMib
             }
         }
     }

+ 3 - 1
Services/UTMASIFImage.h

@@ -22,7 +22,9 @@ NS_ASSUME_NONNULL_BEGIN
 
 + (nullable instancetype)sharedInstance;
 
-- (BOOL)createBlankWithURL:(NSURL *)url numBlocks:(NSInteger)numBlocks error:(NSError * _Nullable *)error;
+- (BOOL)createBlankWithURL:(NSURL *)url numBlocks:(NSInteger)numBlocks error:(NSError * _Nullable *)error API_AVAILABLE(macosx(13), ios(16), tvos(16), watchos(9));
+- (BOOL)resizeWithURL:(NSURL *)url size:(NSInteger)size error:(NSError * _Nullable *)error API_AVAILABLE(macosx(14), ios(17), tvos(17), watchos(10));
+- (nullable NSDictionary<NSString *, NSObject *> *)retrieveInfo:(NSURL *)url error:(NSError * _Nullable *)error API_AVAILABLE(macosx(14), ios(17), tvos(17), watchos(10));
 
 @end
 

+ 80 - 19
Services/UTMASIFImage.m

@@ -22,10 +22,16 @@ extern NSString *const kUTMErrorDomain;
 
 @interface UTMASIFImage ()
 
-@property (nonatomic, nonnull) Class DICreateASIFParams;
 @property (nonatomic, nonnull) Class DiskImages2;
-@property (nonatomic, nonnull) SEL DICreateASIFParamsInitSelector;
-@property (nonatomic, nonnull) SEL DiskImages2CreateSelector;
+@property (nonatomic) Class DICreateASIFParams;
+@property (nonatomic) Class DIResizeParams;
+@property (nonatomic) Class DIImageInfoParams;
+@property (nonatomic) SEL DICreateASIFParamsInitSelector;
+@property (nonatomic) SEL DIResizeParamsInitSelector;
+@property (nonatomic) SEL DIImageInfoParamsInitSelector;
+@property (nonatomic) SEL DiskImages2CreateSelector;
+@property (nonatomic) SEL DiskImages2ResizeSelector;
+@property (nonatomic) SEL DiskImages2RetrieveInfoSelector;
 @property (nonatomic, nonnull, readonly) NSError *notimplementedError;
 
 @end
@@ -64,24 +70,51 @@ extern NSString *const kUTMErrorDomain;
     self.DICreateASIFParams = [bundle classNamed:@"DICreateASIFParams"];
     if (!self.DICreateASIFParams) {
         UTMLog(@"Failed to load DICreateASIFParams");
-        return NO;
     }
     self.DiskImages2 = [bundle classNamed:@"DiskImages2"];
     if (!self.DiskImages2) {
         UTMLog(@"Failed to load DiskImages2");
         return NO;
     }
+    self.DIResizeParams = [bundle classNamed:@"DIResizeParams"];
+    if (!self.DICreateASIFParams) {
+        UTMLog(@"Failed to load DIResizeParams");
+    }
+    self.DIImageInfoParams = [bundle classNamed:@"DIImageInfoParams"];
+    if (!self.DICreateASIFParams) {
+        UTMLog(@"Failed to load DIImageInfoParams");
+    }
 
     self.DICreateASIFParamsInitSelector = NSSelectorFromString(@"initWithURL:numBlocks:error:");
     self.DiskImages2CreateSelector = NSSelectorFromString(@"createBlankWithParams:error:");
+    self.DIResizeParamsInitSelector = NSSelectorFromString(@"initWithURL:size:error:");
+    self.DiskImages2ResizeSelector = NSSelectorFromString(@"resizeWithParams:error:");
+    self.DIImageInfoParamsInitSelector = NSSelectorFromString(@"initWithURL:error:");
+    self.DiskImages2RetrieveInfoSelector = NSSelectorFromString(@"retrieveInfoWithParams:error:");
 
     if (![self.DICreateASIFParams instancesRespondToSelector:self.DICreateASIFParamsInitSelector]) {
         UTMLog(@"DICreateASIFParams does not respond to 'initWithURL:numBlocks:error:'");
-        return NO;
+        self.DICreateASIFParamsInitSelector = nil;
     }
     if (![self.DiskImages2 respondsToSelector:self.DiskImages2CreateSelector]) {
         UTMLog(@"DiskImages2 does not respond to '+createBlankWithParams:error:'");
-        return NO;
+        self.DiskImages2CreateSelector = nil;
+    }
+    if (![self.DIResizeParams instancesRespondToSelector:self.DIResizeParamsInitSelector]) {
+        UTMLog(@"DIResizeParams does not respond to 'initWithURL:size:error:'");
+        self.DIResizeParamsInitSelector = nil;
+    }
+    if (![self.DiskImages2 respondsToSelector:self.DiskImages2ResizeSelector]) {
+        UTMLog(@"DiskImages2 does not respond to '+resizeWithParams:error:'");
+        self.DiskImages2ResizeSelector = nil;
+    }
+    if (![self.DIImageInfoParams instancesRespondToSelector:self.DIImageInfoParamsInitSelector]) {
+        UTMLog(@"DIImageInfoParams does not respond to 'initWithURL:error:'");
+        self.DIImageInfoParamsInitSelector = nil;
+    }
+    if (![self.DiskImages2 respondsToSelector:self.DiskImages2RetrieveInfoSelector]) {
+        UTMLog(@"DiskImages2 does not respond to '+retrieveInfoWithParams:error:'");
+        self.DiskImages2RetrieveInfoSelector = nil;
     }
     return YES;
 }
@@ -90,22 +123,26 @@ extern NSString *const kUTMErrorDomain;
     return [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Not implemented.", "UTMASIFImage")}];
 }
 
-- (id)callDICreateASIFParamsInitWithURL:(NSURL *)url numBlocks:(NSInteger)numBlocks error:(NSError * _Nullable *)error {
-    id params = [self.DICreateASIFParams alloc];
-    if (!params) {
+- (id)callInitSelector:(SEL)selector class:(Class)class URL:(NSURL *)url hasArg1:(BOOL)hasArg1 arg1:(NSInteger)arg1 error:(NSError * _Nullable *)error {
+    id params = [class alloc];
+    if (!params || !class || !selector) {
         *error = self.notimplementedError;
         return nil;
     }
 
-    NSMethodSignature *sig = [params methodSignatureForSelector:self.DICreateASIFParamsInitSelector];
+    NSMethodSignature *sig = [params methodSignatureForSelector:selector];
     NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
-    [inv setSelector:self.DICreateASIFParamsInitSelector];
+    [inv setSelector:selector];
     [inv setTarget:params];
 
     // Set arguments: indexes 0 and 1 are self and _cmd
     [inv setArgument:&url atIndex:2];
-    [inv setArgument:&numBlocks atIndex:3];
-    [inv setArgument:error atIndex:4];
+    if (hasArg1) {
+        [inv setArgument:&arg1 atIndex:3];
+        [inv setArgument:error atIndex:4];
+    } else {
+        [inv setArgument:error atIndex:3];
+    }
 
     [inv invoke];
 
@@ -114,10 +151,15 @@ extern NSString *const kUTMErrorDomain;
     return result;
 }
 
-- (BOOL)callDiskImage2CreateBlankWithParams:(id)params error:(NSError * _Nullable *)error {
-    NSMethodSignature *sig = [self.DiskImages2 methodSignatureForSelector:self.DiskImages2CreateSelector];
+- (BOOL)callDiskImage2Selector:(SEL)selector params:(id)params error:(NSError * _Nullable *)error {
+    if (!selector) {
+        *error = self.notimplementedError;
+        return nil;
+    }
+
+    NSMethodSignature *sig = [self.DiskImages2 methodSignatureForSelector:selector];
     NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
-    [inv setSelector:self.DiskImages2CreateSelector];
+    [inv setSelector:selector];
     [inv setTarget:self.DiskImages2];
 
     [inv setArgument:&params atIndex:2];
@@ -130,12 +172,31 @@ extern NSString *const kUTMErrorDomain;
     return success;
 }
 
-- (BOOL)createBlankWithURL:(NSURL *)url numBlocks:(NSInteger)numBlocks error:(NSError * _Nullable *)error {
-    id params = [self callDICreateASIFParamsInitWithURL:url numBlocks:numBlocks error:error];
+- (BOOL)createBlankWithURL:(NSURL *)url numBlocks:(NSInteger)numBlocks error:(NSError * _Nullable *)error API_AVAILABLE(macosx(13), ios(16), tvos(16), watchos(9)) {
+    id params = [self callInitSelector:self.DICreateASIFParamsInitSelector class:self.DICreateASIFParams URL:url hasArg1:YES arg1:numBlocks error:error];
+    if (!params) {
+        return NO;
+    }
+    return [self callDiskImage2Selector:self.DiskImages2CreateSelector params:params error:error];
+}
+
+- (BOOL)resizeWithURL:(NSURL *)url size:(NSInteger)size error:(NSError * _Nullable *)error API_AVAILABLE(macosx(14), ios(17), tvos(17), watchos(10)) {
+    id params = [self callInitSelector:self.DIResizeParamsInitSelector class:self.DIResizeParams URL:url hasArg1:YES arg1:size error:error];
     if (!params) {
         return NO;
     }
-    return [self callDiskImage2CreateBlankWithParams:params error:error];
+    return [self callDiskImage2Selector:self.DiskImages2ResizeSelector params:params error:error];
+}
+
+- (nullable NSDictionary<NSString *, NSObject *> *)retrieveInfo:(NSURL *)url error:(NSError * _Nullable *)error API_AVAILABLE(macosx(14), ios(17), tvos(17), watchos(10)) {
+    id params = [self callInitSelector:self.DIImageInfoParamsInitSelector class:self.DIImageInfoParams URL:url hasArg1:NO arg1:0 error:error];
+    if (!params) {
+        return nil;
+    }
+    if (![self callDiskImage2Selector:self.DiskImages2RetrieveInfoSelector params:params error:error]) {
+        return nil;
+    }
+    return [params valueForKey:@"imageInfo"];
 }
 
 @end