UTMQemuImage.swift 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. //
  2. // Copyright © 2022 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 Foundation
  17. import QEMUKitInternal
  18. @objc class UTMQemuImage: UTMProcess {
  19. typealias ProgressCallback = (Float) -> Void
  20. private var logOutput: String = ""
  21. private var processExitContinuation: CheckedContinuation<Void, any Error>?
  22. private var onProgress: ProgressCallback?
  23. private init() {
  24. super.init(arguments: [])
  25. }
  26. override func processHasExited(_ exitCode: Int, message: String?) {
  27. if let processExitContinuation = processExitContinuation {
  28. self.processExitContinuation = nil
  29. if exitCode != 0 {
  30. if let message = message {
  31. processExitContinuation.resume(throwing: UTMQemuImageError.qemuError(message))
  32. } else {
  33. processExitContinuation.resume(throwing: UTMQemuImageError.unknown)
  34. }
  35. } else {
  36. processExitContinuation.resume()
  37. }
  38. }
  39. }
  40. private func start() async throws {
  41. try await withCheckedThrowingContinuation { continuation in
  42. processExitContinuation = continuation
  43. start("qemu-img") { error in
  44. if let error = error {
  45. self.processExitContinuation = nil
  46. continuation.resume(throwing: error)
  47. }
  48. }
  49. }
  50. }
  51. static func convert(from url: URL, toQcow2 dest: URL, withCompression compressed: Bool = false, onProgress: ProgressCallback? = nil) async throws {
  52. let qemuImg = UTMQemuImage()
  53. let srcBookmark = try url.bookmarkData()
  54. let dstBookmark = try dest.deletingLastPathComponent().bookmarkData()
  55. qemuImg.pushArgv("convert")
  56. if onProgress != nil {
  57. qemuImg.pushArgv("-p")
  58. }
  59. if compressed {
  60. qemuImg.pushArgv("-c")
  61. qemuImg.pushArgv("-o")
  62. qemuImg.pushArgv("compression_type=zstd")
  63. }
  64. qemuImg.pushArgv("-O")
  65. qemuImg.pushArgv("qcow2")
  66. qemuImg.accessData(withBookmark: srcBookmark)
  67. qemuImg.pushArgv(url.path)
  68. qemuImg.accessData(withBookmark: dstBookmark)
  69. qemuImg.pushArgv(dest.path)
  70. let logging = QEMULogging()
  71. logging.delegate = qemuImg
  72. qemuImg.standardOutput = logging.standardOutput
  73. qemuImg.standardError = logging.standardError
  74. qemuImg.onProgress = onProgress
  75. try await qemuImg.start()
  76. }
  77. /*
  78. The info format looks like:
  79. $ qemu-img info foo.img --output=json
  80. {
  81. "virtual-size": 20971520,
  82. "filename": "foo.img",
  83. "cluster-size": 65536,
  84. "format": "qcow2",
  85. "actual-size": 200704,
  86. "format-specific": {
  87. "type": "qcow2",
  88. "data": {
  89. "compat": "1.1",
  90. "compression-type": "zlib",
  91. "lazy-refcounts": false,
  92. "refcount-bits": 16,
  93. "corrupt": false,
  94. "extended-l2": false
  95. }
  96. },
  97. "dirty-flag": false
  98. }
  99. */
  100. struct QemuImageInfo : Codable {
  101. let virtualSize : Int64
  102. let filename : String
  103. let clusterSize : Int32
  104. let format : String
  105. let actualSize : Int64
  106. let dirtyFlag : Bool
  107. private enum CodingKeys: String, CodingKey {
  108. case virtualSize = "virtual-size"
  109. case filename
  110. case clusterSize = "cluster-size"
  111. case format
  112. case actualSize = "actual-size"
  113. case dirtyFlag = "dirty-flag"
  114. }
  115. }
  116. static func size(image url: URL) async throws -> Int64 {
  117. let qemuImg = UTMQemuImage()
  118. let srcBookmark = try url.bookmarkData()
  119. qemuImg.pushArgv("info")
  120. qemuImg.pushArgv("--output=json")
  121. qemuImg.accessData(withBookmark: srcBookmark)
  122. qemuImg.pushArgv(url.path)
  123. let logging = QEMULogging()
  124. logging.delegate = qemuImg
  125. qemuImg.standardOutput = logging.standardOutput
  126. qemuImg.standardError = logging.standardError
  127. try await qemuImg.start()
  128. let decoder = JSONDecoder()
  129. decoder.keyDecodingStrategy = .convertFromSnakeCase
  130. let data = qemuImg.logOutput.data(using: .utf8) ?? Data()
  131. let image_info: QemuImageInfo = try decoder.decode(QemuImageInfo.self, from: data)
  132. return image_info.virtualSize
  133. }
  134. static func resize(image url: URL, size : UInt64) async throws {
  135. let qemuImg = UTMQemuImage()
  136. let srcBookmark = try url.bookmarkData()
  137. qemuImg.pushArgv("resize")
  138. qemuImg.pushArgv("-f")
  139. qemuImg.pushArgv("qcow2")
  140. qemuImg.accessData(withBookmark: srcBookmark)
  141. qemuImg.pushArgv(url.path)
  142. qemuImg.pushArgv(String(size))
  143. let logging = QEMULogging()
  144. logging.delegate = qemuImg
  145. qemuImg.standardOutput = logging.standardOutput
  146. qemuImg.standardError = logging.standardError
  147. try await qemuImg.start()
  148. }
  149. }
  150. private enum UTMQemuImageError: Error {
  151. case qemuError(String)
  152. case unknown
  153. }
  154. extension UTMQemuImageError: LocalizedError {
  155. var errorDescription: String? {
  156. switch self {
  157. case .qemuError(let message): return message
  158. case .unknown: return NSLocalizedString("An unknown QEMU error has occurred.", comment: "UTMQemuImage")
  159. }
  160. }
  161. }
  162. // MARK: - Logging
  163. extension UTMQemuImage: QEMULoggingDelegate {
  164. func logging(_ logging: QEMULogging, didRecieveOutputLine line: String) {
  165. logOutput += line
  166. if let onProgress = onProgress, line.contains("100%") {
  167. if let progress = parseProgress(line) {
  168. onProgress(progress)
  169. }
  170. }
  171. }
  172. func logging(_ logging: QEMULogging, didRecieveErrorLine line: String) {
  173. }
  174. }
  175. extension UTMQemuImage {
  176. private func parseProgress(_ line: String) -> Float? {
  177. let pattern = "\\(([0-9]+\\.[0-9]+)/100\\%\\)"
  178. do {
  179. let regex = try NSRegularExpression(pattern: pattern)
  180. if let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: line.count)) {
  181. let range = match.range(at: 1)
  182. if let swiftRange = Range(range, in: line) {
  183. let floatValueString = line[swiftRange]
  184. if let floatValue = Float(floatValueString) {
  185. return floatValue
  186. }
  187. }
  188. }
  189. } catch {
  190. }
  191. return nil
  192. }
  193. }