VMAppleRemovableDrivesView.swift 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. //
  2. // Copyright © 2021 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. struct VMAppleRemovableDrivesView: View {
  18. private enum SelectType {
  19. case sharedDirectory
  20. case diskImage
  21. }
  22. @ObservedObject var vm: VMData
  23. @ObservedObject var config: UTMAppleConfiguration
  24. @ObservedObject var registryEntry: UTMRegistryEntry
  25. @EnvironmentObject private var data: UTMData
  26. @State private var fileImportPresented: Bool = false
  27. @State private var selectType: SelectType = .sharedDirectory
  28. @State private var selectedSharedDirectoryBinding: Binding<UTMRegistryEntry.File>?
  29. @State private var selectedDiskImage: UTMAppleConfigurationDrive?
  30. /// Explanation see "SwiftUI FileImporter modal bug" in `showFileImporter`
  31. @State private var workaroundFileImporterBug: Bool = false
  32. private var appleVM: UTMAppleVirtualMachine! {
  33. vm.wrapped as? UTMAppleVirtualMachine
  34. }
  35. private var hasSharingFeatures: Bool {
  36. if #available(macOS 13, *) {
  37. return true
  38. } else if #available(macOS 12, *), config.system.boot.operatingSystem == .linux {
  39. return true
  40. } else {
  41. return false
  42. }
  43. }
  44. private var hasLiveRemovableDrives: Bool {
  45. if #available(macOS 15, *) {
  46. return true
  47. } else {
  48. return false
  49. }
  50. }
  51. var body: some View {
  52. Group {
  53. ForEach($registryEntry.sharedDirectories) { $sharedDirectory in
  54. HStack {
  55. // Browse/Clear menu
  56. Menu {
  57. // Browse button
  58. Button(action: {
  59. selectType = .sharedDirectory
  60. selectedSharedDirectoryBinding = $sharedDirectory
  61. showFileImporter()
  62. }, label: {
  63. Label("Browse…", systemImage: "doc.badge.plus")
  64. })
  65. // Clear button
  66. Button(action: {
  67. deleteShareDirectory(sharedDirectory)
  68. }, label: {
  69. Label("Remove", systemImage: "eject")
  70. })
  71. } label: {
  72. Label("Shared Directory", systemImage: "externaldrive.fill.badge.person.crop")
  73. }
  74. Spacer()
  75. FilePath(url: sharedDirectory.url)
  76. }
  77. }
  78. ForEach($config.drives) { $diskImage in
  79. HStack {
  80. if diskImage.isExternal {
  81. // Drive menu
  82. Menu {
  83. // Browse button
  84. Button(action: {
  85. selectType = .diskImage
  86. selectedDiskImage = diskImage
  87. showFileImporter()
  88. }, label: {
  89. Label("Browse…", systemImage: "doc.badge.plus")
  90. })
  91. // Eject button
  92. if diskImage.isExternal && diskImage.imageURL != nil {
  93. Button(action: { clearRemovableImage(diskImage) }, label: {
  94. Label("Clear", systemImage: "eject")
  95. })
  96. }
  97. } label: {
  98. Label("External Drive", systemImage: "externaldrive")
  99. }.disabled(vm.hasSuspendState || (vm.state != .stopped && !hasLiveRemovableDrives))
  100. } else {
  101. Label("\(diskImage.sizeString) Drive", systemImage: "internaldrive")
  102. }
  103. Spacer()
  104. // Disk image path, or (empty)
  105. FilePath(url: diskImage.imageURL)
  106. }
  107. }
  108. HStack {
  109. Spacer()
  110. if hasSharingFeatures {
  111. Button("New Shared Directory…") {
  112. selectType = .sharedDirectory
  113. selectedSharedDirectoryBinding = nil
  114. showFileImporter()
  115. }
  116. }
  117. }.fileImporter(isPresented: $fileImportPresented, allowedContentTypes: selectType == .sharedDirectory ? [.folder] : [.data]) { result in
  118. if selectType == .sharedDirectory {
  119. if let binding = selectedSharedDirectoryBinding {
  120. selectShareDirectory(for: binding, result: result)
  121. selectedSharedDirectoryBinding = nil
  122. } else {
  123. createShareDirectory(result)
  124. }
  125. } else {
  126. if let diskImage = selectedDiskImage {
  127. selectRemovableImage(for: diskImage, result: result)
  128. selectedDiskImage = nil
  129. }
  130. }
  131. }.onChange(of: workaroundFileImporterBug) { doWorkaround in
  132. /// Explanation see "SwiftUI FileImporter modal bug" below
  133. if doWorkaround {
  134. DispatchQueue.main.async {
  135. workaroundFileImporterBug = false
  136. fileImportPresented = true
  137. }
  138. }
  139. }
  140. }
  141. }
  142. private struct FilePath: View {
  143. let url: URL?
  144. var body: some View {
  145. if let url = url {
  146. Text(url.lastPathComponent)
  147. .truncationMode(.head)
  148. .lineLimit(1)
  149. .foregroundColor(.secondary)
  150. } else {
  151. Text("(empty)")
  152. .foregroundColor(.secondary)
  153. }
  154. }
  155. }
  156. private func showFileImporter() {
  157. // MARK: SwiftUI FileImporter modal bug
  158. /// At this point in the execution, `diskImageFileImportPresented` must be `false`.
  159. /// However there is a SwiftUI FileImporter modal bug:
  160. /// if the user taps outside the import modal to cancel instead of tapping the actual cancel button,
  161. /// the `.fileImporter` doesn't actually set the isPresented Binding to `false`.
  162. if (fileImportPresented) {
  163. /// bug! Let's set the bool to false ourselves.
  164. fileImportPresented = false
  165. /// One more thing: we can't immediately set it to `true` again because then the state won't have changed.
  166. /// So we have to use the workaround, which is caught in the `.onChange` below.
  167. workaroundFileImporterBug = true
  168. } else {
  169. fileImportPresented = true
  170. }
  171. }
  172. private func selectShareDirectory(for binding: Binding<UTMRegistryEntry.File>, result: Result<URL, Error>) {
  173. data.busyWork {
  174. let url = try result.get()
  175. binding.wrappedValue.url = url
  176. }
  177. }
  178. private func createShareDirectory(_ result: Result<URL, Error>) {
  179. data.busyWorkAsync {
  180. let url = try result.get()
  181. let sharedDirectory = try UTMRegistryEntry.File(url: url)
  182. await MainActor.run {
  183. registryEntry.sharedDirectories.append(sharedDirectory)
  184. }
  185. }
  186. }
  187. private func deleteShareDirectory(_ sharedDirectory: UTMRegistryEntry.File) {
  188. appleVM.registryEntry.sharedDirectories.removeAll(where: { $0.id == sharedDirectory.id })
  189. }
  190. private func selectRemovableImage(for diskImage: UTMAppleConfigurationDrive, result: Result<URL, Error>) {
  191. data.busyWorkAsync {
  192. let url = try result.get()
  193. if #available(macOS 15, *) {
  194. try await appleVM.changeMedium(diskImage, to: url)
  195. } else {
  196. let file = try UTMRegistryEntry.File(url: url)
  197. await registryEntry.setExternalDrive(file, forId: diskImage.id)
  198. }
  199. }
  200. }
  201. private func clearRemovableImage(_ diskImage: UTMAppleConfigurationDrive) {
  202. data.busyWorkAsync {
  203. if #available(macOS 15, *) {
  204. try await appleVM.eject(diskImage)
  205. } else {
  206. await registryEntry.removeExternalDrive(forId: diskImage.id)
  207. }
  208. }
  209. }
  210. }
  211. struct VMAppleRemovableDrivesView_Previews: PreviewProvider {
  212. @StateObject static var vm = VMData(from: .empty)
  213. @StateObject static var config = UTMAppleConfiguration()
  214. static var previews: some View {
  215. VMAppleRemovableDrivesView(vm: vm, config: config, registryEntry: vm.registryEntry!)
  216. }
  217. }