2
0

UTMDownloadTask.swift 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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. /// Find the Last-Modified date as a Unix timestamp
  31. var lastModifiedTimestamp: Int {
  32. get async {
  33. var request = URLRequest(url: url)
  34. request.httpMethod = "HEAD"
  35. guard let (_, response) = try? await URLSession.shared.data(for: request) else {
  36. return 0
  37. }
  38. return lastModifiedTimestamp(for: response) ?? 0
  39. }
  40. }
  41. init(for url: URL, named name: String) {
  42. self.url = url
  43. self.name = name
  44. }
  45. /// Called by subclass when download is completed
  46. /// - Parameter location: Downloaded file location
  47. /// - Parameter response: URL response of the download
  48. /// - Returns: Processed UTM virtual machine
  49. func processCompletedDownload(at location: URL, response: URLResponse?) async throws -> any UTMVirtualMachine {
  50. throw "Not Implemented"
  51. }
  52. internal func urlSession(_ session: URLSession, downloadTask sessionTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
  53. guard !downloadTask.isCancelled else {
  54. sessionTask.cancel()
  55. return
  56. }
  57. guard let taskContinuation = taskContinuation else {
  58. return
  59. }
  60. self.taskContinuation = nil
  61. // need to move the file because it will be deleted after delegate returns
  62. let tmpUrl = fileManager.temporaryDirectory.appendingPathComponent("\(location.lastPathComponent).2")
  63. do {
  64. if fileManager.fileExists(atPath: tmpUrl.path) {
  65. try fileManager.removeItem(at: tmpUrl)
  66. }
  67. try fileManager.moveItem(at: location, to: tmpUrl)
  68. } catch {
  69. taskContinuation.resume(throwing: error)
  70. return
  71. }
  72. Task {
  73. await pendingVM.setDownloadFinishedNowProcessing()
  74. do {
  75. let vm = try await processCompletedDownload(at: tmpUrl, response: sessionTask.response)
  76. taskContinuation.resume(returning: vm)
  77. } catch {
  78. taskContinuation.resume(throwing: error)
  79. }
  80. try? fileManager.removeItem(at: tmpUrl) // clean up
  81. #if os(macOS)
  82. await NSApplication.shared.requestUserAttention(.informationalRequest)
  83. #endif
  84. }
  85. }
  86. /// received when the download progresses
  87. internal func urlSession(_ session: URLSession, downloadTask sessionTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
  88. guard !downloadTask.isCancelled else {
  89. sessionTask.cancel()
  90. return
  91. }
  92. retries = 0 // reset retry counter on success
  93. Task {
  94. await pendingVM.setDownloadProgress(new: bytesWritten,
  95. currentTotal: totalBytesWritten,
  96. estimatedTotal: totalBytesExpectedToWrite)
  97. }
  98. }
  99. /// when the session ends with an error, it could be cancelled or an actual error
  100. internal func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
  101. let error = error as? NSError
  102. if let resumeData = error?.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
  103. retries += 1
  104. guard retries > kMaxRetries else {
  105. logger.warning("Retrying download due to connection error...")
  106. let task = session.downloadTask(withResumeData: resumeData)
  107. task.resume()
  108. return
  109. }
  110. }
  111. guard let taskContinuation = taskContinuation else {
  112. return
  113. }
  114. self.taskContinuation = nil
  115. self.retries = 0 // reset retry counter
  116. if let error = error {
  117. if error.code == NSURLErrorCancelled {
  118. /// download was cancelled normally
  119. taskContinuation.resume(returning: nil)
  120. } else {
  121. /// other error
  122. logger.error("\(error.localizedDescription)")
  123. taskContinuation.resume(throwing: error)
  124. }
  125. } else {
  126. taskContinuation.resume(returning: nil)
  127. }
  128. }
  129. internal func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
  130. guard let taskContinuation = taskContinuation else {
  131. return
  132. }
  133. self.taskContinuation = nil
  134. if let error = error {
  135. taskContinuation.resume(throwing: error)
  136. } else {
  137. taskContinuation.resume(returning: nil)
  138. }
  139. }
  140. /// Create a placeholder object to show
  141. /// - Returns: Pending VM
  142. @MainActor private func createPendingVM() -> UTMPendingVirtualMachine {
  143. return UTMPendingVirtualMachine(name: name) {
  144. self.cancel()
  145. }
  146. }
  147. /// Starts the download
  148. /// - Returns: Completed download or nil if canceled
  149. func download() async throws -> (any UTMVirtualMachine)? {
  150. /// begin the download
  151. let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
  152. downloadTask = Task.detached { [self] in
  153. let sessionDownload = session.downloadTask(with: url)
  154. await pendingVM.setDownloadStarting()
  155. return try await withCheckedThrowingContinuation({ continuation in
  156. self.taskContinuation = continuation
  157. sessionDownload.resume()
  158. })
  159. }
  160. return try await downloadTask.value
  161. }
  162. /// Try to cancel the download
  163. func cancel() {
  164. downloadTask?.cancel()
  165. }
  166. /// Get the Last-Modified header as a Unix timestamp
  167. /// - Parameter response: URL response
  168. /// - Returns: Unix timestamp
  169. func lastModifiedTimestamp(for response: URLResponse?) -> Int? {
  170. guard let headers = (response as? HTTPURLResponse)?.allHeaderFields, let lastModified = headers["Last-Modified"] as? String else {
  171. return nil
  172. }
  173. let dateFormatter = DateFormatter()
  174. dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
  175. guard let lastModifiedDate = dateFormatter.date(from: lastModified) else {
  176. return nil
  177. }
  178. return Int(lastModifiedDate.timeIntervalSince1970)
  179. }
  180. }