VMConfigDriveDetailsView.swift 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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. private let bytesInMib: Int64 = 1024 * 1024
  18. private let mibInGib: Int = 1024
  19. struct VMConfigDriveDetailsView: View {
  20. private enum ConfirmItem: Identifiable {
  21. case reclaim(URL)
  22. case compress(URL)
  23. case resize(URL)
  24. var id: Int {
  25. switch self {
  26. case .reclaim(_): return 1
  27. case .compress(_): return 2
  28. case .resize(_): return 3
  29. }
  30. }
  31. }
  32. @Binding var config: UTMQemuConfigurationDrive
  33. @Binding var requestDriveDelete: UTMQemuConfigurationDrive?
  34. @EnvironmentObject private var data: UTMData
  35. @State private var isImporterPresented: Bool = false
  36. @State private var confirmAlert: ConfirmItem?
  37. @State private var isResizePopoverShown: Bool = false
  38. @State private var proposedSizeMib: Int = 0
  39. var body: some View {
  40. Form {
  41. Toggle(isOn: $config.isExternal.animation(), label: {
  42. Text("Removable Drive")
  43. }).disabled(true)
  44. if !config.isExternal {
  45. HStack {
  46. Text("Name")
  47. Spacer()
  48. if let imageName = config.imageURL?.lastPathComponent ?? config.imageName {
  49. Text(imageName)
  50. .lineLimit(1)
  51. .multilineTextAlignment(.trailing)
  52. } else {
  53. Text("(new)")
  54. }
  55. }
  56. } else {
  57. FileBrowseField(url: $config.imageURL, isFileImporterPresented: $isImporterPresented)
  58. .globalFileImporter(isPresented: $isImporterPresented, allowedContentTypes: [.item]) { result in
  59. data.busyWorkAsync {
  60. let url = try result.get()
  61. await MainActor.run {
  62. config.imageURL = url
  63. }
  64. }
  65. }
  66. }
  67. Toggle("Read Only?", isOn: $config.isReadOnly)
  68. .disabled(config.imageType != .none && config.imageType != .disk)
  69. VMConfigConstantPicker("Image Type", selection: $config.imageType)
  70. .onChange(of: config.imageType) { imageType in
  71. if imageType != .none && config.imageType != .disk {
  72. config.isReadOnly = true
  73. }
  74. }
  75. if config.imageType == .disk || config.imageType == .cd {
  76. VMConfigConstantPicker("Interface", selection: $config.interface)
  77. .onChange(of: config.interface) { interface in
  78. config.interfaceVersion = UTMQemuConfigurationDrive.latestInterfaceVersion
  79. if interface == .floppy && config.imageType == .cd {
  80. config.imageType = .disk
  81. }
  82. }
  83. }
  84. if config.interface == .ide && config.interfaceVersion != UTMQemuConfigurationDrive.latestInterfaceVersion {
  85. Button {
  86. config.interfaceVersion = UTMQemuConfigurationDrive.latestInterfaceVersion
  87. } label: {
  88. Text("Update Interface")
  89. }.help("Older versions of UTM added each IDE device to a separate bus. Check this to change the configuration to place two units on each bus.")
  90. }
  91. if let imageUrl = config.imageURL {
  92. let fileSize = data.computeSize(for: imageUrl)
  93. DefaultTextField("Size", text: .constant(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .binary))).disabled(true)
  94. } else if config.sizeMib > 0 {
  95. DefaultTextField("Size", text: .constant(ByteCountFormatter.string(fromByteCount: Int64(config.sizeMib) * bytesInMib, countStyle: .binary))).disabled(true)
  96. }
  97. #if os(macOS)
  98. HStack {
  99. if #unavailable(macOS 12) {
  100. Button {
  101. requestDriveDelete = config
  102. } label: {
  103. Label("Delete Drive", systemImage: "externaldrive.badge.minus")
  104. .foregroundColor(.red)
  105. }.help("Delete this drive.")
  106. }
  107. if let imageUrl = config.imageURL, FileManager.default.fileExists(atPath: imageUrl.path) {
  108. Button {
  109. confirmAlert = .reclaim(imageUrl)
  110. } label: {
  111. Label("Reclaim Space", systemImage: "arrow.3.trianglepath")
  112. }.help("Reclaim disk space by re-converting the disk image.")
  113. Button {
  114. confirmAlert = .compress(imageUrl)
  115. } label: {
  116. Label("Compress", systemImage: "arrowtriangle.right.and.line.vertical.and.arrowtriangle.left")
  117. }.help("Compress by re-converting the disk image and compressing the data.")
  118. Button {
  119. isResizePopoverShown.toggle()
  120. } label: {
  121. Label("Resize…", systemImage: "arrowtriangle.left.and.line.vertical.and.arrowtriangle.right")
  122. }.help("Increase the size of the disk image.")
  123. .popover(isPresented: $isResizePopoverShown) {
  124. ResizePopoverView(imageURL: imageUrl, proposedSizeMib: $proposedSizeMib) {
  125. confirmAlert = .resize(imageUrl)
  126. }.padding()
  127. .frame(minHeight: 100)
  128. }
  129. }
  130. }.alert(item: $confirmAlert) { item in
  131. switch item {
  132. case .reclaim(let imageURL):
  133. return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space? Note this will require enough temporary space to perform the conversion. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: false) }, secondaryButton: .cancel())
  134. case .compress(let imageURL):
  135. return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space and apply compression? Note this will require enough temporary space to perform the conversion. Compression only applies to existing data and new data will still be written uncompressed. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: true) }, secondaryButton: .cancel())
  136. case .resize(let imageURL):
  137. return 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")) {
  138. resizeDrive(for: imageURL, sizeInMib: proposedSizeMib)
  139. }, secondaryButton: .cancel())
  140. }
  141. }
  142. #endif
  143. }
  144. }
  145. #if os(macOS)
  146. private func reclaimSpace(for driveUrl: URL, withCompression isCompressed: Bool) {
  147. data.busyWorkAsync {
  148. try await data.reclaimSpace(for: driveUrl, withCompression: isCompressed)
  149. }
  150. }
  151. private func resizeDrive(for driveUrl: URL, sizeInMib: Int) {
  152. data.busyWorkAsync {
  153. try await data.resizeQcow2Drive(for: driveUrl, sizeInMib: sizeInMib)
  154. }
  155. }
  156. #endif
  157. }
  158. #if os(macOS)
  159. private struct ResizePopoverView: View {
  160. let imageURL: URL
  161. @Binding var proposedSizeMib: Int
  162. let onConfirm: () -> Void
  163. @EnvironmentObject private var data: UTMData
  164. @State private var currentSize: Int64?
  165. @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
  166. private var sizeString: String? {
  167. if let currentSize = currentSize {
  168. return ByteCountFormatter.string(fromByteCount: currentSize, countStyle: .binary)
  169. } else {
  170. return nil
  171. }
  172. }
  173. private var minSizeMib: Int {
  174. Int((currentSize! + bytesInMib - 1) / bytesInMib)
  175. }
  176. var body: some View {
  177. VStack {
  178. if let sizeString = sizeString {
  179. Text("Minimum size: \(sizeString)")
  180. Form {
  181. SizeTextField($proposedSizeMib, minSizeMib: minSizeMib)
  182. Button("Resize") {
  183. if proposedSizeMib > minSizeMib {
  184. onConfirm()
  185. }
  186. presentationMode.wrappedValue.dismiss()
  187. }
  188. }
  189. } else {
  190. ProgressView("Calculating current size...")
  191. }
  192. }.onAppear {
  193. Task { @MainActor in
  194. currentSize = await data.qcow2DriveSize(for: imageURL)
  195. proposedSizeMib = minSizeMib
  196. }
  197. }
  198. }
  199. }
  200. #endif