2024-10-31 22:35:22 +08:00
|
|
|
|
import Foundation
|
|
|
|
|
|
import Network
|
|
|
|
|
|
import Combine
|
|
|
|
|
|
import AppKit
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
|
|
|
|
var completionHandler: (URL?, URLResponse?, Error?) -> Void
|
|
|
|
|
|
var progressHandler: ((Int64, Int64, Int64) -> Void)?
|
|
|
|
|
|
var destinationDirectory: URL
|
|
|
|
|
|
var fileName: String
|
|
|
|
|
|
|
|
|
|
|
|
init(destinationDirectory: URL,
|
|
|
|
|
|
fileName: String,
|
|
|
|
|
|
completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void,
|
|
|
|
|
|
progressHandler: ((Int64, Int64, Int64) -> Void)? = nil) {
|
|
|
|
|
|
self.destinationDirectory = destinationDirectory
|
|
|
|
|
|
self.fileName = fileName
|
|
|
|
|
|
self.completionHandler = completionHandler
|
|
|
|
|
|
self.progressHandler = progressHandler
|
|
|
|
|
|
super.init()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
|
|
|
|
|
do {
|
|
|
|
|
|
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
|
|
|
|
|
|
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
|
|
|
|
|
|
}
|
|
|
|
|
|
let destinationURL = destinationDirectory.appendingPathComponent(fileName)
|
|
|
|
|
|
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
|
|
|
|
|
try FileManager.default.removeItem(at: destinationURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
try FileManager.default.moveItem(at: location, to: destinationURL)
|
|
|
|
|
|
Thread.sleep(forTimeInterval: 0.5)
|
|
|
|
|
|
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
2024-11-04 00:29:08 +08:00
|
|
|
|
_ = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0
|
2024-10-31 22:35:22 +08:00
|
|
|
|
completionHandler(destinationURL, downloadTask.response, nil)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
completionHandler(nil, downloadTask.response, NetworkError.fileSystemError("文件移动后不存在", nil))
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
completionHandler(nil, downloadTask.response, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
|
|
|
|
guard let error = error else { return }
|
|
|
|
|
|
switch (error as NSError).code {
|
|
|
|
|
|
case NSURLErrorCancelled:
|
|
|
|
|
|
return
|
|
|
|
|
|
case NSURLErrorTimedOut:
|
|
|
|
|
|
completionHandler(nil, task.response, NetworkError.downloadError("下载超时", error))
|
|
|
|
|
|
case NSURLErrorNotConnectedToInternet:
|
|
|
|
|
|
completionHandler(nil, task.response, NetworkError.noConnection)
|
|
|
|
|
|
default:
|
|
|
|
|
|
completionHandler(nil, task.response, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
|
|
|
|
didWriteData bytesWritten: Int64,
|
|
|
|
|
|
totalBytesWritten: Int64,
|
|
|
|
|
|
totalBytesExpectedToWrite: Int64) {
|
|
|
|
|
|
guard totalBytesExpectedToWrite > 0 else { return }
|
|
|
|
|
|
guard bytesWritten > 0 else { return }
|
|
|
|
|
|
|
|
|
|
|
|
progressHandler?(bytesWritten, totalBytesWritten, totalBytesExpectedToWrite)
|
|
|
|
|
|
}
|
|
|
|
|
|
func cleanup() {
|
|
|
|
|
|
completionHandler = { _, _, _ in }
|
|
|
|
|
|
progressHandler = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
|
class NetworkManager: ObservableObject {
|
|
|
|
|
|
typealias ProgressUpdate = (bytesWritten: Int64, totalWritten: Int64, expectedToWrite: Int64)
|
|
|
|
|
|
@Published var isConnected = false
|
2024-11-03 00:12:38 +08:00
|
|
|
|
@Published var saps: [String: Sap] = [:]
|
2024-10-31 22:35:22 +08:00
|
|
|
|
@Published var cdn: String = ""
|
2024-11-03 00:12:38 +08:00
|
|
|
|
@Published var allowedPlatform = ["macuniversal", "macarm64", "osx10-64", "osx10"]
|
|
|
|
|
|
@Published var sapCodes: [SapCodes] = []
|
2024-10-31 22:35:22 +08:00
|
|
|
|
@Published var loadingState: LoadingState = .idle
|
2024-11-03 00:12:38 +08:00
|
|
|
|
@Published var downloadTasks: [NewDownloadTask] = []
|
2024-10-31 22:35:22 +08:00
|
|
|
|
@Published var installationState: InstallationState = .idle
|
2024-11-04 17:40:01 +08:00
|
|
|
|
@Published var installationLogs: [String] = []
|
2024-10-31 22:35:22 +08:00
|
|
|
|
private let cancelTracker = CancelTracker()
|
|
|
|
|
|
internal var downloadUtils: DownloadUtils!
|
|
|
|
|
|
internal var progressObservers: [UUID: NSKeyValueObservation] = [:]
|
|
|
|
|
|
internal var activeDownloadTaskId: UUID?
|
|
|
|
|
|
internal var monitor = NWPathMonitor()
|
|
|
|
|
|
internal var isFetchingProducts = false
|
|
|
|
|
|
private let installManager = InstallManager()
|
2024-11-04 00:29:08 +08:00
|
|
|
|
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
|
|
|
|
|
enum InstallationState {
|
|
|
|
|
|
case idle
|
|
|
|
|
|
case installing(progress: Double, status: String)
|
|
|
|
|
|
case completed
|
|
|
|
|
|
case failed(Error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
|
self.downloadUtils = DownloadUtils(networkManager: self, cancelTracker: cancelTracker)
|
|
|
|
|
|
setupNetworkMonitoring()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func fetchProducts() async {
|
|
|
|
|
|
await fetchProductsWithRetry()
|
|
|
|
|
|
}
|
2024-11-03 00:12:38 +08:00
|
|
|
|
func startDownload(sap: Sap, selectedVersion: String, language: String, destinationURL: URL) async throws {
|
|
|
|
|
|
guard let productInfo = self.saps[sap.sapCode]?.versions[selectedVersion] else {
|
2024-10-31 22:35:22 +08:00
|
|
|
|
throw NetworkError.invalidData("无法获取产品信息")
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
print(destinationURL)
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let task = NewDownloadTask(
|
|
|
|
|
|
sapCode: sap.sapCode,
|
|
|
|
|
|
version: selectedVersion,
|
2024-10-31 22:35:22 +08:00
|
|
|
|
language: language,
|
2024-11-03 00:12:38 +08:00
|
|
|
|
displayName: sap.displayName,
|
|
|
|
|
|
directory: destinationURL,
|
|
|
|
|
|
productsToDownload: [],
|
|
|
|
|
|
createAt: Date(),
|
|
|
|
|
|
totalStatus: .preparing(DownloadStatus.PrepareInfo(
|
|
|
|
|
|
message: "正在准备下载...",
|
2024-10-31 22:35:22 +08:00
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
stage: .initializing
|
|
|
|
|
|
)),
|
2024-11-03 00:12:38 +08:00
|
|
|
|
totalProgress: 0,
|
|
|
|
|
|
totalDownloadedSize: 0,
|
2024-10-31 22:35:22 +08:00
|
|
|
|
totalSize: 0,
|
2024-11-03 00:12:38 +08:00
|
|
|
|
totalSpeed: 0
|
2024-10-31 22:35:22 +08:00
|
|
|
|
)
|
2024-11-03 00:12:38 +08:00
|
|
|
|
|
|
|
|
|
|
downloadTasks.append(task)
|
2024-11-04 14:44:52 +08:00
|
|
|
|
updateDockBadge()
|
2024-11-03 00:12:38 +08:00
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
try downloadUtils.createInstallerApp(
|
|
|
|
|
|
for: task.sapCode,
|
|
|
|
|
|
version: task.version,
|
|
|
|
|
|
language: task.language,
|
|
|
|
|
|
at: task.directory
|
|
|
|
|
|
)
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
var productsToDownload: [ProductsToDownload] = []
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
productsToDownload.append(ProductsToDownload(
|
|
|
|
|
|
sapCode: sap.sapCode,
|
|
|
|
|
|
version: selectedVersion,
|
|
|
|
|
|
buildGuid: productInfo.buildGuid
|
|
|
|
|
|
))
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
for dependency in productInfo.dependencies {
|
|
|
|
|
|
if let dependencyVersions = saps[dependency.sapCode]?.versions {
|
|
|
|
|
|
let sortedVersions = dependencyVersions.sorted { first, second in
|
|
|
|
|
|
first.value.productVersion.compare(second.value.productVersion, options: .numeric) == .orderedDescending
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-04 17:40:01 +08:00
|
|
|
|
var firstGuid = ""
|
2024-11-03 00:12:38 +08:00
|
|
|
|
var buildGuid = ""
|
2024-11-04 17:40:01 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
for (_, versionInfo) in sortedVersions where versionInfo.baseVersion == dependency.version {
|
2024-11-04 17:40:01 +08:00
|
|
|
|
if firstGuid.isEmpty { firstGuid = versionInfo.buildGuid }
|
|
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
if allowedPlatform.contains(versionInfo.apPlatform) {
|
|
|
|
|
|
buildGuid = versionInfo.buildGuid
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 17:40:01 +08:00
|
|
|
|
|
|
|
|
|
|
if buildGuid.isEmpty { buildGuid = firstGuid }
|
|
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
if !buildGuid.isEmpty {
|
|
|
|
|
|
productsToDownload.append(ProductsToDownload(
|
|
|
|
|
|
sapCode: dependency.sapCode,
|
|
|
|
|
|
version: dependency.version,
|
|
|
|
|
|
buildGuid: buildGuid
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
for product in productsToDownload {
|
2024-11-03 17:13:25 +08:00
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
task.setStatus(.preparing(DownloadStatus.PrepareInfo(
|
|
|
|
|
|
message: "正在处理 \(product.sapCode) 的产品信息...",
|
|
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
stage: .fetchingInfo
|
|
|
|
|
|
)))
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let productDir = task.directory.appendingPathComponent("Contents/Resources/products/\(product.sapCode)")
|
|
|
|
|
|
if !FileManager.default.fileExists(atPath: productDir.path) {
|
|
|
|
|
|
try FileManager.default.createDirectory(at: productDir, withIntermediateDirectories: true)
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 17:13:25 +08:00
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
task.setStatus(.preparing(DownloadStatus.PrepareInfo(
|
|
|
|
|
|
message: "正在下载 \(product.sapCode) 的产品信息...",
|
|
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
stage: .fetchingInfo
|
|
|
|
|
|
)))
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
2024-11-04 17:40:01 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let jsonString = try await getApplicationInfo(buildGuid: product.buildGuid)
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let jsonURL = productDir.appendingPathComponent("application.json")
|
|
|
|
|
|
try jsonString.write(to: jsonURL, atomically: true, encoding: .utf8)
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
guard let jsonData = jsonString.data(using: .utf8),
|
|
|
|
|
|
let appInfo = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
|
|
|
|
|
let packages = appInfo["Packages"] as? [String: Any],
|
|
|
|
|
|
let packageArray = packages["Package"] as? [[String: Any]] else {
|
|
|
|
|
|
throw NetworkError.invalidData("无法解析产品信息")
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-04 17:40:01 +08:00
|
|
|
|
let totalPackages = packageArray.count
|
|
|
|
|
|
task.totalPackages = totalPackages
|
|
|
|
|
|
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
task.setStatus(.downloading(DownloadStatus.DownloadInfo(
|
|
|
|
|
|
fileName: task.currentPackage?.fullPackageName ?? "",
|
|
|
|
|
|
currentPackageIndex: task.completedPackages,
|
|
|
|
|
|
totalPackages: task.totalPackages,
|
|
|
|
|
|
startTime: Date(),
|
|
|
|
|
|
estimatedTimeRemaining: nil
|
|
|
|
|
|
)))
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
for package in packageArray {
|
|
|
|
|
|
let fullPackageName: String
|
|
|
|
|
|
if let name = package["fullPackageName"] as? String, !name.isEmpty {
|
|
|
|
|
|
fullPackageName = name
|
|
|
|
|
|
} else if let name = package["PackageName"] as? String, !name.isEmpty {
|
2024-11-04 17:40:01 +08:00
|
|
|
|
fullPackageName = "\(name).zip"
|
2024-11-03 00:12:38 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let packageType = package["Type"] as? String ?? "non-core"
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-04 17:40:01 +08:00
|
|
|
|
let isLanguageSuitable: Bool
|
|
|
|
|
|
if packageType == "core" {
|
|
|
|
|
|
isLanguageSuitable = true
|
2024-11-03 00:12:38 +08:00
|
|
|
|
} else {
|
2024-11-04 17:40:01 +08:00
|
|
|
|
let condition = package["Condition"] as? String ?? ""
|
|
|
|
|
|
let osLang = Locale.current.identifier
|
|
|
|
|
|
isLanguageSuitable = (
|
|
|
|
|
|
task.language == "ALL" ||
|
|
|
|
|
|
condition.isEmpty ||
|
|
|
|
|
|
!condition.contains("[installLanguage]") ||
|
|
|
|
|
|
condition.contains("[installLanguage]==\(task.language)") ||
|
|
|
|
|
|
condition.contains("[installLanguage]==\(osLang)")
|
|
|
|
|
|
)
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
2024-11-04 17:40:01 +08:00
|
|
|
|
|
|
|
|
|
|
if isLanguageSuitable {
|
|
|
|
|
|
let downloadSize: Int64
|
|
|
|
|
|
if let sizeNumber = package["DownloadSize"] as? NSNumber {
|
|
|
|
|
|
downloadSize = sizeNumber.int64Value
|
|
|
|
|
|
} else if let sizeString = package["DownloadSize"] as? String,
|
|
|
|
|
|
let parsedSize = Int64(sizeString) {
|
|
|
|
|
|
downloadSize = parsedSize
|
|
|
|
|
|
} else if let sizeInt = package["DownloadSize"] as? Int {
|
|
|
|
|
|
downloadSize = Int64(sizeInt)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard let downloadURL = package["Path"] as? String,
|
|
|
|
|
|
!downloadURL.isEmpty else {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let newPackage = Package(
|
|
|
|
|
|
type: packageType,
|
|
|
|
|
|
fullPackageName: fullPackageName,
|
|
|
|
|
|
downloadSize: downloadSize,
|
|
|
|
|
|
downloadURL: downloadURL
|
|
|
|
|
|
)
|
|
|
|
|
|
product.packages.append(newPackage)
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
task.productsToDownload = productsToDownload
|
|
|
|
|
|
task.totalSize = productsToDownload.reduce(0) { productSum, product in
|
|
|
|
|
|
productSum + product.packages.reduce(0) { packageSum, pkg in
|
|
|
|
|
|
packageSum + (pkg.downloadSize > 0 ? pkg.downloadSize : 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
await downloadUtils.startDownloadProcess(task: task)
|
|
|
|
|
|
|
|
|
|
|
|
} catch {
|
2024-11-03 17:13:25 +08:00
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
task.setStatus(.failed(DownloadStatus.FailureInfo(
|
|
|
|
|
|
message: error.localizedDescription,
|
|
|
|
|
|
error: error,
|
|
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
recoverable: true
|
|
|
|
|
|
)))
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
2024-11-03 00:12:38 +08:00
|
|
|
|
throw error
|
2024-10-31 22:35:22 +08:00
|
|
|
|
}
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
private func validateAndStartDownload(task: NewDownloadTask) async throws {
|
2024-10-31 22:35:22 +08:00
|
|
|
|
try downloadUtils.createInstallerApp(
|
|
|
|
|
|
for: task.sapCode,
|
|
|
|
|
|
version: task.version,
|
|
|
|
|
|
language: task.language,
|
2024-11-03 00:12:38 +08:00
|
|
|
|
at: task.directory
|
2024-10-31 22:35:22 +08:00
|
|
|
|
)
|
2024-11-03 00:12:38 +08:00
|
|
|
|
await startDownloadProcess(task: task)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
internal func startDownloadProcess(task: NewDownloadTask) async {
|
|
|
|
|
|
task.totalStatus = .preparing(DownloadStatus.PrepareInfo(
|
|
|
|
|
|
message: "正在准备下载...",
|
2024-10-31 22:35:22 +08:00
|
|
|
|
timestamp: Date(),
|
2024-11-03 00:12:38 +08:00
|
|
|
|
stage: .initializing
|
|
|
|
|
|
))
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
for product in task.productsToDownload {
|
|
|
|
|
|
let sapCode = product.sapCode
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let productDir = task.directory.appendingPathComponent("Contents/Resources/products/\(sapCode)")
|
|
|
|
|
|
try? FileManager.default.createDirectory(at: productDir, withIntermediateDirectories: true)
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
for package in product.packages {
|
|
|
|
|
|
task.currentPackage = package
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let downloadURL = cdn + package.downloadURL
|
|
|
|
|
|
guard let url = URL(string: downloadURL) else { continue }
|
|
|
|
|
|
|
|
|
|
|
|
var request = URLRequest(url: url)
|
|
|
|
|
|
NetworkConstants.downloadHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
|
|
|
|
|
|
|
|
|
|
|
let delegate = DownloadDelegate(
|
|
|
|
|
|
destinationDirectory: task.directory.appendingPathComponent("Contents/Resources/products/\(sapCode)"),
|
|
|
|
|
|
fileName: package.fullPackageName,
|
|
|
|
|
|
completionHandler: { [weak self] localURL, response, error in
|
|
|
|
|
|
if let error = error {
|
|
|
|
|
|
Task {
|
|
|
|
|
|
await self?.handleError(task.id, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
package.downloaded = true
|
|
|
|
|
|
package.progress = 1.0
|
|
|
|
|
|
package.status = .completed
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let totalDownloaded = task.productsToDownload.reduce(0) { sum, product in
|
|
|
|
|
|
sum + product.packages.reduce(0) { sum, pkg in
|
|
|
|
|
|
sum + (pkg.downloaded ? pkg.downloadSize : 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let totalSize = task.productsToDownload.reduce(0) { sum, product in
|
|
|
|
|
|
sum + product.packages.reduce(0) { sum, pkg in pkg.downloadSize }
|
|
|
|
|
|
}
|
|
|
|
|
|
task.totalProgress = Double(totalDownloaded) / Double(totalSize)
|
|
|
|
|
|
},
|
|
|
|
|
|
progressHandler: { bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in
|
|
|
|
|
|
package.downloadedSize = totalBytesWritten
|
|
|
|
|
|
package.progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
|
|
|
|
package.speed = Double(bytesWritten)
|
|
|
|
|
|
package.status = .downloading
|
|
|
|
|
|
|
|
|
|
|
|
task.totalDownloadedSize = totalBytesWritten
|
|
|
|
|
|
task.totalSize = totalBytesExpectedToWrite
|
|
|
|
|
|
task.totalSpeed = Double(bytesWritten)
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
|
|
|
|
|
|
let downloadTask = session.downloadTask(with: request)
|
|
|
|
|
|
downloadTask.resume()
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
await withCheckedContinuation { continuation in
|
|
|
|
|
|
delegate.completionHandler = { _, _, _ in
|
|
|
|
|
|
continuation.resume()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-10-31 22:35:22 +08:00
|
|
|
|
let driverXml = downloadUtils.generateDriverXML(
|
|
|
|
|
|
sapCode: task.sapCode,
|
|
|
|
|
|
version: task.version,
|
|
|
|
|
|
language: task.language,
|
2024-11-03 00:12:38 +08:00
|
|
|
|
productInfo: (self.saps[task.sapCode]?.versions[task.version])!,
|
|
|
|
|
|
displayName: task.displayName
|
2024-10-31 22:35:22 +08:00
|
|
|
|
)
|
2024-11-03 00:12:38 +08:00
|
|
|
|
|
|
|
|
|
|
try? driverXml.write(
|
|
|
|
|
|
to: task.directory.appendingPathComponent("Contents/Resources/products/driver.xml"),
|
|
|
|
|
|
atomically: true,
|
|
|
|
|
|
encoding: .utf8
|
|
|
|
|
|
)
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
task.totalStatus = .completed(DownloadStatus.CompletionInfo(
|
|
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
totalTime: Date().timeIntervalSince(task.createAt),
|
|
|
|
|
|
totalSize: task.totalSize
|
|
|
|
|
|
))
|
2024-10-31 22:35:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
private func performDownload(task: NewDownloadTask) async throws {
|
|
|
|
|
|
if task.sapCode == "APRO" {
|
|
|
|
|
|
// APRO 的特殊处理
|
|
|
|
|
|
// 暂时移除 APRO 的处理,或者实现新的处理逻辑
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
try downloadUtils.createInstallerApp(
|
|
|
|
|
|
for: task.sapCode,
|
|
|
|
|
|
version: task.version,
|
|
|
|
|
|
language: task.language,
|
|
|
|
|
|
at: task.directory
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try await downloadUtils.signApp(at: task.directory)
|
|
|
|
|
|
|
|
|
|
|
|
let productsDir = task.directory.appendingPathComponent("Contents/Resources/products")
|
|
|
|
|
|
try FileManager.default.createDirectory(at: productsDir, withIntermediateDirectories: true)
|
|
|
|
|
|
|
|
|
|
|
|
for product in task.productsToDownload {
|
|
|
|
|
|
let sapCode = product.sapCode
|
|
|
|
|
|
let version = product.version
|
|
|
|
|
|
|
|
|
|
|
|
print("[\(sapCode)_\(version)] Downloading application.json")
|
|
|
|
|
|
let jsonString = try await getApplicationInfo(buildGuid: product.buildGuid)
|
|
|
|
|
|
|
|
|
|
|
|
print("[\(sapCode)_\(version)] Creating folder for product")
|
|
|
|
|
|
let productDir = productsDir.appendingPathComponent(sapCode)
|
|
|
|
|
|
try FileManager.default.createDirectory(at: productDir, withIntermediateDirectories: true)
|
|
|
|
|
|
|
|
|
|
|
|
print("[\(sapCode)_\(version)] Saving application.json")
|
|
|
|
|
|
try jsonString.write(to: productDir.appendingPathComponent("application.json"),
|
|
|
|
|
|
atomically: true,
|
|
|
|
|
|
encoding: .utf8)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let productInfo = self.saps[task.sapCode]?.versions[task.version] {
|
|
|
|
|
|
let driverXml = downloadUtils.generateDriverXML(
|
|
|
|
|
|
sapCode: task.sapCode,
|
|
|
|
|
|
version: task.version,
|
|
|
|
|
|
language: task.language,
|
|
|
|
|
|
productInfo: productInfo,
|
|
|
|
|
|
displayName: task.displayName
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try driverXml.write(
|
|
|
|
|
|
to: productsDir.appendingPathComponent("driver.xml"),
|
|
|
|
|
|
atomically: true,
|
|
|
|
|
|
encoding: .utf8
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await resumeDownload(taskId: task.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func pauseDownload(taskId: UUID) {
|
|
|
|
|
|
Task {
|
|
|
|
|
|
if let task = downloadTasks.first(where: { $0.id == taskId }) {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
task.setStatus(.paused(DownloadStatus.PauseInfo(
|
|
|
|
|
|
reason: .userRequested,
|
|
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
resumable: true
|
|
|
|
|
|
)))
|
2024-11-04 14:44:52 +08:00
|
|
|
|
updateDockBadge()
|
2024-11-03 00:12:38 +08:00
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
await cancelTracker.pause(taskId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
func resumeDownload(taskId: UUID) async {
|
|
|
|
|
|
if let task = downloadTasks.first(where: { $0.id == taskId }) {
|
2024-11-03 17:13:25 +08:00
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
task.setStatus(.downloading(DownloadStatus.DownloadInfo(
|
|
|
|
|
|
fileName: task.currentPackage?.fullPackageName ?? "",
|
|
|
|
|
|
currentPackageIndex: 0,
|
|
|
|
|
|
totalPackages: task.productsToDownload.reduce(0) { $0 + $1.packages.count },
|
|
|
|
|
|
startTime: Date(),
|
|
|
|
|
|
estimatedTimeRemaining: nil
|
|
|
|
|
|
)))
|
2024-11-04 14:44:52 +08:00
|
|
|
|
updateDockBadge()
|
2024-11-03 17:13:25 +08:00
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await downloadUtils.startDownloadProcess(task: task)
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-04 00:29:08 +08:00
|
|
|
|
func cancelDownload(taskId: UUID, removeFiles: Bool = true) {
|
2024-11-03 00:12:38 +08:00
|
|
|
|
Task {
|
|
|
|
|
|
if let task = downloadTasks.first(where: { $0.id == taskId }) {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
task.setStatus(.failed(DownloadStatus.FailureInfo(
|
|
|
|
|
|
message: "下载已取消",
|
|
|
|
|
|
error: NetworkError.downloadCancelled,
|
|
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
recoverable: false
|
|
|
|
|
|
)))
|
2024-11-04 14:44:52 +08:00
|
|
|
|
updateDockBadge()
|
2024-11-03 00:12:38 +08:00
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
await cancelTracker.cancel(taskId)
|
|
|
|
|
|
if removeFiles {
|
|
|
|
|
|
try? FileManager.default.removeItem(at: task.directory)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func clearCompletedTasks() {
|
|
|
|
|
|
Task {
|
2024-11-04 00:29:08 +08:00
|
|
|
|
for task in downloadTasks {
|
|
|
|
|
|
if case .completed = task.status {
|
|
|
|
|
|
try? FileManager.default.removeItem(at: task.directory)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 14:44:52 +08:00
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
downloadTasks.removeAll { task in
|
|
|
|
|
|
if case .completed = task.status {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
updateDockBadge()
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
private func setupNetworkMonitoring() {
|
|
|
|
|
|
configureNetworkMonitor()
|
2024-10-31 22:35:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
private func handleDownloadError(taskId: UUID, error: Error) async {
|
|
|
|
|
|
guard let index = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
|
|
|
|
|
|
let task = downloadTasks[index]
|
|
|
|
|
|
|
|
|
|
|
|
let (errorMessage, isRecoverable) = classifyError(error)
|
|
|
|
|
|
|
|
|
|
|
|
if isRecoverable && task.retryCount < NetworkConstants.maxRetryAttempts {
|
|
|
|
|
|
task.retryCount += 1
|
|
|
|
|
|
let nextRetryDate = Date().addingTimeInterval(TimeInterval(NetworkConstants.retryDelay / 1_000_000_000))
|
|
|
|
|
|
task.setStatus(.retrying(DownloadStatus.RetryInfo(
|
|
|
|
|
|
attempt: task.retryCount,
|
|
|
|
|
|
maxAttempts: NetworkConstants.maxRetryAttempts,
|
|
|
|
|
|
reason: errorMessage,
|
|
|
|
|
|
nextRetryDate: nextRetryDate
|
|
|
|
|
|
)))
|
|
|
|
|
|
|
|
|
|
|
|
Task {
|
|
|
|
|
|
do {
|
|
|
|
|
|
try await Task.sleep(nanoseconds: NetworkConstants.retryDelay)
|
|
|
|
|
|
if await !cancelTracker.isCancelled(taskId) {
|
|
|
|
|
|
await downloadUtils.resumeDownloadTask(taskId: taskId)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
print("Retry cancelled for task: \(taskId)")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
task.setStatus(.failed(DownloadStatus.FailureInfo(
|
|
|
|
|
|
message: errorMessage,
|
|
|
|
|
|
error: error,
|
|
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
recoverable: isRecoverable
|
|
|
|
|
|
)))
|
|
|
|
|
|
|
|
|
|
|
|
progressObservers[taskId]?.invalidate()
|
|
|
|
|
|
progressObservers.removeValue(forKey: taskId)
|
|
|
|
|
|
|
|
|
|
|
|
if let currentPackage = task.currentPackage {
|
|
|
|
|
|
let destinationDir = task.directory
|
|
|
|
|
|
.appendingPathComponent("Contents/Resources/products/\(task.sapCode)")
|
|
|
|
|
|
let fileURL = destinationDir.appendingPathComponent(currentPackage.fullPackageName)
|
|
|
|
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDockBadge()
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func classifyError(_ error: Error) -> (message: String, recoverable: Bool) {
|
|
|
|
|
|
switch error {
|
|
|
|
|
|
case let networkError as NetworkError:
|
|
|
|
|
|
switch networkError {
|
|
|
|
|
|
case .noConnection:
|
|
|
|
|
|
return ("网络连接已断开", true)
|
|
|
|
|
|
case .timeout:
|
|
|
|
|
|
return ("下载超时", true)
|
|
|
|
|
|
case .serverUnreachable:
|
|
|
|
|
|
return ("服务器无法访问", true)
|
|
|
|
|
|
case .insufficientStorage:
|
|
|
|
|
|
return ("存储空间不足", false)
|
|
|
|
|
|
case .filePermissionDenied:
|
|
|
|
|
|
return ("没有入权限", false)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return (networkError.localizedDescription, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
case let urlError as URLError:
|
|
|
|
|
|
switch urlError.code {
|
|
|
|
|
|
case .notConnectedToInternet:
|
|
|
|
|
|
return ("网络连接已开", true)
|
|
|
|
|
|
case .timedOut:
|
|
|
|
|
|
return ("连接超时", true)
|
|
|
|
|
|
case .cancelled:
|
|
|
|
|
|
return ("下载已取消", false)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return (urlError.localizedDescription, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
return (error.localizedDescription, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
private func updateProgress(for taskId: UUID, progress: ProgressUpdate) {
|
|
|
|
|
|
guard let index = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
|
|
|
|
|
|
let task = downloadTasks[index]
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
guard let currentPackage = task.currentPackage else { return }
|
|
|
|
|
|
|
|
|
|
|
|
let now = Date()
|
|
|
|
|
|
let timeDiff = now.timeIntervalSince(currentPackage.lastUpdated)
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 17:13:25 +08:00
|
|
|
|
if timeDiff >= NetworkConstants.progressUpdateInterval {
|
|
|
|
|
|
Task { @MainActor in
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 17:13:25 +08:00
|
|
|
|
currentPackage.updateProgress(
|
|
|
|
|
|
downloadedSize: progress.totalWritten,
|
|
|
|
|
|
speed: Double(progress.bytesWritten)
|
|
|
|
|
|
)
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 17:13:25 +08:00
|
|
|
|
let totalDownloaded = task.productsToDownload.reduce(Int64(0)) { sum, prod in
|
|
|
|
|
|
sum + prod.packages.reduce(Int64(0)) { sum, pkg in
|
|
|
|
|
|
if pkg.downloaded {
|
|
|
|
|
|
return sum + pkg.downloadSize
|
|
|
|
|
|
} else if pkg.id == currentPackage.id {
|
|
|
|
|
|
return sum + progress.totalWritten
|
|
|
|
|
|
}
|
|
|
|
|
|
return sum
|
|
|
|
|
|
}
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
2024-11-03 17:13:25 +08:00
|
|
|
|
|
|
|
|
|
|
task.totalDownloadedSize = totalDownloaded
|
|
|
|
|
|
task.totalProgress = Double(totalDownloaded) / Double(task.totalSize)
|
|
|
|
|
|
task.totalSpeed = currentPackage.speed
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 17:13:25 +08:00
|
|
|
|
currentPackage.lastRecordedSize = progress.totalWritten
|
|
|
|
|
|
currentPackage.lastUpdated = now
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
2024-11-03 17:13:25 +08:00
|
|
|
|
task.objectWillChange.send()
|
|
|
|
|
|
objectWillChange.send()
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func updateTaskStatus(_ taskId: UUID, _ status: DownloadStatus) async {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
if let index = downloadTasks.firstIndex(where: { $0.id == taskId }) {
|
|
|
|
|
|
downloadTasks[index].setStatus(status)
|
|
|
|
|
|
|
|
|
|
|
|
switch status {
|
|
|
|
|
|
case .completed:
|
|
|
|
|
|
progressObservers[taskId]?.invalidate()
|
|
|
|
|
|
progressObservers.removeValue(forKey: taskId)
|
|
|
|
|
|
if activeDownloadTaskId == taskId {
|
|
|
|
|
|
activeDownloadTaskId = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case .failed:
|
|
|
|
|
|
progressObservers[taskId]?.invalidate()
|
|
|
|
|
|
progressObservers.removeValue(forKey: taskId)
|
|
|
|
|
|
if activeDownloadTaskId == taskId {
|
|
|
|
|
|
activeDownloadTaskId = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case .downloading:
|
|
|
|
|
|
activeDownloadTaskId = taskId
|
|
|
|
|
|
|
|
|
|
|
|
case .paused:
|
|
|
|
|
|
if activeDownloadTaskId == taskId {
|
|
|
|
|
|
activeDownloadTaskId = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDockBadge()
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
|
|
|
|
|
private func clampProgress(_ value: Double) -> Double {
|
|
|
|
|
|
min(1.0, max(0.0, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func retryFetchData() {
|
|
|
|
|
|
Task {
|
|
|
|
|
|
isFetchingProducts = false
|
|
|
|
|
|
loadingState = .idle
|
|
|
|
|
|
await fetchProducts()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func getActiveTaskId() async -> UUID? {
|
|
|
|
|
|
await MainActor.run { activeDownloadTaskId }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
func setTaskStatus(_ taskId: UUID, _ status: DownloadStatus) async {
|
|
|
|
|
|
await updateTaskStatus(taskId, status)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func getTasks() async -> [NewDownloadTask] {
|
|
|
|
|
|
await MainActor.run { downloadTasks }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func handleError(_ taskId: UUID, _ error: Error) async {
|
|
|
|
|
|
guard let index = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
|
|
|
|
|
|
let task = downloadTasks[index]
|
|
|
|
|
|
|
|
|
|
|
|
let (errorMessage, isRecoverable) = classifyError(error)
|
|
|
|
|
|
|
|
|
|
|
|
if isRecoverable && task.retryCount < NetworkConstants.maxRetryAttempts {
|
|
|
|
|
|
task.retryCount += 1
|
|
|
|
|
|
let nextRetryDate = Date().addingTimeInterval(TimeInterval(NetworkConstants.retryDelay / 1_000_000_000))
|
|
|
|
|
|
task.setStatus(.retrying(DownloadStatus.RetryInfo(
|
|
|
|
|
|
attempt: task.retryCount,
|
|
|
|
|
|
maxAttempts: NetworkConstants.maxRetryAttempts,
|
|
|
|
|
|
reason: errorMessage,
|
|
|
|
|
|
nextRetryDate: nextRetryDate
|
|
|
|
|
|
)))
|
|
|
|
|
|
|
|
|
|
|
|
Task {
|
|
|
|
|
|
do {
|
|
|
|
|
|
try await Task.sleep(nanoseconds: NetworkConstants.retryDelay)
|
|
|
|
|
|
if await !cancelTracker.isCancelled(taskId) {
|
|
|
|
|
|
await downloadUtils.resumeDownloadTask(taskId: taskId)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
print("Retry cancelled for task: \(taskId)")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
task.setStatus(.failed(DownloadStatus.FailureInfo(
|
|
|
|
|
|
message: errorMessage,
|
|
|
|
|
|
error: error,
|
|
|
|
|
|
timestamp: Date(),
|
|
|
|
|
|
recoverable: isRecoverable
|
|
|
|
|
|
)))
|
|
|
|
|
|
|
|
|
|
|
|
progressObservers[taskId]?.invalidate()
|
|
|
|
|
|
progressObservers.removeValue(forKey: taskId)
|
|
|
|
|
|
|
|
|
|
|
|
if let currentPackage = task.currentPackage {
|
|
|
|
|
|
let destinationDir = task.directory
|
|
|
|
|
|
.appendingPathComponent("Contents/Resources/products/\(task.sapCode)")
|
|
|
|
|
|
let fileURL = destinationDir.appendingPathComponent(currentPackage.fullPackageName)
|
|
|
|
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDockBadge()
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
func updateDownloadProgress(for taskId: UUID, progress: ProgressUpdate) {
|
|
|
|
|
|
updateProgress(for: taskId, progress: progress)
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
|
|
|
|
|
var cdnUrl: String {
|
|
|
|
|
|
get async {
|
|
|
|
|
|
await MainActor.run { cdn }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
func removeTask(taskId: UUID, removeFiles: Bool = true) {
|
|
|
|
|
|
Task {
|
|
|
|
|
|
await cancelTracker.cancel(taskId)
|
|
|
|
|
|
|
|
|
|
|
|
if let task = downloadTasks.first(where: { $0.id == taskId }) {
|
|
|
|
|
|
if removeFiles {
|
2024-11-04 14:44:52 +08:00
|
|
|
|
try? FileManager.default.removeItem(at: task.directory)
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
downloadTasks.removeAll { $0.id == taskId }
|
|
|
|
|
|
updateDockBadge()
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
|
|
|
|
|
private func fetchProductsWithRetry() async {
|
|
|
|
|
|
guard !isFetchingProducts else { return }
|
|
|
|
|
|
|
|
|
|
|
|
isFetchingProducts = true
|
|
|
|
|
|
loadingState = .loading
|
|
|
|
|
|
|
|
|
|
|
|
let maxRetries = 3
|
|
|
|
|
|
var retryCount = 0
|
|
|
|
|
|
|
|
|
|
|
|
while retryCount < maxRetries {
|
|
|
|
|
|
do {
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let (saps, cdn, sapCodes) = try await fetchProductsData()
|
|
|
|
|
|
|
2024-10-31 22:35:22 +08:00
|
|
|
|
await MainActor.run {
|
2024-11-03 00:12:38 +08:00
|
|
|
|
self.saps = saps
|
2024-10-31 22:35:22 +08:00
|
|
|
|
self.cdn = cdn
|
2024-11-03 00:12:38 +08:00
|
|
|
|
self.sapCodes = sapCodes
|
2024-10-31 22:35:22 +08:00
|
|
|
|
self.loadingState = .success
|
|
|
|
|
|
self.isFetchingProducts = false
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
retryCount += 1
|
|
|
|
|
|
if retryCount == maxRetries {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.loadingState = .failed(error)
|
|
|
|
|
|
self.isFetchingProducts = false
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
try? await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount))) * 1_000_000_000)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
private func clearCompletedDownloadTasks() async {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
downloadTasks.removeAll { task in
|
2024-11-04 00:29:08 +08:00
|
|
|
|
if task.status.isCompleted || task.status.isFailed {
|
|
|
|
|
|
try? FileManager.default.removeItem(at: task.directory)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
updateDockBadge()
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
|
|
|
|
|
|
func installProduct(at path: URL) async {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
installationState = .installing(progress: 0, status: "准备安装...")
|
2024-11-04 17:40:01 +08:00
|
|
|
|
installationLogs.removeAll()
|
2024-10-31 22:35:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
do {
|
2024-11-04 17:40:01 +08:00
|
|
|
|
try await installManager.install(
|
|
|
|
|
|
at: path,
|
|
|
|
|
|
progressHandler: { progress, status in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
if status.contains("完成") || status.contains("成功") {
|
|
|
|
|
|
self.installationState = .completed
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.installationState = .installing(progress: progress, status: status)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
logHandler: { log in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
self.installationLogs.append(log)
|
2024-11-04 14:44:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 17:40:01 +08:00
|
|
|
|
)
|
2024-11-04 14:44:52 +08:00
|
|
|
|
|
2024-10-31 22:35:22 +08:00
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
installationState = .completed
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
await MainActor.run {
|
2024-11-01 17:28:23 +08:00
|
|
|
|
if let installError = error as? InstallManager.InstallError {
|
|
|
|
|
|
switch installError {
|
|
|
|
|
|
case .installationFailed(let message):
|
2024-11-04 14:44:52 +08:00
|
|
|
|
if message.contains("需要重新输入密码") {
|
|
|
|
|
|
Task {
|
|
|
|
|
|
await installProduct(at: path)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
installationState = .failed(InstallManager.InstallError.installationFailed(message))
|
|
|
|
|
|
}
|
|
|
|
|
|
case .cancelled:
|
|
|
|
|
|
installationState = .failed(InstallManager.InstallError.cancelled)
|
|
|
|
|
|
case .setupNotFound:
|
|
|
|
|
|
installationState = .failed(InstallManager.InstallError.setupNotFound)
|
|
|
|
|
|
case .permissionDenied:
|
|
|
|
|
|
installationState = .failed(InstallManager.InstallError.permissionDenied)
|
2024-11-01 17:28:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2024-11-04 14:44:52 +08:00
|
|
|
|
installationState = .failed(InstallManager.InstallError.installationFailed(error.localizedDescription))
|
2024-11-01 17:28:23 +08:00
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func cancelInstallation() {
|
|
|
|
|
|
Task {
|
|
|
|
|
|
await installManager.cancel()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-01 17:28:23 +08:00
|
|
|
|
|
|
|
|
|
|
func retryInstallation(at path: URL) async {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
installationState = .installing(progress: 0, status: "正在重试安装...")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
do {
|
2024-11-04 17:40:01 +08:00
|
|
|
|
try await installManager.retry(
|
|
|
|
|
|
at: path,
|
|
|
|
|
|
progressHandler: { progress, status in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
if status.contains("完成") || status.contains("成功") {
|
|
|
|
|
|
self.installationState = .completed
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.installationState = .installing(progress: progress, status: status)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
logHandler: { log in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
self.installationLogs.append(log)
|
2024-11-01 17:28:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 17:40:01 +08:00
|
|
|
|
)
|
2024-11-01 17:28:23 +08:00
|
|
|
|
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
installationState = .completed
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if case InstallManager.InstallError.installationFailed(let message) = error,
|
|
|
|
|
|
message.contains("需要重新输入密码") {
|
|
|
|
|
|
await installProduct(at: path)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
if let installError = error as? InstallManager.InstallError {
|
|
|
|
|
|
installationState = .failed(installError)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
installationState = .failed(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-03 00:12:38 +08:00
|
|
|
|
|
|
|
|
|
|
func getApplicationInfo(buildGuid: String) async throws -> String {
|
|
|
|
|
|
guard let url = URL(string: NetworkConstants.applicationJsonURL) else {
|
|
|
|
|
|
throw NetworkError.invalidURL(NetworkConstants.applicationJsonURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var request = URLRequest(url: url)
|
|
|
|
|
|
request.httpMethod = "GET"
|
|
|
|
|
|
|
|
|
|
|
|
var headers = NetworkConstants.adobeRequestHeaders
|
|
|
|
|
|
headers["x-adobe-build-guid"] = buildGuid
|
|
|
|
|
|
headers["Cookie"] = generateCookie()
|
|
|
|
|
|
|
|
|
|
|
|
headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
2024-11-04 17:40:01 +08:00
|
|
|
|
|
2024-11-03 00:12:38 +08:00
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
|
|
|
|
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
|
|
|
|
throw NetworkError.invalidResponse
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
|
|
|
|
throw NetworkError.httpError(httpResponse.statusCode, String(data: data, encoding: .utf8))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard let jsonString = String(data: data, encoding: .utf8) else {
|
2024-11-04 14:44:52 +08:00
|
|
|
|
throw NetworkError.invalidData("无法将响应数据转换为json符串")
|
2024-11-03 00:12:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return jsonString
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func fetchProductsData() async throws -> ([String: Sap], String, [SapCodes]) {
|
|
|
|
|
|
var components = URLComponents(string: NetworkConstants.productsXmlURL)
|
|
|
|
|
|
components?.queryItems = [
|
|
|
|
|
|
URLQueryItem(name: "_type", value: "xml"),
|
|
|
|
|
|
URLQueryItem(name: "channel", value: "ccm"),
|
|
|
|
|
|
URLQueryItem(name: "channel", value: "sti"),
|
|
|
|
|
|
URLQueryItem(name: "platform", value: "osx10-64,osx10,macarm64,macuniversal"),
|
|
|
|
|
|
URLQueryItem(name: "productType", value: "Desktop")
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
guard let url = components?.url else {
|
|
|
|
|
|
throw NetworkError.invalidURL(NetworkConstants.productsXmlURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var request = URLRequest(url: url)
|
|
|
|
|
|
request.httpMethod = "GET"
|
|
|
|
|
|
NetworkConstants.adobeRequestHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
|
|
|
|
|
|
|
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
|
|
|
|
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
|
|
|
|
throw NetworkError.invalidResponse
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
|
|
|
|
throw NetworkError.httpError(httpResponse.statusCode, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard let xmlString = String(data: data, encoding: .utf8) else {
|
|
|
|
|
|
throw NetworkError.invalidData("无法解码XML数据")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let result: ([String: Sap], String, [SapCodes]) = try await Task.detached(priority: .userInitiated) {
|
|
|
|
|
|
let parseResult = try XHXMLParser.parse(xmlString: xmlString)
|
|
|
|
|
|
let products = parseResult.products, cdn = parseResult.cdn
|
|
|
|
|
|
var sapCodes: [SapCodes] = []
|
|
|
|
|
|
let allowedPlatforms = ["macuniversal", "macarm64", "osx10-64", "osx10"]
|
|
|
|
|
|
for product in products.values {
|
|
|
|
|
|
if product.isValid {
|
|
|
|
|
|
var lastVersion: String? = nil
|
|
|
|
|
|
for version in product.versions.values.reversed() {
|
|
|
|
|
|
if !version.buildGuid.isEmpty && allowedPlatforms.contains(version.apPlatform) {
|
|
|
|
|
|
lastVersion = version.productVersion
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if lastVersion != nil {
|
|
|
|
|
|
sapCodes.append(SapCodes(
|
|
|
|
|
|
sapCode: product.sapCode,
|
|
|
|
|
|
displayName: product.displayName
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return (products, cdn, sapCodes)
|
|
|
|
|
|
}.value
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
|
func updateTaskStatus(_ taskId: UUID, status: DownloadStatus) {
|
|
|
|
|
|
if let index = downloadTasks.firstIndex(where: { $0.id == taskId }) {
|
|
|
|
|
|
downloadTasks[index].setStatus(status)
|
|
|
|
|
|
objectWillChange.send()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
|
|
|
|
|
func isVersionDownloaded(sap: Sap, version: String, language: String) -> URL? {
|
|
|
|
|
|
let platform = sap.versions[version]?.apPlatform ?? "unknown"
|
2024-11-04 17:40:01 +08:00
|
|
|
|
let fileName = "Install \(sap.sapCode)_\(version)-\(language)-\(platform).app"
|
2024-11-04 00:29:08 +08:00
|
|
|
|
|
|
|
|
|
|
if !defaultDirectory.isEmpty {
|
|
|
|
|
|
let defaultPath = URL(fileURLWithPath: defaultDirectory)
|
|
|
|
|
|
.appendingPathComponent(fileName)
|
|
|
|
|
|
if FileManager.default.fileExists(atPath: defaultPath.path) {
|
|
|
|
|
|
return defaultPath
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let task = downloadTasks.first(where: {
|
|
|
|
|
|
$0.sapCode == sap.sapCode &&
|
|
|
|
|
|
$0.version == version &&
|
|
|
|
|
|
$0.language == language
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if FileManager.default.fileExists(atPath: task.directory.path) {
|
|
|
|
|
|
return task.directory
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2024-10-31 22:35:22 +08:00
|
|
|
|
}
|