UTMDownloadTask.swift 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  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 Logging
  18. /// Downloads a file and creates a pending VM placeholder.
  19. class UTMDownloadTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
  20. let url: URL
  21. let name: String
  22. private var downloadTask: Task<(any UTMVirtualMachine)?, Error>!
  23. private var taskContinuation: CheckedContinuation<(any UTMVirtualMachine)?, Error>?
  24. @MainActor private(set) lazy var pendingVM: UTMPendingVirtualMachine = createPendingVM()
  25. private let kMaxRetries = 5
  26. private var retries = 0
  27. var fileManager: FileManager {
  28. FileManager.default
  29. }
  30. init(for url: URL, named name: String) {
  31. self.url = url
  32. self.name = name
  33. }
  34. /// Called by subclass when download is completed
  35. /// - Parameter location: Downloaded file location
  36. /// - Returns: Processed UTM virtual machine
  37. func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
  38. throw "Not Implemented"
  39. }
  40. internal func urlSession(_ session: URLSession, downloadTask sessionTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
  41. guard !downloadTask.isCancelled else {
  42. sessionTask.cancel()
  43. return
  44. }
  45. guard let taskContinuation = taskContinuation else {
  46. return
  47. }
  48. self.taskContinuation = nil
  49. // need to move the file because it will be deleted after delegate returns
  50. let tmpUrl = fileManager.temporaryDirectory.appendingPathComponent("\(location.lastPathComponent).2")
  51. do {
  52. if fileManager.fileExists(atPath: tmpUrl.path) {
  53. try fileManager.removeItem(at: tmpUrl)
  54. }
  55. try fileManager.moveItem(at: location, to: tmpUrl)
  56. } catch {
  57. taskContinuation.resume(throwing: error)
  58. return
  59. }
  60. Task {
  61. await pendingVM.setDownloadFinishedNowProcessing()
  62. do {
  63. let vm = try await processCompletedDownload(at: tmpUrl)
  64. taskContinuation.resume(returning: vm)
  65. } catch {
  66. taskContinuation.resume(throwing: error)
  67. }
  68. try? fileManager.removeItem(at: tmpUrl) // clean up
  69. #if os(macOS)
  70. await NSApplication.shared.requestUserAttention(.informationalRequest)
  71. #endif
  72. }
  73. }
  74. /// received when the download progresses
  75. internal func urlSession(_ session: URLSession, downloadTask sessionTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
  76. guard !downloadTask.isCancelled else {
  77. sessionTask.cancel()
  78. return
  79. }
  80. retries = 0 // reset retry counter on success
  81. Task {
  82. await pendingVM.setDownloadProgress(new: bytesWritten,
  83. currentTotal: totalBytesWritten,
  84. estimatedTotal: totalBytesExpectedToWrite)
  85. }
  86. }
  87. /// when the session ends with an error, it could be cancelled or an actual error
  88. internal func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
  89. let error = error as? NSError
  90. if let resumeData = error?.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
  91. retries += 1
  92. guard retries > kMaxRetries else {
  93. logger.warning("Retrying download due to connection error...")
  94. let task = session.downloadTask(withResumeData: resumeData)
  95. task.resume()
  96. return
  97. }
  98. }
  99. guard let taskContinuation = taskContinuation else {
  100. return
  101. }
  102. self.taskContinuation = nil
  103. self.retries = 0 // reset retry counter
  104. if let error = error {
  105. if error.code == NSURLErrorCancelled {
  106. /// download was cancelled normally
  107. taskContinuation.resume(returning: nil)
  108. } else {
  109. /// other error
  110. logger.error("\(error.localizedDescription)")
  111. taskContinuation.resume(throwing: error)
  112. }
  113. } else {
  114. taskContinuation.resume(returning: nil)
  115. }
  116. }
  117. internal func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
  118. guard let taskContinuation = taskContinuation else {
  119. return
  120. }
  121. self.taskContinuation = nil
  122. if let error = error {
  123. taskContinuation.resume(throwing: error)
  124. } else {
  125. taskContinuation.resume(returning: nil)
  126. }
  127. }
  128. /// Create a placeholder object to show
  129. /// - Returns: Pending VM
  130. @MainActor private func createPendingVM() -> UTMPendingVirtualMachine {
  131. return UTMPendingVirtualMachine(name: name) {
  132. self.cancel()
  133. }
  134. }
  135. /// Starts the download
  136. /// - Returns: Completed download or nil if canceled
  137. func download() async throws -> (any UTMVirtualMachine)? {
  138. /// begin the download
  139. let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
  140. downloadTask = Task.detached { [self] in
  141. let sessionDownload = session.downloadTask(with: url)
  142. await pendingVM.setDownloadStarting()
  143. return try await withCheckedThrowingContinuation({ continuation in
  144. self.taskContinuation = continuation
  145. sessionDownload.resume()
  146. })
  147. }
  148. return try await downloadTask.value
  149. }
  150. /// Try to cancel the download
  151. func cancel() {
  152. downloadTask?.cancel()
  153. }
  154. }