UTM/Platform/Shared/VMRemovableDrivesView.swift

237 lines
9.3 KiB
Swift

//
// Copyright © 2020 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 VMRemovableDrivesView: View {
@ObservedObject var vm: VMData
@ObservedObject var config: UTMQemuConfiguration
@EnvironmentObject private var data: UTMData
@State private var shareDirectoryFileImportPresented: Bool = false
@State private var diskImageFileImportPresented: Bool = false
/// Explanation see "SwiftUI FileImporter modal bug" in the `body`
@State private var workaroundFileImporterBug: Bool = false
@State private var currentDrive: UTMQemuConfigurationDrive?
private var qemuVM: UTMQemuVirtualMachine! {
vm.wrapped as? UTMQemuVirtualMachine
}
var fileManager: FileManager {
FileManager.default
}
// Is a shared directory set?
private var hasSharedDir: Bool { qemuVM.sharedDirectoryURL != nil }
@ViewBuilder private var shareMenuActions: some View {
Button(action: { shareDirectoryFileImportPresented.toggle() }) {
Label("Browse…", systemImage: "doc.badge.plus")
}
if hasSharedDir {
Button(action: clearShareDirectory) {
Label("Clear", systemImage: "eject")
}
}
}
var body: some View {
let title = Label {
Text("Shared Directory")
} icon: {
Image(systemName: hasSharedDir ? "externaldrive.fill.badge.person.crop" : "externaldrive.badge.person.crop")
.foregroundColor(.primary)
}
Group {
let mode = config.sharing.directoryShareMode
if mode != .none {
HStack {
title
Spacer()
if hasSharedDir {
Menu {
shareMenuActions
} label: {
SharedPath(path: qemuVM.sharedDirectoryURL?.path)
}.fixedSize()
} else {
Button("Browse…", action: { shareDirectoryFileImportPresented.toggle() })
}
}.fileImporter(isPresented: $shareDirectoryFileImportPresented, allowedContentTypes: [.folder], onCompletion: selectShareDirectory)
.disabled(mode == .virtfs && vm.state != .stopped)
}
ForEach(config.drives.filter { $0.isExternal }) { drive in
HStack {
// Drive menu
Menu {
// Browse button
Button(action: {
currentDrive = drive
// 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 (diskImageFileImportPresented) {
/// bug! Let's set the bool to false ourselves.
diskImageFileImportPresented = 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 {
diskImageFileImportPresented = true
}
}, label: {
Label("Browse…", systemImage: "doc.badge.plus")
})
.onChange(of: workaroundFileImporterBug) { doWorkaround in
/// Explanation see "SwiftUI FileImporter modal bug" above
if doWorkaround {
DispatchQueue.main.async {
workaroundFileImporterBug = false
diskImageFileImportPresented = true
}
}
}
// Eject button
if qemuVM.externalImageURL(for: drive) != nil {
Button(action: { clearRemovableImage(forDrive: drive) }, label: {
Label("Clear", systemImage: "eject")
})
}
} label: {
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
}.disabled(vm.hasSuspendState)
Spacer()
// Disk image path, or (empty)
Text(pathFor(drive))
.lineLimit(1)
.truncationMode(.tail)
.foregroundColor(.secondary)
}.fileImporter(isPresented: $diskImageFileImportPresented, allowedContentTypes: [.data]) { result in
if let currentDrive = self.currentDrive {
selectRemovableImage(forDrive: currentDrive, result: result)
self.currentDrive = nil
}
}
}
}
}
private struct SharedPath: View {
let path: String?
var body: some View {
if let path = path {
let url = URL(fileURLWithPath: path)
HStack {
Text(url.lastPathComponent)
.truncationMode(.head)
.lineLimit(1)
#if os(iOS) || os(visionOS)
Image(systemName: "chevron.down")
#endif
}
} else {
Text("(empty)")
.foregroundColor(.secondary)
}
}
}
private func pathFor(_ drive: UTMQemuConfigurationDrive) -> String {
if let url = qemuVM.externalImageURL(for: drive) {
return url.lastPathComponent
} else {
return NSLocalizedString("(empty)", comment: "A removable drive that has no image file inserted.")
}
}
private struct DriveLabel: View {
let drive: UTMQemuConfigurationDrive
let isInserted: Bool
var body: some View {
if drive.imageType == .cd {
return Label("CD/DVD", systemImage: !isInserted ? "opticaldiscdrive" : "opticaldiscdrive.fill")
} else {
return Label(String.localizedStringWithFormat(NSLocalizedString("%@ %@", comment: "VMRemovableDrivesView"),
NSLocalizedString("Removable", comment: "VMRemovableDrivesView"),
drive.interface.prettyValue),
systemImage: "externaldrive")
}
}
}
private func selectShareDirectory(result: Result<URL, Error>) {
data.busyWorkAsync {
switch result {
case .success(let url):
try await qemuVM.changeSharedDirectory(to: url)
break
case .failure(let err):
throw err
}
}
}
private func clearShareDirectory() {
data.busyWorkAsync {
await qemuVM.clearSharedDirectory()
}
}
private func selectRemovableImage(forDrive drive: UTMQemuConfigurationDrive, result: Result<URL, Error>) {
data.busyWorkAsync {
switch result {
case .success(let url):
try await qemuVM.changeMedium(drive, to: url)
break
case .failure(let err):
throw err
}
}
}
private func clearRemovableImage(forDrive drive: UTMQemuConfigurationDrive) {
data.busyWorkAsync {
try await qemuVM.eject(drive)
}
}
}
struct VMRemovableDrivesView_Previews: PreviewProvider {
@State static private var config = UTMQemuConfiguration()
static var previews: some View {
VMDetailsView(vm: VMData(from: .empty))
.onAppear {
config.sharing.directoryShareMode = .webdav
var drive = UTMQemuConfigurationDrive()
drive.imageType = .disk
drive.interface = .ide
config.drives.append(drive)
drive.interface = .scsi
config.drives.append(drive)
drive.imageType = .cd
config.drives.append(drive)
}
}
}