VMRemovableDrivesView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. //
  2. // Copyright © 2020 osy. All rights reserved.
  3. //
  4. // Licensed under the Apache License, Version 2.0 (the "License");
  5. // you may not use this file except in compliance with the License.
  6. // You may obtain a copy of the License at
  7. //
  8. // http://www.apache.org/licenses/LICENSE-2.0
  9. //
  10. // Unless required by applicable law or agreed to in writing, software
  11. // distributed under the License is distributed on an "AS IS" BASIS,
  12. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. // See the License for the specific language governing permissions and
  14. // limitations under the License.
  15. //
  16. import SwiftUI
  17. import UniformTypeIdentifiers
  18. struct VMRemovableDrivesView: View {
  19. @ObservedObject var vm: VMData
  20. @ObservedObject var config: UTMQemuConfiguration
  21. @EnvironmentObject private var data: UTMData
  22. @State private var shareDirectoryFileImportPresented: Bool = false
  23. @State private var diskImageFileImportPresented: Bool = false
  24. /// Explanation see "SwiftUI FileImporter modal bug" in the `body`
  25. @State private var workaroundFileImporterBug: Bool = false
  26. @State private var currentDrive: UTMQemuConfigurationDrive?
  27. private static let shareDirectoryUTType = UTType.folder
  28. private static let diskImageUTType = UTType.data
  29. private var qemuVM: (any UTMSpiceVirtualMachine)! {
  30. vm.wrapped as? any UTMSpiceVirtualMachine
  31. }
  32. var fileManager: FileManager {
  33. FileManager.default
  34. }
  35. // Is a shared directory set?
  36. private var hasSharedDir: Bool { qemuVM.sharedDirectoryURL != nil }
  37. @ViewBuilder private var shareMenuActions: some View {
  38. Button(action: { shareDirectoryFileImportPresented.toggle() }) {
  39. Label("Browse…", systemImage: "doc.badge.plus")
  40. }
  41. if hasSharedDir {
  42. Button(action: clearShareDirectory) {
  43. Label("Clear", systemImage: "eject")
  44. }
  45. }
  46. }
  47. var body: some View {
  48. let title = Label {
  49. Text("Shared Directory")
  50. } icon: {
  51. Image(systemName: hasSharedDir ? "externaldrive.fill.badge.person.crop" : "externaldrive.badge.person.crop")
  52. .foregroundColor(.primary)
  53. }
  54. Group {
  55. let mode = config.sharing.directoryShareMode
  56. if mode != .none {
  57. HStack {
  58. title
  59. Spacer()
  60. if hasSharedDir {
  61. Menu {
  62. shareMenuActions
  63. } label: {
  64. SharedPath(path: qemuVM.sharedDirectoryURL?.path)
  65. }.fixedSize()
  66. } else {
  67. Button("Browse…", action: { shareDirectoryFileImportPresented.toggle() })
  68. }
  69. }.fileImporter(isPresented: $shareDirectoryFileImportPresented, allowedContentTypes: [Self.shareDirectoryUTType], onCompletion: selectShareDirectory)
  70. .disabled(mode == .virtfs && vm.state != .stopped)
  71. .onDrop(of: [Self.shareDirectoryUTType], isTargeted: nil) { providers in
  72. guard let item = providers.first, item.hasItemConformingToTypeIdentifier(Self.shareDirectoryUTType.identifier) else { return false }
  73. item.loadItem(forTypeIdentifier: Self.shareDirectoryUTType.identifier) { url, error in
  74. if let url = url as? URL {
  75. selectShareDirectory(result: .success(url))
  76. }
  77. if let error = error {
  78. selectShareDirectory(result: .failure(error))
  79. }
  80. }
  81. return true
  82. }
  83. }
  84. ForEach(config.drives.filter { $0.isExternal }) { drive in
  85. HStack {
  86. #if !WITH_REMOTE // FIXME: implement remote feature
  87. // Drive menu
  88. Menu {
  89. // Browse button
  90. Button(action: {
  91. currentDrive = drive
  92. // MARK: SwiftUI FileImporter modal bug
  93. /// At this point in the execution, `diskImageFileImportPresented` must be `false`.
  94. /// However there is a SwiftUI FileImporter modal bug:
  95. /// if the user taps outside the import modal to cancel instead of tapping the actual cancel button,
  96. /// the `.fileImporter` doesn't actually set the isPresented Binding to `false`.
  97. if (diskImageFileImportPresented) {
  98. /// bug! Let's set the bool to false ourselves.
  99. diskImageFileImportPresented = false
  100. /// One more thing: we can't immediately set it to `true` again because then the state won't have changed.
  101. /// So we have to use the workaround, which is caught in the `.onChange` below.
  102. workaroundFileImporterBug = true
  103. } else {
  104. diskImageFileImportPresented = true
  105. }
  106. }, label: {
  107. Label("Browse…", systemImage: "doc.badge.plus")
  108. })
  109. .onChange(of: workaroundFileImporterBug) { doWorkaround in
  110. /// Explanation see "SwiftUI FileImporter modal bug" above
  111. if doWorkaround {
  112. DispatchQueue.main.async {
  113. workaroundFileImporterBug = false
  114. diskImageFileImportPresented = true
  115. }
  116. }
  117. }
  118. // Eject button
  119. if qemuVM.externalImageURL(for: drive) != nil {
  120. Button(action: { clearRemovableImage(forDrive: drive) }, label: {
  121. Label("Clear", systemImage: "eject")
  122. })
  123. }
  124. } label: {
  125. DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
  126. }.disabled(vm.hasSuspendState)
  127. #else
  128. DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
  129. #endif
  130. Spacer()
  131. // Disk image path, or (empty)
  132. Text(pathFor(drive))
  133. .lineLimit(1)
  134. .truncationMode(.tail)
  135. .foregroundColor(.secondary)
  136. }.fileImporter(isPresented: $diskImageFileImportPresented, allowedContentTypes: [Self.diskImageUTType]) { result in
  137. if let currentDrive = self.currentDrive {
  138. selectRemovableImage(forDrive: currentDrive, result: result)
  139. self.currentDrive = nil
  140. }
  141. }
  142. .onDrop(of: [Self.diskImageUTType], isTargeted: nil) { providers in
  143. guard let item = providers.first, item.hasItemConformingToTypeIdentifier(Self.diskImageUTType.identifier) else { return false }
  144. item.loadItem(forTypeIdentifier: Self.diskImageUTType.identifier) { url, error in
  145. if let url = url as? URL{
  146. selectRemovableImage(forDrive: drive, result: .success(url))
  147. }
  148. if let error {
  149. selectRemovableImage(forDrive: drive, result: .failure(error))
  150. }
  151. }
  152. return true
  153. }
  154. }
  155. }
  156. }
  157. private struct SharedPath: View {
  158. let path: String?
  159. var body: some View {
  160. if let path = path {
  161. let url = URL(fileURLWithPath: path)
  162. HStack {
  163. Text(url.lastPathComponent)
  164. .truncationMode(.head)
  165. .lineLimit(1)
  166. #if os(iOS) || os(visionOS)
  167. Image(systemName: "chevron.down")
  168. #endif
  169. }
  170. } else {
  171. Text("(empty)")
  172. .foregroundColor(.secondary)
  173. }
  174. }
  175. }
  176. private func pathFor(_ drive: UTMQemuConfigurationDrive) -> String {
  177. if let url = qemuVM.externalImageURL(for: drive) {
  178. return url.lastPathComponent
  179. } else {
  180. return NSLocalizedString("(empty)", comment: "A removable drive that has no image file inserted.")
  181. }
  182. }
  183. private struct DriveLabel: View {
  184. let drive: UTMQemuConfigurationDrive
  185. let isInserted: Bool
  186. var body: some View {
  187. if drive.imageType == .cd {
  188. return Label("CD/DVD", systemImage: !isInserted ? "opticaldiscdrive" : "opticaldiscdrive.fill")
  189. } else {
  190. return Label(String.localizedStringWithFormat(NSLocalizedString("%@ %@", comment: "VMRemovableDrivesView"),
  191. NSLocalizedString("Removable", comment: "VMRemovableDrivesView"),
  192. drive.interface.prettyValue),
  193. systemImage: "externaldrive")
  194. }
  195. }
  196. }
  197. private func selectShareDirectory(result: Result<URL, Error>) {
  198. data.busyWorkAsync {
  199. switch result {
  200. case .success(let url):
  201. try await qemuVM.changeSharedDirectory(to: url)
  202. break
  203. case .failure(let err):
  204. throw err
  205. }
  206. }
  207. }
  208. private func clearShareDirectory() {
  209. data.busyWorkAsync {
  210. await qemuVM.clearSharedDirectory()
  211. }
  212. }
  213. private func selectRemovableImage(forDrive drive: UTMQemuConfigurationDrive, result: Result<URL, Error>) {
  214. data.busyWorkAsync {
  215. switch result {
  216. case .success(let url):
  217. try await qemuVM.changeMedium(drive, to: url)
  218. break
  219. case .failure(let err):
  220. throw err
  221. }
  222. }
  223. }
  224. private func clearRemovableImage(forDrive drive: UTMQemuConfigurationDrive) {
  225. data.busyWorkAsync {
  226. try await qemuVM.eject(drive)
  227. }
  228. }
  229. }
  230. struct VMRemovableDrivesView_Previews: PreviewProvider {
  231. @State static private var config = UTMQemuConfiguration()
  232. static var previews: some View {
  233. VMDetailsView(vm: VMData(from: .empty))
  234. .onAppear {
  235. config.sharing.directoryShareMode = .webdav
  236. var drive = UTMQemuConfigurationDrive()
  237. drive.imageType = .disk
  238. drive.interface = .ide
  239. config.drives.append(drive)
  240. drive.interface = .scsi
  241. config.drives.append(drive)
  242. drive.imageType = .cd
  243. config.drives.append(drive)
  244. }
  245. }
  246. }