UTM/Platform/macOS/VMAppleRemovableDrivesView....

215 lines
8.6 KiB
Swift

//
// Copyright © 2021 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 SwiftUI
struct VMAppleRemovableDrivesView: View {
private enum SelectType {
case sharedDirectory
case diskImage
}
@ObservedObject var vm: UTMAppleVirtualMachine
@ObservedObject var config: UTMAppleConfiguration
@ObservedObject var registryEntry: UTMRegistryEntry
@EnvironmentObject private var data: UTMData
@State private var fileImportPresented: Bool = false
@State private var selectType: SelectType = .sharedDirectory
@State private var selectedSharedDirectoryBinding: Binding<UTMRegistryEntry.File>?
@State private var selectedDiskImage: UTMAppleConfigurationDrive?
/// Explanation see "SwiftUI FileImporter modal bug" in `showFileImporter`
@State private var workaroundFileImporterBug: Bool = false
private var hasSharingFeatures: Bool {
if #available(macOS 13, *) {
return true
} else if #available(macOS 12, *), config.system.boot.operatingSystem == .linux {
return true
} else {
return false
}
}
var body: some View {
Group {
ForEach($registryEntry.sharedDirectories) { $sharedDirectory in
HStack {
// Browse/Clear menu
Menu {
// Browse button
Button(action: {
selectType = .sharedDirectory
selectedSharedDirectoryBinding = $sharedDirectory
showFileImporter()
}, label: {
Label("Browse…", systemImage: "doc.badge.plus")
})
// Clear button
Button(action: {
deleteShareDirectory(sharedDirectory)
}, label: {
Label("Remove", systemImage: "eject")
})
} label: {
Label("Shared Directory", systemImage: "externaldrive.fill.badge.person.crop")
}
Spacer()
FilePath(url: sharedDirectory.url)
}
}
ForEach($config.drives) { $diskImage in
HStack {
if diskImage.isExternal {
// Drive menu
Menu {
// Browse button
Button(action: {
selectType = .diskImage
selectedDiskImage = diskImage
showFileImporter()
}, label: {
Label("Browse…", systemImage: "doc.badge.plus")
})
// Eject button
if diskImage.isExternal && diskImage.imageURL != nil {
Button(action: { clearRemovableImage(diskImage) }, label: {
Label("Clear", systemImage: "eject")
})
}
} label: {
Label("External Drive", systemImage: "externaldrive")
}.disabled(vm.hasSaveState || vm.state != .vmStopped)
} else {
Label("\(diskImage.sizeString) Drive", systemImage: "internaldrive")
}
Spacer()
// Disk image path, or (empty)
FilePath(url: diskImage.imageURL)
}
}
HStack {
Spacer()
if hasSharingFeatures {
Button("New Shared Directory…") {
selectType = .sharedDirectory
selectedSharedDirectoryBinding = nil
showFileImporter()
}
}
}.fileImporter(isPresented: $fileImportPresented, allowedContentTypes: selectType == .sharedDirectory ? [.folder] : [.data]) { result in
if selectType == .sharedDirectory {
if let binding = selectedSharedDirectoryBinding {
selectShareDirectory(for: binding, result: result)
selectedSharedDirectoryBinding = nil
} else {
createShareDirectory(result)
}
} else {
if let diskImage = selectedDiskImage {
selectRemovableImage(for: diskImage, result: result)
selectedDiskImage = nil
}
}
}.onChange(of: workaroundFileImporterBug) { doWorkaround in
/// Explanation see "SwiftUI FileImporter modal bug" below
if doWorkaround {
DispatchQueue.main.async {
workaroundFileImporterBug = false
fileImportPresented = true
}
}
}
}
}
private struct FilePath: View {
let url: URL?
var body: some View {
if let url = url {
Text(url.lastPathComponent)
.truncationMode(.head)
.lineLimit(1)
.foregroundColor(.secondary)
} else {
Text("(empty)")
.foregroundColor(.secondary)
}
}
}
private func showFileImporter() {
// MARK: SwiftUI FileImporter modal bug
/// At this point in the execution, `diskImageFileImportPresented` must be `false`.
/// However there is a SwiftUI FileImporter modal bug:
/// if the user taps outside the import modal to cancel instead of tapping the actual cancel button,
/// the `.fileImporter` doesn't actually set the isPresented Binding to `false`.
if (fileImportPresented) {
/// bug! Let's set the bool to false ourselves.
fileImportPresented = false
/// One more thing: we can't immediately set it to `true` again because then the state won't have changed.
/// So we have to use the workaround, which is caught in the `.onChange` below.
workaroundFileImporterBug = true
} else {
fileImportPresented = true
}
}
private func selectShareDirectory(for binding: Binding<UTMRegistryEntry.File>, result: Result<URL, Error>) {
data.busyWorkAsync {
let url = try result.get()
binding.wrappedValue.url = url
}
}
private func createShareDirectory(_ result: Result<URL, Error>) {
data.busyWorkAsync {
let url = try result.get()
let sharedDirectory = try UTMRegistryEntry.File(url: url)
await MainActor.run {
registryEntry.sharedDirectories.append(sharedDirectory)
}
}
}
private func deleteShareDirectory(_ sharedDirectory: UTMRegistryEntry.File) {
vm.registryEntry.sharedDirectories.removeAll { existing in
existing.url == sharedDirectory.url
}
}
private func selectRemovableImage(for diskImage: UTMAppleConfigurationDrive, result: Result<URL, Error>) {
data.busyWorkAsync {
let url = try result.get()
let file = try UTMRegistryEntry.File(url: url)
await registryEntry.setExternalDrive(file, forId: diskImage.id)
}
}
private func clearRemovableImage(_ diskImage: UTMAppleConfigurationDrive) {
registryEntry.removeExternalDrive(forId: diskImage.id)
}
}
struct VMAppleRemovableDrivesView_Previews: PreviewProvider {
@StateObject static var vm = UTMAppleVirtualMachine()
@StateObject static var config = UTMAppleConfiguration()
static var previews: some View {
VMAppleRemovableDrivesView(vm: vm, config: config, registryEntry: vm.registryEntry)
}
}