From 6de8104084aefe659ecbf6053d173f2892c357e9 Mon Sep 17 00:00:00 2001 From: X1a0He Date: Sat, 9 Nov 2024 23:15:50 +0800 Subject: [PATCH] Add: Support task record persistence. --- Adobe Downloader.xcodeproj/project.pbxproj | 8 +- .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 +- Adobe Downloader/Adobe DownloaderApp.swift | 4 + Adobe Downloader/AppDelegate.swift | 73 ++++- Adobe Downloader/Commons/Enums.swift | 118 ++++++- Adobe Downloader/ContentView.swift | 1 + Adobe Downloader/Models/DownloadTask.swift | 4 +- Adobe Downloader/NetworkManager.swift | 162 +++------ .../Services/NetworkService.swift | 100 ++++++ Adobe Downloader/Utils/CancelTracker.swift | 6 +- Adobe Downloader/Utils/DownloadUtils.swift | 307 +++++++++--------- .../Utils/TaskPersistenceManager.swift | 255 +++++++++++++++ Adobe Downloader/Views/AppCardView.swift | 102 ++++-- .../Views/DownloadProgressView.swift | 9 +- Localizables/Localizable.xcstrings | 130 ++++++++ appcast.xml | 34 +- readme-en.md | 18 +- readme.md | 16 +- update-log.md | 30 ++ 19 files changed, 1052 insertions(+), 331 deletions(-) create mode 100644 Adobe Downloader/Services/NetworkService.swift create mode 100644 Adobe Downloader/Utils/TaskPersistenceManager.swift diff --git a/Adobe Downloader.xcodeproj/project.pbxproj b/Adobe Downloader.xcodeproj/project.pbxproj index efeda1e..0d46756 100644 --- a/Adobe Downloader.xcodeproj/project.pbxproj +++ b/Adobe Downloader.xcodeproj/project.pbxproj @@ -286,7 +286,7 @@ CODE_SIGN_ENTITLEMENTS = "Adobe Downloader/Adobe Downloader.entitlements"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 102; + CURRENT_PROJECT_VERSION = 110; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Adobe Downloader/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -300,7 +300,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.x1a0he.macOS.Adobe-Downloader"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -318,7 +318,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 102; + CURRENT_PROJECT_VERSION = 110; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Adobe Downloader/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -332,7 +332,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.x1a0he.macOS.Adobe-Downloader"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Adobe Downloader.xcodeproj/xcuserdata/hejinhui.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Adobe Downloader.xcodeproj/xcuserdata/hejinhui.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 458873a..e483083 100644 --- a/Adobe Downloader.xcodeproj/xcuserdata/hejinhui.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Adobe Downloader.xcodeproj/xcuserdata/hejinhui.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -14,9 +14,9 @@ filePath = "Adobe Downloader/Utils/DownloadUtils.swift" startingColumnNumber = "9223372036854775807" endingColumnNumber = "9223372036854775807" - startingLineNumber = "468" - endingLineNumber = "468" - landmarkName = "retryPackage(task:package:)" + startingLineNumber = "463" + endingLineNumber = "463" + landmarkName = "startDownloadProcess(task:)" landmarkType = "7"> diff --git a/Adobe Downloader/Adobe DownloaderApp.swift b/Adobe Downloader/Adobe DownloaderApp.swift index 48495c7..5508529 100644 --- a/Adobe Downloader/Adobe DownloaderApp.swift +++ b/Adobe Downloader/Adobe DownloaderApp.swift @@ -56,6 +56,10 @@ struct Adobe_DownloaderApp: App { .frame(width: 850, height: 800) .tint(.blue) .onAppear { + appDelegate.networkManager = networkManager + + networkManager.loadSavedTasks() + checkCreativeCloudSetup() if ModifySetup.checkSetupBackup() { diff --git a/Adobe Downloader/AppDelegate.swift b/Adobe Downloader/AppDelegate.swift index a0db0d4..2cd831f 100644 --- a/Adobe Downloader/AppDelegate.swift +++ b/Adobe Downloader/AppDelegate.swift @@ -2,7 +2,78 @@ import Cocoa import SwiftUI class AppDelegate: NSObject, NSApplicationDelegate { + private var eventMonitor: Any? + var networkManager: NetworkManager? + func applicationDidFinishLaunching(_ notification: Notification) { - NSApp.mainMenu = nil + NSApp.mainMenu = nil + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.modifierFlags.contains(.command) && event.characters?.lowercased() == "q" { + if let mainWindow = NSApp.mainWindow, + mainWindow.sheets.isEmpty && !mainWindow.isSheet { + self?.handleQuitCommand() + return nil + } + } + return event + } + } + + @MainActor private func handleQuitCommand() { + guard let manager = networkManager else { + NSApplication.shared.terminate(nil) + return + } + + let hasActiveDownloads = manager.downloadTasks.contains { task in + if case .downloading = task.totalStatus { + return true + } + return false + } + + if hasActiveDownloads { + Task { + for task in manager.downloadTasks { + if case .downloading = task.totalStatus { + await manager.downloadUtils.pauseDownloadTask( + taskId: task.id, + reason: .other(String(localized: "程序即将退出")) + ) + } + } + + await MainActor.run { + let alert = NSAlert() + alert.messageText = String(localized: "确认退出") + alert.informativeText = String(localized:"有正在进行的下载任务,确定要退出吗?\n所有下载任务的进度已保存,下次启动可以继续下载") + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized:"退出")) + alert.addButton(withTitle: String(localized:"取消")) + + let response = alert.runModal() + if response == .alertSecondButtonReturn { + Task { + for task in manager.downloadTasks { + if case .paused = task.totalStatus { + await manager.downloadUtils.resumeDownloadTask(taskId: task.id) + } + } + } + } else { + NSApplication.shared.terminate(0) + } + } + } + } else { + NSApplication.shared.terminate(nil) + } + } + + deinit { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + } + networkManager = nil } } diff --git a/Adobe Downloader/Commons/Enums.swift b/Adobe Downloader/Commons/Enums.swift index a249963..0ba46ca 100644 --- a/Adobe Downloader/Commons/Enums.swift +++ b/Adobe Downloader/Commons/Enums.swift @@ -6,7 +6,7 @@ import Foundation import SwiftUI -enum PackageStatus: Equatable { +enum PackageStatus: Equatable, Codable { case waiting case downloading case paused @@ -165,7 +165,7 @@ enum NetworkError: Error, LocalizedError { } } -enum DownloadStatus: Equatable { +enum DownloadStatus: Equatable, Codable { case waiting case preparing(PrepareInfo) case downloading(DownloadInfo) @@ -174,12 +174,12 @@ enum DownloadStatus: Equatable { case failed(FailureInfo) case retrying(RetryInfo) - struct PrepareInfo { + struct PrepareInfo: Codable { let message: String let timestamp: Date let stage: PrepareStage - enum PrepareStage { + enum PrepareStage: Codable { case initializing case creatingInstaller case signingApp @@ -188,7 +188,7 @@ enum DownloadStatus: Equatable { } } - struct DownloadInfo { + struct DownloadInfo: Codable { let fileName: String let currentPackageIndex: Int let totalPackages: Int @@ -196,12 +196,12 @@ enum DownloadStatus: Equatable { let estimatedTimeRemaining: TimeInterval? } - struct PauseInfo { + struct PauseInfo: Codable { let reason: PauseReason let timestamp: Date let resumable: Bool - enum PauseReason { + enum PauseReason: Codable { case userRequested case networkIssue case systemSleep @@ -209,26 +209,124 @@ enum DownloadStatus: Equatable { } } - struct CompletionInfo { + struct CompletionInfo: Codable { let timestamp: Date let totalTime: TimeInterval let totalSize: Int64 } - struct FailureInfo { + struct FailureInfo: Codable { let message: String let error: Error? let timestamp: Date let recoverable: Bool + + enum CodingKeys: CodingKey { + case message + case timestamp + case recoverable + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(message, forKey: .message) + try container.encode(timestamp, forKey: .timestamp) + try container.encode(recoverable, forKey: .recoverable) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + message = try container.decode(String.self, forKey: .message) + timestamp = try container.decode(Date.self, forKey: .timestamp) + recoverable = try container.decode(Bool.self, forKey: .recoverable) + error = nil + } + + init(message: String, error: Error?, timestamp: Date, recoverable: Bool) { + self.message = message + self.error = error + self.timestamp = timestamp + self.recoverable = recoverable + } } - struct RetryInfo { + struct RetryInfo: Codable { let attempt: Int let maxAttempts: Int let reason: String let nextRetryDate: Date } + private enum CodingKeys: String, CodingKey { + case type + case info + } + + private enum StatusType: String, Codable { + case waiting + case preparing + case downloading + case paused + case completed + case failed + case retrying + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .waiting: + try container.encode(StatusType.waiting, forKey: .type) + case .preparing(let info): + try container.encode(StatusType.preparing, forKey: .type) + try container.encode(info, forKey: .info) + case .downloading(let info): + try container.encode(StatusType.downloading, forKey: .type) + try container.encode(info, forKey: .info) + case .paused(let info): + try container.encode(StatusType.paused, forKey: .type) + try container.encode(info, forKey: .info) + case .completed(let info): + try container.encode(StatusType.completed, forKey: .type) + try container.encode(info, forKey: .info) + case .failed(let info): + try container.encode(StatusType.failed, forKey: .type) + try container.encode(info, forKey: .info) + case .retrying(let info): + try container.encode(StatusType.retrying, forKey: .type) + try container.encode(info, forKey: .info) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(StatusType.self, forKey: .type) + + switch type { + case .waiting: + self = .waiting + case .preparing: + let info = try container.decode(PrepareInfo.self, forKey: .info) + self = .preparing(info) + case .downloading: + let info = try container.decode(DownloadInfo.self, forKey: .info) + self = .downloading(info) + case .paused: + let info = try container.decode(PauseInfo.self, forKey: .info) + self = .paused(info) + case .completed: + let info = try container.decode(CompletionInfo.self, forKey: .info) + self = .completed(info) + case .failed: + let info = try container.decode(FailureInfo.self, forKey: .info) + self = .failed(info) + case .retrying: + let info = try container.decode(RetryInfo.self, forKey: .info) + self = .retrying(info) + } + } + var description: String { switch self { case .waiting: diff --git a/Adobe Downloader/ContentView.swift b/Adobe Downloader/ContentView.swift index 401b3a4..65c1f26 100644 --- a/Adobe Downloader/ContentView.swift +++ b/Adobe Downloader/ContentView.swift @@ -65,6 +65,7 @@ struct ContentView: View { Image(systemName: "arrow.down.circle") .imageScale(.medium) } + .disabled(isRefreshing) .buttonStyle(.borderless) .overlay( Group { diff --git a/Adobe Downloader/Models/DownloadTask.swift b/Adobe Downloader/Models/DownloadTask.swift index 1588cfb..13791e1 100644 --- a/Adobe Downloader/Models/DownloadTask.swift +++ b/Adobe Downloader/Models/DownloadTask.swift @@ -42,6 +42,7 @@ class NewDownloadTask: Identifiable, ObservableObject, Equatable { objectWillChange.send() } } + let platform: String var status: DownloadStatus { totalStatus ?? .waiting @@ -88,7 +89,7 @@ class NewDownloadTask: Identifiable, ObservableObject, Equatable { objectWillChange.send() } - init(sapCode: String, version: String, language: String, displayName: String, directory: URL, productsToDownload: [ProductsToDownload] = [], retryCount: Int = 0, createAt: Date, totalStatus: DownloadStatus? = nil, totalProgress: Double, totalDownloadedSize: Int64 = 0, totalSize: Int64 = 0, totalSpeed: Double = 0, currentPackage: Package? = nil) { + init(sapCode: String, version: String, language: String, displayName: String, directory: URL, productsToDownload: [ProductsToDownload] = [], retryCount: Int = 0, createAt: Date, totalStatus: DownloadStatus? = nil, totalProgress: Double, totalDownloadedSize: Int64 = 0, totalSize: Int64 = 0, totalSpeed: Double = 0, currentPackage: Package? = nil, platform: String) { self.sapCode = sapCode self.version = version self.language = language @@ -104,6 +105,7 @@ class NewDownloadTask: Identifiable, ObservableObject, Equatable { self.totalSpeed = totalSpeed self.currentPackage = currentPackage self.displayInstallButton = sapCode != "APRO" + self.platform = platform } static func == (lhs: NewDownloadTask, rhs: NewDownloadTask) -> Bool { diff --git a/Adobe Downloader/NetworkManager.swift b/Adobe Downloader/NetworkManager.swift index 3864dd1..9b123ca 100644 --- a/Adobe Downloader/NetworkManager.swift +++ b/Adobe Downloader/NetworkManager.swift @@ -40,12 +40,18 @@ class NetworkManager: ObservableObject { case failed(Error) } - init() { + private let networkService: NetworkService + + init(networkService: NetworkService = NetworkService(), + downloadUtils: DownloadUtils? = nil) { let useAppleSilicon = UserDefaults.standard.bool(forKey: "downloadAppleSilicon") self.allowedPlatform = useAppleSilicon ? ["macuniversal", "macarm64"] : ["macuniversal", "osx10-64"] - self.downloadUtils = DownloadUtils(networkManager: self, cancelTracker: cancelTracker) - setupNetworkMonitoring() + self.networkService = networkService + self.downloadUtils = downloadUtils ?? DownloadUtils(networkManager: self, cancelTracker: cancelTracker) + + TaskPersistenceManager.shared.setCancelTracker(cancelTracker) + configureNetworkMonitor() } func fetchProducts() async { @@ -64,15 +70,21 @@ class NetworkManager: ObservableObject { directory: destinationURL, productsToDownload: [], createAt: Date(), - totalStatus: .preparing(DownloadStatus.PrepareInfo(message: "正在准备下载...", timestamp: Date(), stage: .initializing)), + totalStatus: .preparing(DownloadStatus.PrepareInfo( + message: "正在准备下载...", + timestamp: Date(), + stage: .initializing + )), totalProgress: 0, totalDownloadedSize: 0, totalSize: 0, - totalSpeed: 0 + totalSpeed: 0, + platform: productInfo.apPlatform ) downloadTasks.append(task) updateDockBadge() + saveTask(task) do { try await downloadUtils.handleDownload(task: task, productInfo: productInfo, allowedPlatform: allowedPlatform, saps: saps) @@ -84,6 +96,7 @@ class NetworkManager: ObservableObject { timestamp: Date(), recoverable: true ))) + saveTask(task) objectWillChange.send() } throw error @@ -101,10 +114,22 @@ class NetworkManager: ObservableObject { await cancelTracker.cancel(taskId) if let task = downloadTasks.first(where: { $0.id == taskId }) { + if task.status.isActive { + task.setStatus(.failed(DownloadStatus.FailureInfo( + message: "下载已取消", + error: NetworkError.downloadCancelled, + timestamp: Date(), + recoverable: false + ))) + saveTask(task) + } + if removeFiles { try? FileManager.default.removeItem(at: task.directory) } + TaskPersistenceManager.shared.removeTask(task) + await MainActor.run { downloadTasks.removeAll { $0.id == taskId } updateDockBadge() @@ -125,7 +150,7 @@ class NetworkManager: ObservableObject { while retryCount < maxRetries { do { - let (saps, cdn, sapCodes) = try await fetchProductsData() + let (saps, cdn, sapCodes) = try await networkService.fetchProductsData() await MainActor.run { self.saps = saps @@ -273,97 +298,17 @@ class NetworkManager: ObservableObject { } 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) } - - 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 { - throw NetworkError.invalidData("无法将响应数据转换为json符串") - } - - 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 + return try await networkService.getApplicationInfo(buildGuid: buildGuid) } func isVersionDownloaded(sap: Sap, version: String, language: String) -> URL? { + if let task = downloadTasks.first(where: { + $0.sapCode == sap.sapCode && + $0.version == version && + $0.language == language && + !$0.status.isCompleted + }) { return task.directory } + let platform = sap.versions[version]?.apPlatform ?? "unknown" let fileName = sap.sapCode == "APRO" ? "Adobe Downloader \(sap.sapCode)_\(version)_\(platform).dmg" @@ -377,16 +322,6 @@ class NetworkManager: ObservableObject { } } - 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 } @@ -405,10 +340,6 @@ class NetworkManager: ObservableObject { } } - private func setupNetworkMonitoring() { - configureNetworkMonitor() - } - func retryFetchData() { Task { isFetchingProducts = false @@ -420,4 +351,19 @@ class NetworkManager: ObservableObject { func updateAllowedPlatform(useAppleSilicon: Bool) { allowedPlatform = useAppleSilicon ? ["macuniversal", "macarm64"] : ["macuniversal", "osx10-64"] } + + func saveTask(_ task: NewDownloadTask) { + TaskPersistenceManager.shared.saveTask(task) + } + + func loadSavedTasks() { + let savedTasks = TaskPersistenceManager.shared.loadTasks() + for task in savedTasks { + for product in task.productsToDownload { + product.updateCompletedPackages() + } + } + downloadTasks.append(contentsOf: savedTasks) + updateDockBadge() + } } diff --git a/Adobe Downloader/Services/NetworkService.swift b/Adobe Downloader/Services/NetworkService.swift new file mode 100644 index 0000000..376d75e --- /dev/null +++ b/Adobe Downloader/Services/NetworkService.swift @@ -0,0 +1,100 @@ +import Foundation + +class NetworkService { + 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 + } + + 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) } + + 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 { + throw NetworkError.invalidData("无法将响应数据转换为json符串") + } + + return jsonString + } + + private func generateCookie() -> String { + let timestamp = Int(Date().timeIntervalSince1970) + let random = Int.random(in: 100000...999999) + return "s_cc=true; s_sq=; AMCV_9E1005A551ED61CA0A490D45%40AdobeOrg=1075005958%7CMCIDTS%7C\(timestamp)%7CMCMID%7C\(random)%7CMCAAMLH-1683925272%7C11%7CMCAAMB-1683925272%7CRKhpRz8krg2tLO6pguXWp5olkAcUniQYPHaMWWgdJ3xzPWQmdj0y%7CMCOPTOUT-1683327672s%7CNONE%7CvVersion%7C4.4.1; gpv=cc-search-desktop; s_ppn=cc-search-desktop" + } +} \ No newline at end of file diff --git a/Adobe Downloader/Utils/CancelTracker.swift b/Adobe Downloader/Utils/CancelTracker.swift index 72819ee..12afc99 100644 --- a/Adobe Downloader/Utils/CancelTracker.swift +++ b/Adobe Downloader/Utils/CancelTracker.swift @@ -8,7 +8,7 @@ import Foundation actor CancelTracker { private var cancelledIds: Set = [] private var pausedIds: Set = [] - private var downloadTasks: [UUID: URLSessionDownloadTask] = [:] + var downloadTasks: [UUID: URLSessionDownloadTask] = [:] private var sessions: [UUID: URLSession] = [:] private var resumeData: [UUID: Data] = [:] @@ -64,4 +64,8 @@ actor CancelTracker { func isPaused(_ id: UUID) -> Bool { return pausedIds.contains(id) } + + func storeResumeData(_ id: UUID, data: Data) { + resumeData[id] = data + } } diff --git a/Adobe Downloader/Utils/DownloadUtils.swift b/Adobe Downloader/Utils/DownloadUtils.swift index a23bcc4..ba94562 100644 --- a/Adobe Downloader/Utils/DownloadUtils.swift +++ b/Adobe Downloader/Utils/DownloadUtils.swift @@ -103,6 +103,18 @@ class DownloadUtils { } func pauseDownloadTask(taskId: UUID, reason: DownloadStatus.PauseInfo.PauseReason) async { + let task = await cancelTracker.downloadTasks[taskId] + if let downloadTask = task { + let data = await withCheckedContinuation { continuation in + downloadTask.cancel(byProducingResumeData: { data in + continuation.resume(returning: data) + }) + } + if let data = data { + await cancelTracker.storeResumeData(taskId, data: data) + } + } + await MainActor.run { if let task = networkManager?.downloadTasks.first(where: { $0.id == taskId }) { task.setStatus(.paused(DownloadStatus.PauseInfo( @@ -110,24 +122,12 @@ class DownloadUtils { timestamp: Date(), resumable: true ))) + networkManager?.saveTask(task) } } - await cancelTracker.pause(taskId) } func resumeDownloadTask(taskId: UUID) async { - await MainActor.run { - if let task = networkManager?.downloadTasks.first(where: { $0.id == taskId }) { - task.setStatus(.downloading(DownloadStatus.DownloadInfo( - fileName: task.currentPackage?.fullPackageName ?? "", - currentPackageIndex: 0, - totalPackages: task.productsToDownload.reduce(0) { $0 + $1.packages.count }, - startTime: Date(), - estimatedTimeRemaining: nil - ))) - } - } - if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) { await startDownloadProcess(task: task) } @@ -223,7 +223,133 @@ class DownloadUtils { } } - internal func startDownloadProcess(task: NewDownloadTask) async { + private func downloadPackage(package: Package, task: NewDownloadTask, product: ProductsToDownload, url: URL? = nil, resumeData: Data? = nil) async throws { + var lastUpdateTime = Date() + var lastBytes: Int64 = 0 + + return try await withCheckedThrowingContinuation { continuation in + let delegate = DownloadDelegate( + destinationDirectory: task.directory.appendingPathComponent(product.sapCode), + fileName: package.fullPackageName, + completionHandler: { [weak networkManager] localURL, response, error in + if let error = error { + if (error as NSError).code == NSURLErrorCancelled { + continuation.resume() + } else { + continuation.resume(throwing: error) + } + return + } + + Task { @MainActor in + package.downloadedSize = package.downloadSize + package.progress = 1.0 + package.status = .completed + package.downloaded = true + + var totalDownloaded: Int64 = 0 + var totalSize: Int64 = 0 + + for prod in task.productsToDownload { + for pkg in prod.packages { + totalSize += pkg.downloadSize + if pkg.downloaded { + totalDownloaded += pkg.downloadSize + } + } + } + + task.totalSize = totalSize + task.totalDownloadedSize = totalDownloaded + task.totalProgress = Double(totalDownloaded) / Double(totalSize) + task.totalSpeed = 0 + + let allCompleted = task.productsToDownload.allSatisfy { + product in product.packages.allSatisfy { $0.downloaded } + } + + if allCompleted { + task.setStatus(.completed(DownloadStatus.CompletionInfo( + timestamp: Date(), + totalTime: Date().timeIntervalSince(task.createAt), + totalSize: totalSize + ))) + } + + product.updateCompletedPackages() + networkManager?.saveTask(task) + networkManager?.objectWillChange.send() + } + + continuation.resume() + }, + progressHandler: { [weak networkManager] bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in + Task { @MainActor in + let now = Date() + let timeDiff = now.timeIntervalSince(lastUpdateTime) + + if timeDiff >= 1.0 { + let bytesDiff = totalBytesWritten - lastBytes + let speed = Double(bytesDiff) / timeDiff + + package.updateProgress( + downloadedSize: totalBytesWritten, + speed: speed + ) + + var totalDownloaded: Int64 = 0 + var totalSize: Int64 = 0 + var currentSpeed: Double = 0 + + for prod in task.productsToDownload { + for pkg in prod.packages { + totalSize += pkg.downloadSize + if pkg.downloaded { + totalDownloaded += pkg.downloadSize + } else if pkg.id == package.id { + totalDownloaded += totalBytesWritten + currentSpeed = speed + } + } + } + + task.totalSize = totalSize + task.totalDownloadedSize = totalDownloaded + task.totalProgress = totalSize > 0 ? Double(totalDownloaded) / Double(totalSize) : 0 + task.totalSpeed = currentSpeed + + lastUpdateTime = now + lastBytes = totalBytesWritten + + networkManager?.objectWillChange.send() + } + } + } + ) + + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + + Task { + let downloadTask: URLSessionDownloadTask + if let resumeData = resumeData { + downloadTask = session.downloadTask(withResumeData: resumeData) + } else if let url = url { + var request = URLRequest(url: url) + NetworkConstants.downloadHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } + downloadTask = session.downloadTask(with: request) + } else { + continuation.resume(throwing: NetworkError.invalidData("Neither URL nor resume data provided")) + return + } + + await cancelTracker.registerTask(task.id, task: downloadTask, session: session) + await cancelTracker.clearResumeData(task.id) + downloadTask.resume() + } + } + } + + private func startDownloadProcess(task: NewDownloadTask) async { actor DownloadProgress { var currentPackageIndex: Int = 0 func increment() { currentPackageIndex += 1 } @@ -300,6 +426,7 @@ class DownloadUtils { startTime: Date(), estimatedTimeRemaining: nil ))) + networkManager?.saveTask(task) } await progress.increment() @@ -318,10 +445,14 @@ class DownloadUtils { guard let url = URL(string: downloadURL) else { continue } do { - try await downloadPackage(package: package, task: task, product: product, url: url) + if let resumeData = await cancelTracker.getResumeData(task.id) { + try await downloadPackage(package: package, task: task, product: product, resumeData: resumeData) + } else { + try await downloadPackage(package: package, task: task, product: product, url: url) + } } catch { - print("Error downloading \(package.fullPackageName): \(error.localizedDescription)") - await self.handleError(task.id, error) + print("Error downloading package \(package.fullPackageName): \(error.localizedDescription)") + await handleError(task.id, error) return } } @@ -338,128 +469,7 @@ class DownloadUtils { totalTime: Date().timeIntervalSince(task.createAt), totalSize: task.totalSize ))) - } - } - } - - private func downloadPackage(package: Package, task: NewDownloadTask, product: ProductsToDownload, url: URL) async throws { - var lastUpdateTime = Date() - var lastBytes: Int64 = 0 - - return try await withCheckedThrowingContinuation { continuation in - let delegate = DownloadDelegate( - destinationDirectory: task.directory.appendingPathComponent(product.sapCode), - fileName: package.fullPackageName, - completionHandler: { [weak networkManager] localURL, response, error in - if let error = error { - if (error as NSError).code == NSURLErrorCancelled { - continuation.resume() - } else { - continuation.resume(throwing: error) - } - return - } - - Task { @MainActor in - package.downloadedSize = package.downloadSize - package.progress = 1.0 - package.status = .completed - package.downloaded = true - - var totalDownloaded: Int64 = 0 - var totalSize: Int64 = 0 - - for prod in task.productsToDownload { - for pkg in prod.packages { - totalSize += pkg.downloadSize - if pkg.downloaded { - totalDownloaded += pkg.downloadSize - } - } - } - - task.totalSize = totalSize - task.totalDownloadedSize = totalDownloaded - task.totalProgress = Double(totalDownloaded) / Double(totalSize) - task.totalSpeed = 0 - - let allCompleted = task.productsToDownload.allSatisfy { - product in product.packages.allSatisfy { $0.downloaded } - } - - if allCompleted { - task.setStatus(.completed(DownloadStatus.CompletionInfo( - timestamp: Date(), - totalTime: Date().timeIntervalSince(task.createAt), - totalSize: totalSize - ))) - } - - product.updateCompletedPackages() - - networkManager?.objectWillChange.send() - } - - continuation.resume() - }, - progressHandler: { [weak networkManager] bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in - Task { @MainActor in - let now = Date() - let timeDiff = now.timeIntervalSince(lastUpdateTime) - - if timeDiff >= 1.0 { - let bytesDiff = totalBytesWritten - lastBytes - let speed = Double(bytesDiff) / timeDiff - - package.updateProgress( - downloadedSize: totalBytesWritten, - speed: speed - ) - - var completedSize: Int64 = 0 - var totalSize: Int64 = 0 - - for prod in task.productsToDownload { - for pkg in prod.packages { - totalSize += pkg.downloadSize - if pkg.downloaded { - completedSize += pkg.downloadSize - } else if pkg.id == package.id { - completedSize += totalBytesWritten - } - } - } - - task.totalSize = totalSize - task.totalDownloadedSize = completedSize - task.totalProgress = Double(completedSize) / Double(totalSize) - task.totalSpeed = speed - - lastUpdateTime = now - lastBytes = totalBytesWritten - - networkManager?.objectWillChange.send() - } - } - } - ) - - var request = URLRequest(url: url) - NetworkConstants.downloadHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } - - let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) - - Task { - if let resumeData = await cancelTracker.getResumeData(task.id) { - let downloadTask = session.downloadTask(withResumeData: resumeData) - await cancelTracker.registerTask(task.id, task: downloadTask, session: session) - await cancelTracker.clearResumeData(task.id) - downloadTask.resume() - } else { - let downloadTask = session.downloadTask(with: request) - await cancelTracker.registerTask(task.id, task: downloadTask, session: session) - downloadTask.resume() - } + networkManager?.saveTask(task) } } } @@ -784,7 +794,7 @@ class DownloadUtils { } guard let jsonString = String(data: data, encoding: .utf8) else { - throw NetworkError.invalidData("无法将响应数据转换为json字符串") + throw NetworkError.invalidData(String(localized: "无法将响应数据转换为json字符串")) } return jsonString @@ -831,6 +841,7 @@ class DownloadUtils { try? FileManager.default.removeItem(at: fileURL) } + networkManager?.saveTask(task) networkManager?.updateDockBadge() networkManager?.objectWillChange.send() } @@ -842,26 +853,26 @@ class DownloadUtils { case let networkError as NetworkError: switch networkError { case .noConnection: - return ("网络连接已断开", true) + return (String(localized: "网络连接已断开"), true) case .timeout: - return ("下载超时", true) + return (String(localized: "下载超时"), true) case .serverUnreachable: - return ("服务器无法访问", true) + return (String(localized: "服务器无法访问"), true) case .insufficientStorage: - return ("存储空间不足", false) + return (String(localized: "存储空间不足"), false) case .filePermissionDenied: - return ("没有入权限", false) + return (String(localized: "没有写入权限"), false) default: return (networkError.localizedDescription, false) } case let urlError as URLError: switch urlError.code { case .notConnectedToInternet: - return ("网络连接已开", true) + return (String(localized: "网络连接已断开"), true) case .timedOut: - return ("连接超时", true) + return (String(localized: "连接超时"), true) case .cancelled: - return ("下载已取消", false) + return (String(localized: "下载已取消"), false) default: return (urlError.localizedDescription, true) } diff --git a/Adobe Downloader/Utils/TaskPersistenceManager.swift b/Adobe Downloader/Utils/TaskPersistenceManager.swift new file mode 100644 index 0000000..b95c357 --- /dev/null +++ b/Adobe Downloader/Utils/TaskPersistenceManager.swift @@ -0,0 +1,255 @@ +import Foundation + +class TaskPersistenceManager { + static let shared = TaskPersistenceManager() + + private let fileManager = FileManager.default + private var tasksDirectory: URL + private weak var cancelTracker: CancelTracker? + + private init() { + let containerURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + tasksDirectory = containerURL.appendingPathComponent("Adobe Downloader/tasks", isDirectory: true) + print(tasksDirectory) + try? fileManager.createDirectory(at: tasksDirectory, withIntermediateDirectories: true) + } + + func setCancelTracker(_ tracker: CancelTracker) { + self.cancelTracker = tracker + } + + private func getTaskFileName(sapCode: String, version: String, language: String, platform: String) -> String { + return sapCode == "APRO" + ? "Adobe Downloader \(sapCode)_\(version)_\(platform)-task.json" + : "Adobe Downloader \(sapCode)_\(version)-\(language)-\(platform)-task.json" + } + + func saveTask(_ task: NewDownloadTask) { + let fileName = getTaskFileName( + sapCode: task.sapCode, + version: task.version, + language: task.language, + platform: task.platform + ) + let fileURL = tasksDirectory.appendingPathComponent(fileName) + + var resumeDataDict: [String: Data]? = nil + + Task { + if let currentPackage = task.currentPackage, + let cancelTracker = self.cancelTracker, + let resumeData = await cancelTracker.getResumeData(task.id) { + resumeDataDict = [currentPackage.id.uuidString: resumeData] + } + } + + let taskData = TaskData( + sapCode: task.sapCode, + version: task.version, + language: task.language, + displayName: task.displayName, + directory: task.directory, + productsToDownload: task.productsToDownload.map { product in + ProductData( + sapCode: product.sapCode, + version: product.version, + buildGuid: product.buildGuid, + applicationJson: product.applicationJson, + packages: product.packages.map { package in + PackageData( + type: package.type, + fullPackageName: package.fullPackageName, + downloadSize: package.downloadSize, + downloadURL: package.downloadURL, + downloadedSize: package.downloadedSize, + progress: package.progress, + speed: package.speed, + status: package.status, + downloaded: package.downloaded + ) + } + ) + }, + retryCount: task.retryCount, + createAt: task.createAt, + totalStatus: task.totalStatus ?? .waiting, + totalProgress: task.totalProgress, + totalDownloadedSize: task.totalDownloadedSize, + totalSize: task.totalSize, + totalSpeed: task.totalSpeed, + displayInstallButton: task.displayInstallButton, + platform: task.platform, + resumeData: resumeDataDict + ) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(taskData) + // print("保存数据") + try data.write(to: fileURL) + } catch { + print("Error saving task: \(error)") + } + } + + func loadTasks() -> [NewDownloadTask] { + var tasks: [NewDownloadTask] = [] + + do { + let files = try fileManager.contentsOfDirectory(at: tasksDirectory, includingPropertiesForKeys: nil) + for file in files where file.pathExtension == "json" { + if let task = loadTask(from: file) { + tasks.append(task) + } + } + } catch { + print("Error loading tasks: \(error)") + } + + return tasks + } + + private func loadTask(from url: URL) -> NewDownloadTask? { + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + let taskData = try decoder.decode(TaskData.self, from: data) + + let products = taskData.productsToDownload.map { productData -> ProductsToDownload in + let product = ProductsToDownload( + sapCode: productData.sapCode, + version: productData.version, + buildGuid: productData.buildGuid, + applicationJson: productData.applicationJson ?? "" + ) + + product.packages = productData.packages.map { packageData -> Package in + let package = Package( + type: packageData.type, + fullPackageName: packageData.fullPackageName, + downloadSize: packageData.downloadSize, + downloadURL: packageData.downloadURL + ) + package.downloadedSize = packageData.downloadedSize + package.progress = packageData.progress + package.speed = packageData.speed + package.status = packageData.status + package.downloaded = packageData.downloaded + return package + } + + return product + } + + for product in products { + for package in product.packages { + package.speed = 0 + } + } + + let initialStatus: DownloadStatus + switch taskData.totalStatus { + case .completed: + initialStatus = taskData.totalStatus + case .failed: + initialStatus = taskData.totalStatus + case .downloading: + initialStatus = .paused(DownloadStatus.PauseInfo( + reason: .other(String(localized: "程序意外退出")), + timestamp: Date(), + resumable: true + )) + default: + initialStatus = .paused(DownloadStatus.PauseInfo( + reason: .other(String(localized: "程序重启后自动暂停")), + timestamp: Date(), + resumable: true + )) + } + + let task = NewDownloadTask( + sapCode: taskData.sapCode, + version: taskData.version, + language: taskData.language, + displayName: taskData.displayName, + directory: taskData.directory, + productsToDownload: products, + retryCount: taskData.retryCount, + createAt: taskData.createAt, + totalStatus: initialStatus, + totalProgress: taskData.totalProgress, + totalDownloadedSize: taskData.totalDownloadedSize, + totalSize: taskData.totalSize, + totalSpeed: 0, + currentPackage: products.first?.packages.first, + platform: taskData.platform + ) + task.displayInstallButton = taskData.displayInstallButton + + if let resumeData = taskData.resumeData?.values.first { + Task { + if let cancelTracker = self.cancelTracker { + await cancelTracker.storeResumeData(task.id, data: resumeData) + } + } + } + + return task + } catch { + print("Error loading task from \(url): \(error)") + return nil + } + } + + func removeTask(_ task: NewDownloadTask) { + let fileName = getTaskFileName( + sapCode: task.sapCode, + version: task.version, + language: task.language, + platform: task.platform + ) + let fileURL = tasksDirectory.appendingPathComponent(fileName) + + try? fileManager.removeItem(at: fileURL) + } +} + +private struct TaskData: Codable { + let sapCode: String + let version: String + let language: String + let displayName: String + let directory: URL + let productsToDownload: [ProductData] + let retryCount: Int + let createAt: Date + let totalStatus: DownloadStatus + let totalProgress: Double + let totalDownloadedSize: Int64 + let totalSize: Int64 + let totalSpeed: Double + let displayInstallButton: Bool + let platform: String + let resumeData: [String: Data]? +} + +private struct ProductData: Codable { + let sapCode: String + let version: String + let buildGuid: String + let applicationJson: String? + let packages: [PackageData] +} + +private struct PackageData: Codable { + let type: String + let fullPackageName: String + let downloadSize: Int64 + let downloadURL: String + let downloadedSize: Int64 + let progress: Double + let speed: Double + let status: PackageStatus + let downloaded: Bool +} diff --git a/Adobe Downloader/Views/AppCardView.swift b/Adobe Downloader/Views/AppCardView.swift index b59d953..b93ed41 100644 --- a/Adobe Downloader/Views/AppCardView.swift +++ b/Adobe Downloader/Views/AppCardView.swift @@ -5,6 +5,7 @@ // import SwiftUI +import Combine class IconCache { static let shared = IconCache() @@ -47,22 +48,50 @@ class AppCardViewModel: ObservableObject { get { userDefaults.string(forKey: "defaultDirectory") ?? "" } } + private var cancellables = Set() + init(sap: Sap, networkManager: NetworkManager?) { self.sap = sap self.networkManager = networkManager - loadIcon() - updateDownloadingStatus() + + setupObservers() + } + + private func setupObservers() { + networkManager?.objectWillChange + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.updateDownloadingStatus() + } + .store(in: &cancellables) } func updateDownloadingStatus() { + guard let networkManager = networkManager else { + Task { @MainActor in + self.isDownloading = false + } + return + } + Task { @MainActor in - isDownloading = networkManager?.downloadTasks.contains { task in - return task.sapCode == sap.sapCode && task.status.isActive - } ?? false + let isActive = networkManager.downloadTasks.contains { task in + task.sapCode == sap.sapCode && isTaskActive(task.status) + } + self.isDownloading = isActive + } + } + + private func isTaskActive(_ status: DownloadStatus) -> Bool { + switch status { + case .downloading, .preparing, .paused, .waiting, .retrying(_): + return true + case .completed, .failed: + return false } } - func getDestinationURL(version: String, language: String, useDefaultDirectory: Bool, defaultDirectory: String) async throws -> URL { + func getDestinationURL(version: String, language: String) async throws -> URL { let platform = sap.versions[version]?.apPlatform ?? "unknown" let installerName = sap.sapCode == "APRO" ? "Adobe Downloader \(sap.sapCode)_\(version)_\(platform).dmg" @@ -164,29 +193,17 @@ class AppCardViewModel: ObservableObject { showExistingFileAlert = true } } else { - startDownload(version, language) - } - } - } - - func startDownload(_ version: String, _ language: String) { - Task { - do { - let destinationURL = try await getDestinationURL( - version: version, - language: language, - useDefaultDirectory: useDefaultDirectory, - defaultDirectory: defaultDirectory - ) - - try await networkManager?.startDownload( - sap: sap, - selectedVersion: version, - language: language, - destinationURL: destinationURL - ) - } catch { - handleError(error) + do { + let destinationURL = try await getDestinationURL(version: version, language: language) + try await networkManager.startDownload( + sap: sap, + selectedVersion: version, + language: language, + destinationURL: destinationURL + ) + } catch { + handleError(error) + } } } } @@ -256,7 +273,8 @@ class AppCardViewModel: ObservableObject { totalProgress: 1.0, totalDownloadedSize: 0, totalSize: 0, - totalSpeed: 0 + totalSpeed: 0, + platform: "" ) await MainActor.run { @@ -319,6 +337,9 @@ struct AppCardView: View { viewModel.networkManager = networkManager viewModel.updateDownloadingStatus() } + .onChange(of: networkManager.downloadTasks) { _ in + viewModel.updateDownloadingStatus() + } } } @@ -470,7 +491,12 @@ struct AlertModifier: ViewModifier { if confirmRedownload { viewModel.showRedownloadConfirm = true } else { - viewModel.startDownload(viewModel.pendingVersion, viewModel.pendingLanguage) + Task { + await viewModel.checkAndStartDownload( + version: viewModel.pendingVersion, + language: viewModel.pendingLanguage + ) + } } } }, @@ -487,7 +513,12 @@ struct AlertModifier: ViewModifier { Button("取消", role: .cancel) { } Button("确认") { if !viewModel.pendingVersion.isEmpty && !viewModel.pendingLanguage.isEmpty { - viewModel.startDownload(viewModel.pendingVersion, viewModel.pendingLanguage) + Task { + await viewModel.checkAndStartDownload( + version: viewModel.pendingVersion, + language: viewModel.pendingLanguage + ) + } } } } message: { @@ -497,7 +528,12 @@ struct AlertModifier: ViewModifier { Button("确定", role: .cancel) { } Button("重试") { if !viewModel.selectedVersion.isEmpty { - viewModel.startDownload(viewModel.selectedVersion, viewModel.selectedLanguage) + Task { + await viewModel.checkAndStartDownload( + version: viewModel.selectedVersion, + language: viewModel.selectedLanguage + ) + } } } } message: { diff --git a/Adobe Downloader/Views/DownloadProgressView.swift b/Adobe Downloader/Views/DownloadProgressView.swift index 423275f..621f335 100644 --- a/Adobe Downloader/Views/DownloadProgressView.swift +++ b/Adobe Downloader/Views/DownloadProgressView.swift @@ -607,7 +607,8 @@ struct PackageRow: View { totalProgress: 0.45, totalDownloadedSize: 457424883, totalSize: 878454797, - totalSpeed: 1024 * 1024 * 2 + totalSpeed: 1024 * 1024 * 2, + platform: "" ), onCancel: {}, onPause: {}, @@ -642,7 +643,8 @@ struct PackageRow: View { totalProgress: 1.0, totalDownloadedSize: 878454797, totalSize: 878454797, - totalSpeed: 0 + totalSpeed: 0, + platform: "" ), onCancel: {}, onPause: {}, @@ -677,7 +679,8 @@ struct PackageRow: View { totalProgress: 0.52, totalDownloadedSize: 457424883, totalSize: 878454797, - totalSpeed: 0 + totalSpeed: 0, + platform: "" ), onCancel: {}, onPause: {}, diff --git a/Localizables/Localizable.xcstrings b/Localizables/Localizable.xcstrings index 8d9a55b..c589a3e 100644 --- a/Localizables/Localizable.xcstrings +++ b/Localizables/Localizable.xcstrings @@ -218,6 +218,16 @@ } } }, + "下载超时" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download timeout" + } + } + } + }, "下载错误" : { "localizations" : { "en" : { @@ -418,6 +428,16 @@ } } }, + "存储空间不足" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insufficient storage space" + } + } + } + }, "安装" : { "localizations" : { "en" : { @@ -619,6 +639,16 @@ } } }, + "无法将响应数据转换为json字符串" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to convert response data to json string" + } + } + } + }, "是否确认重新下载?这将覆盖现有的安装程序。" : { "localizations" : { "en" : { @@ -659,6 +689,16 @@ } } }, + "有正在进行的下载任务,确定要退出吗?\n所有下载任务的进度已保存,下次启动可以继续下载" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There are download tasks in progress. Are you sure you want to quit?\nThe progress of all download tasks has been saved and you can continue downloading next time you start the program." + } + } + } + }, "服务器响应无效" : { "comment" : "Invalid response", "localizations" : { @@ -670,6 +710,16 @@ } } }, + "服务器无法访问" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server Unreachable" + } + } + } + }, "未安装 Adobe Creative Cloud" : { "localizations" : { "en" : { @@ -818,6 +868,16 @@ } } }, + "没有写入权限" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No write permission" + } + } + } + }, "没有找到产品" : { "localizations" : { "en" : { @@ -879,6 +939,16 @@ } } }, + "确认退出" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm Exit" + } + } + } + }, "确认重新下载" : { "localizations" : { "en" : { @@ -899,6 +969,36 @@ } } }, + "程序即将退出" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Program is about to exit" + } + } + } + }, + "程序意外退出" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Program quit unexpectedly" + } + } + } + }, + "程序重启后自动暂停" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatically pause after program restart" + } + } + } + }, "等待中" : { "comment" : "Download status waiting", "localizations" : { @@ -942,6 +1042,16 @@ } } }, + "网络连接已断开" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Network connection lost" + } + } + } + }, "联系 @X1a0He" : { "localizations" : { "en" : { @@ -1003,6 +1113,26 @@ } } }, + "连接超时" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection timeout" + } + } + } + }, + "退出" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit" + } + } + } + }, "选择" : { "localizations" : { "en" : { diff --git a/appcast.xml b/appcast.xml index 074d510..493cb68 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,36 @@ Adobe Downloader + + 1.1.0 + Sat, 09 Nov 2024 23:08:48 +0800 + 110 + 1.1.0 + 12.0 + + + ul{margin-top: 0;margin-bottom: 7;padding-left: 18;} +

Adobe Downloader 更新日志:

+
    +
  • 修复了初次启动程序时,默认下载目录为 "Downloads" 导致提示 你不能存储文件“ILST”,因为该宗卷是只读宗卷 的问题
  • +
  • 新的实现取代了 windowResizability 以适应 macOS 12.0+(可能)
  • +
  • 新增下载记录持久化功能(M1 Max macOS 15上测试正常,未测试其他机型)
  • +
+

PS: 此版本改动略大,如有bugs,请及时提出

+
+

Adobe Downloader Changes:

+
    +
  • Fixed the issue that when launching the program for the first time, the default directory is "Downloads", which causes a download error message
  • +
  • New implementation replaces windowResizability to adapt to macOS 12.0+ (Maybe)
  • +
  • Added task record persistence(Tested normally on M1 Max macOS 15, other models not tested)
  • +
+

PS: This version has been slightly changed. If there are any bugs, please report them in time.

+ ]]> +
+
1.0.1 Thu, 07 Nov 2024 21:29:26 +0800 @@ -14,7 +44,7 @@ sparkle:edSignature="/paRKw18fjGopMIkrNSPJV1k0NloLccfjeyBLjjbNus7IyjFyGmdTH5ccxcbcXnYuFqozFrtKuBizpTCmNJfBw=="/> ul{margin-top: 0;margin-bottom: 7;padding-left: 18;} -
Adobe Downloader 更新日志:
+
Adobe Downloader 更新日志:
  • 修复了当系统版本低于 macOS 14.6 时无法打开程序的问题,现已支持 macOS 13.0 以上
  • 增加 Sparkle 用于检测更新
  • @@ -24,7 +54,7 @@
  • 修复了在任务下载中,已下载包与总包数量不更新的问题

-
Adobe Downloader Changes:
+
Adobe Downloader Changes:
  • Support macOS 13.0 and above
  • Added Sparkle for checking update
  • diff --git a/readme-en.md b/readme-en.md index e42fa98..a4bc784 100644 --- a/readme-en.md +++ b/readme-en.md @@ -6,7 +6,7 @@ ## Before Use -**🍎Only for macOS 13.0+.** +**🍎Only for macOS 12.0+.** > **If you like Adobe Downloader, or it helps you, please Star🌟 it.** > @@ -27,16 +27,15 @@ - For historical update logs, please go to [Update Log](update-log.md) -- 2024-11-07 21:10 Update Log +- 2024-11-09 23:00 Update Log ```markdown -1. Support macOS 13.0 and above -2. Added Sparkle for checking update -3. When the default directory is not selected, the Downloads folder will be used as the default directory -4. When installing via Adobe Downloader and encountering permission issues, provide terminal commands to allow users to - install by themselves -5. Adjusted the UI display of existing files -6. Fixed the issue where the number of downloaded packages and total packages was not updated during task download +1. Fixed the issue that when launching the program for the first time, the default directory is "Downloads", which + causes a download error message +2. New implementation replaces windowResizability to adapt to macOS 12.0+ (Maybe) +3. Added task record persistence(Tested normally on M1 Max macOS 15, other models not tested) + +PS: This version has been slightly changed. If there are any bugs, please report them in time. ``` ### Language friendly @@ -59,6 +58,7 @@ via Telegram.** - [x] Support installation of non-Acrobat products - [x] Support multiple products download at the same time - [x] Supports using default language and default directory + - [x] Support task record persistence ## 👀 Preview diff --git a/readme.md b/readme.md index a3bc302..7368dbe 100644 --- a/readme.md +++ b/readme.md @@ -6,7 +6,7 @@ ## 使用须知 -**🍎仅支持 macOS 13.0+** +**🍎仅支持 macOS 12.0+** > **如果你也喜欢 Adobe Downloader, 或者对你有帮助, 请 Star 仓库吧 🌟, 你的支持是我更新的动力** > @@ -26,15 +26,14 @@ - 更多关于 App 的更新日志,请查看 [Update Log](update-log.md) -- 2024-11-07 21:10 更新日志 +- 2024-11-09 23:00 更新日志 ```markdown -1. 修复了当系统版本低于 macOS 14.6 时无法打开程序的问题,现已支持 macOS 13.0 以上 -2. 增加 Sparkle 用于检测更新 -3. 当默认目录为 未选择 时,将 下载 文件夹作为默认目录 -4. 当通过 Adobe Downloader 安装遇到权限问题时,提供终端命令让用户自行安装 -5. 调整了文件已存在的 UI 显示 -6. 修复了在任务下载中,已下载包与总包数量不更新的问题 +1. 修复了初次启动程序时,默认下载目录为 "Downloads" 导致提示 你不能存储文件“ILST”,因为该宗卷是只读宗卷 的问题 +2. 新的实现取代了 windowResizability 以适应 macOS 12.0+(可能) +3. 新增下载记录持久化功能(M1 Max macOS 15上测试正常,未测试其他机型) + +PS: 此版本改动略大,如有bugs,请及时提出 ``` ### 语言支持 @@ -56,6 +55,7 @@ - [x] 支持安装非 Acrobat 产品 - [x] 支持多个产品同时下载 - [x] 支持使用默认语言和默认目录 + - [x] 支持任务记录持久化 ## 👀 预览 diff --git a/update-log.md b/update-log.md index 4cf3683..8cac696 100644 --- a/update-log.md +++ b/update-log.md @@ -1,7 +1,29 @@ # Change Log +- 2024-11-09 23:00 更新日志 + +[//]: # (1.1.0) + +```markdown +1. 修复了初次启动程序时,默认下载目录为 "Downloads" 导致提示 你不能存储文件“ILST”,因为该宗卷是只读宗卷 的问题 +2. 新的实现取代了 windowResizability 以适应 macOS 12.0+(可能) +3. 新增下载记录持久化功能(M1 Max macOS 15上测试正常,未测试其他机型) + +PS: 此版本改动略大,如有bugs,请及时提出 +==================== + +1. Fixed the issue that when launching the program for the first time, the default directory is "Downloads", which + causes a download error message +2. New implementation replaces windowResizability to adapt to macOS 12.0+ (Maybe) +3. Added task record persistence(Tested normally on M1 Max macOS 15, other models not tested) + +PS: This version has been slightly changed. If there are any bugs, please report them in time. +``` + - 2024-11-07 21:10 更新日志 +[//]: # (1.0.1) + ```markdown 1. 修复了当系统版本低于 macOS 14.6 时无法打开程序的问题,现已支持 macOS 13.0 以上 2. 增加 Sparkle 用于检测更新 @@ -21,6 +43,14 @@ 6. Fixed the issue where the number of downloaded packages and total packages was not updated during task download ``` +image + +image + +image + +image + - 2024-11-06 15:50 更新日志 ```markdown