mirror of
https://github.com/X1a0He/Adobe-Downloader.git
synced 2025-11-25 11:18:53 +08:00
Rewrite: Rewrite the dependency acquisition logic and product dependency logic, but there is a problem that the package download progress and download status cannot be updated.
This commit is contained in:
@@ -3,4 +3,22 @@
|
||||
uuid = "05600D7B-4F3A-44C5-8A39-5E4971936E92"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
<Breakpoints>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "DEA1D888-6EDC-4454-A704-4F163A408945"
|
||||
shouldBeEnabled = "No"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "Adobe Downloader/Utils/XHXMLParser.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "46"
|
||||
endingLineNumber = "46"
|
||||
landmarkName = "unknown"
|
||||
landmarkType = "0">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
</Breakpoints>
|
||||
</Bucket>
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
// Created by X1a0He on 2024/10/30.
|
||||
//
|
||||
import Foundation
|
||||
enum PackageStatus {
|
||||
case waiting
|
||||
case downloading
|
||||
case paused
|
||||
case completed
|
||||
case failed(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .waiting: return "等待中"
|
||||
case .downloading: return "下载中"
|
||||
case .paused: return "已暂停"
|
||||
case .completed: return "已完成"
|
||||
case .failed(let message): return "失败: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: Error, LocalizedError {
|
||||
case noConnection
|
||||
|
||||
@@ -13,15 +13,15 @@ extension FileManager {
|
||||
}
|
||||
}
|
||||
|
||||
extension Product.ProductVersion {
|
||||
extension Sap.Versions {
|
||||
var size: Int64 {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadTask {
|
||||
extension NewDownloadTask {
|
||||
var startTime: Date {
|
||||
switch status {
|
||||
switch totalStatus {
|
||||
case .downloading(let info):
|
||||
return info.startTime
|
||||
case .completed(let info):
|
||||
@@ -36,164 +36,13 @@ extension DownloadTask {
|
||||
return info.timestamp
|
||||
case .waiting:
|
||||
return Date()
|
||||
case .none:
|
||||
return createAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NetworkManager {
|
||||
func handleDownloadCompletion(taskId: UUID, packageIndex: Int) async {
|
||||
await MainActor.run {
|
||||
guard let taskIndex = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
|
||||
|
||||
downloadTasks[taskIndex].packages[packageIndex].downloaded = true
|
||||
downloadTasks[taskIndex].packages[packageIndex].progress = 1.0
|
||||
downloadTasks[taskIndex].packages[packageIndex].status = .completed
|
||||
|
||||
if let nextPackageIndex = downloadTasks[taskIndex].packages.firstIndex(where: { !$0.downloaded }) {
|
||||
downloadTasks[taskIndex].status = .downloading(DownloadTask.DownloadStatus.DownloadInfo(
|
||||
fileName: downloadTasks[taskIndex].packages[nextPackageIndex].name,
|
||||
currentPackageIndex: nextPackageIndex,
|
||||
totalPackages: downloadTasks[taskIndex].packages.count,
|
||||
startTime: Date(),
|
||||
estimatedTimeRemaining: nil
|
||||
))
|
||||
Task {
|
||||
await resumeDownload(taskId: taskId)
|
||||
}
|
||||
} else {
|
||||
let startTime = downloadTasks[taskIndex].startTime
|
||||
let totalTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
downloadTasks[taskIndex].status = .completed(DownloadTask.DownloadStatus.CompletionInfo(
|
||||
timestamp: Date(),
|
||||
totalTime: totalTime,
|
||||
totalSize: downloadTasks[taskIndex].totalSize
|
||||
))
|
||||
downloadTasks[taskIndex].progress = 1.0
|
||||
progressObservers[taskId]?.invalidate()
|
||||
progressObservers.removeValue(forKey: taskId)
|
||||
|
||||
if activeDownloadTaskId == taskId {
|
||||
activeDownloadTaskId = nil
|
||||
}
|
||||
|
||||
updateDockBadge()
|
||||
objectWillChange.send()
|
||||
Task {
|
||||
do {
|
||||
try await downloadUtils.clearExtendedAttributes(at: downloadTasks[taskIndex].destinationURL)
|
||||
print("Successfully cleared extended attributes for \(downloadTasks[taskIndex].destinationURL.path)")
|
||||
} catch {
|
||||
print("Failed to clear extended attributes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NetworkManager {
|
||||
func getApplicationInfo(buildGuid: String) async throws -> ApplicationInfo {
|
||||
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["Accept"] = "application/json"
|
||||
headers["Connection"] = "keep-alive"
|
||||
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))
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let applicationInfo: ApplicationInfo = try decoder.decode(ApplicationInfo.self, from: data)
|
||||
return applicationInfo
|
||||
} catch {
|
||||
throw NetworkError.parsingError(error, "Failed to parse application info")
|
||||
}
|
||||
}
|
||||
|
||||
func fetchProductsData() async throws -> ([String: Product], String) {
|
||||
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: Product], String) = try await Task.detached(priority: .userInitiated) {
|
||||
let parseResult = try XHXMLParser.parse(
|
||||
xmlString: xmlString,
|
||||
urlVersion: 6,
|
||||
allowedPlatforms: Set(["osx10-64", "osx10", "macuniversal", "macarm64"])
|
||||
)
|
||||
return (parseResult.products, parseResult.cdn)
|
||||
}.value
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getDownloadPath(for fileName: String) async throws -> URL {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "选择保存位置"
|
||||
panel.canCreateDirectories = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
if let baseURL = panel.url {
|
||||
continuation.resume(returning: baseURL)
|
||||
} else {
|
||||
continuation.resume(throwing: NetworkError.fileSystemError("未选择保存位置", nil))
|
||||
}
|
||||
} else {
|
||||
continuation.resume(throwing: NetworkError.fileSystemError("用户取消了操作", nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func configureNetworkMonitor() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor in
|
||||
@@ -202,22 +51,24 @@ extension NetworkManager {
|
||||
self.isConnected = path.status == .satisfied
|
||||
|
||||
if !wasConnected && self.isConnected {
|
||||
for task in self.downloadTasks where task.status.isPaused {
|
||||
for task in self.downloadTasks {
|
||||
if case .paused(let info) = task.status,
|
||||
info.reason == .networkIssue {
|
||||
await self.resumeDownload(taskId: task.id)
|
||||
}
|
||||
}
|
||||
} else if wasConnected && !self.isConnected {
|
||||
for task in self.downloadTasks where task.status.isActive {
|
||||
for task in self.downloadTasks {
|
||||
if case .downloading = task.status {
|
||||
await self.downloadUtils.pauseDownloadTask(
|
||||
taskId: task.id,
|
||||
reason: DownloadTask.DownloadStatus.PauseInfo.PauseReason.networkIssue
|
||||
reason: .networkIssue
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: DispatchQueue.global(qos: .utility))
|
||||
}
|
||||
|
||||
@@ -228,7 +79,13 @@ extension NetworkManager {
|
||||
}
|
||||
|
||||
func updateDockBadge() {
|
||||
let activeCount = downloadTasks.filter { $0.status.isActive }.count
|
||||
let activeCount = downloadTasks.filter { task in
|
||||
if case .downloading = task.status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}.count
|
||||
|
||||
if activeCount > 0 {
|
||||
NSApplication.shared.dockTile.badgeLabel = "\(activeCount)"
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,120 @@
|
||||
//
|
||||
import Foundation
|
||||
|
||||
class Package: Identifiable, ObservableObject {
|
||||
let id = UUID()
|
||||
var type: String
|
||||
var fullPackageName: String
|
||||
var downloadSize: Int64
|
||||
var downloadURL: String
|
||||
@Published var downloadedSize: Int64 = 0 {
|
||||
didSet {
|
||||
if downloadSize > 0 {
|
||||
progress = Double(downloadedSize) / Double(downloadSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var progress: Double = 0
|
||||
@Published var speed: Double = 0 {
|
||||
didSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
@Published var status: PackageStatus = .waiting {
|
||||
didSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
@Published var downloaded: Bool = false
|
||||
var lastUpdated: Date = Date()
|
||||
var lastRecordedSize: Int64 = 0
|
||||
|
||||
init(type: String, fullPackageName: String, downloadSize: Int64, downloadURL: String) {
|
||||
self.type = type
|
||||
self.fullPackageName = fullPackageName
|
||||
self.downloadSize = downloadSize
|
||||
self.downloadURL = downloadURL
|
||||
}
|
||||
|
||||
// 添加格式化的大小显示
|
||||
var formattedSize: String {
|
||||
ByteCountFormatter.string(fromByteCount: downloadSize, countStyle: .file)
|
||||
}
|
||||
|
||||
// 添加有效大小检查
|
||||
var hasValidSize: Bool {
|
||||
downloadSize > 0
|
||||
}
|
||||
}
|
||||
class ProductsToDownload {
|
||||
var sapCode: String
|
||||
var version: String
|
||||
var buildGuid: String
|
||||
var applicationJson: String?
|
||||
var packages: [Package] = []
|
||||
|
||||
init(sapCode: String, version: String, buildGuid: String, applicationJson: String = "") {
|
||||
self.sapCode = sapCode
|
||||
self.version = version
|
||||
self.buildGuid = buildGuid
|
||||
self.applicationJson = applicationJson
|
||||
}
|
||||
}
|
||||
|
||||
struct SapCodes: Identifiable {
|
||||
var id: String { sapCode }
|
||||
var sapCode: String
|
||||
var displayName: String
|
||||
}
|
||||
|
||||
struct Sap: Identifiable {
|
||||
var id: String { sapCode }
|
||||
var hidden: Bool
|
||||
var displayName: String
|
||||
var sapCode: String
|
||||
var versions: [String: Versions]
|
||||
var icons: [ProductIcon]
|
||||
var productsToDownload: [ProductsToDownload]? = nil
|
||||
|
||||
|
||||
struct Versions {
|
||||
var sapCode: String
|
||||
var baseVersion: String
|
||||
var productVersion: String
|
||||
var apPlatform: String
|
||||
var dependencies: [Dependencies]
|
||||
var buildGuid: String
|
||||
|
||||
struct Dependencies {
|
||||
var sapCode: String
|
||||
var version: String
|
||||
}
|
||||
}
|
||||
|
||||
struct ProductIcon {
|
||||
let size: String
|
||||
let url: String
|
||||
|
||||
var dimension: Int {
|
||||
let components = size.split(separator: "x")
|
||||
if components.count == 2,
|
||||
let dimension = Int(components[0]) {
|
||||
return dimension
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
var isValid: Bool { !hidden }
|
||||
|
||||
func getBestIcon() -> ProductIcon? {
|
||||
if let icon = icons.first(where: { $0.size == "192x192" }) {
|
||||
return icon
|
||||
}
|
||||
return icons.max(by: { $0.dimension < $1.dimension })
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkConstants {
|
||||
static let downloadTimeout: TimeInterval = 300
|
||||
static let maxRetryAttempts = 3
|
||||
@@ -30,7 +144,6 @@ struct NetworkConstants {
|
||||
static let ADOBE_CC_MAC_ICON_PATH = "/Library/Application Support/Adobe/Adobe Desktop Common/HDBox/Install.app/Contents/Resources/CreativeCloudInstaller.icns"
|
||||
static let MAC_VOLUME_ICON_PATH = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/CDAudioVolumeIcon.icns"
|
||||
|
||||
// 这里好像不怎么需要这个script了
|
||||
static let INSTALL_APP_APPLE_SCRIPT = """
|
||||
const app = Application.currentApplication()
|
||||
app.includeStandardAdditions = true
|
||||
@@ -185,239 +298,3 @@ struct NetworkConstants {
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
struct ApplicationInfo: Codable {
|
||||
let Name: String?
|
||||
let SAPCode: String?
|
||||
let CodexVersion: String?
|
||||
let AssetGuid: String?
|
||||
let ProductVersion: String?
|
||||
let BaseVersion: String?
|
||||
let Platform: String?
|
||||
let LbsUrl: String?
|
||||
let LanguageSet: String?
|
||||
let Packages: PackagesContainer
|
||||
let SupportedLanguages: SupportedLanguages?
|
||||
let ConflictingProcesses: ConflictingProcesses?
|
||||
let AMTConfig: AMTConfig?
|
||||
let SystemRequirement: SystemRequirement?
|
||||
let version: String?
|
||||
let NglLicensingInfo: NglLicensingInfo?
|
||||
let AppLineage: String?
|
||||
let FamilyName: String?
|
||||
let BuildGuid: String?
|
||||
let selfServeBuild: Bool?
|
||||
let HDBuilderVersion: String?
|
||||
let IsSTI: Bool?
|
||||
let AppsPanelFullAppUpdateConfig: AppsPanelFullAppUpdateConfig?
|
||||
let Cdn: CdnInfo?
|
||||
let WhatsNewUrl: UrlContainer?
|
||||
let TutorialUrl: UrlContainer?
|
||||
let AppLaunch: String?
|
||||
let InstallDir: InstallDir?
|
||||
let MoreInfoUrl: UrlContainer?
|
||||
let AddRemoveInfo: AddRemoveInfo?
|
||||
let AutoUpdate: String?
|
||||
let AppsPanelPreviousVersionConfig: AppsPanelPreviousVersionConfig?
|
||||
let ProductDescription: ProductDescription?
|
||||
let IsNonCCProduct: Bool?
|
||||
let CompressionType: String?
|
||||
let MinimumSupportedClientVersion: String?
|
||||
}
|
||||
|
||||
struct PackagesContainer: Codable {
|
||||
let Package: [Package]
|
||||
|
||||
struct Package: Codable {
|
||||
let PackageType: String?
|
||||
let PackageName: String?
|
||||
let PackageVersion: String?
|
||||
let DownloadSize: Int64?
|
||||
let ExtractSize: Int64?
|
||||
let Path: String
|
||||
let Format: String?
|
||||
let ValidationURL: String?
|
||||
let packageHashKey: String?
|
||||
let DeltaPackages: [DeltaPackage]?
|
||||
let ValidationURLs: ValidationURLs?
|
||||
let Condition: String?
|
||||
let InstallSequenceNumber: Int?
|
||||
let fullPackageName: String?
|
||||
let PackageValidation: String?
|
||||
let AliasPackageName: String?
|
||||
let PackageScheme: String?
|
||||
let Features: Features?
|
||||
|
||||
var size: Int64 { DownloadSize ?? 0 }
|
||||
}
|
||||
}
|
||||
|
||||
struct DeltaPackage: Codable {
|
||||
let SchemaVersion: String?
|
||||
let PackageName: String?
|
||||
let Path: String?
|
||||
let BasePackageVersion: String?
|
||||
let ValidationURL: String?
|
||||
let DownloadSize: Int64?
|
||||
let ExtractSize: Int64?
|
||||
let packageHashKey: String?
|
||||
}
|
||||
|
||||
struct ValidationURLs: Codable {
|
||||
let TYPE1: String?
|
||||
let TYPE2: String?
|
||||
}
|
||||
|
||||
struct Features: Codable {
|
||||
let Feature: [FeatureItem]
|
||||
|
||||
struct FeatureItem: Codable {
|
||||
let name: String?
|
||||
let value: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct CdnInfo: Codable {
|
||||
let Secure: String
|
||||
let NonSecure: String
|
||||
}
|
||||
|
||||
struct UrlContainer: Codable {
|
||||
let Stage: LanguageContainer
|
||||
let Prod: LanguageContainer
|
||||
|
||||
struct LanguageContainer: Codable {
|
||||
let Language: [LanguageValue]
|
||||
}
|
||||
|
||||
struct LanguageValue: Codable {
|
||||
let value: String
|
||||
let locale: String
|
||||
}
|
||||
}
|
||||
|
||||
struct InstallDir: Codable {
|
||||
let value: String?
|
||||
let maxPath: String?
|
||||
}
|
||||
|
||||
struct AddRemoveInfo: Codable {
|
||||
let DisplayName: LanguageContainer
|
||||
let DisplayVersion: LanguageContainer?
|
||||
let URLInfoAbout: LanguageContainer?
|
||||
|
||||
struct LanguageContainer: Codable {
|
||||
let Language: [LanguageValue]
|
||||
}
|
||||
|
||||
struct LanguageValue: Codable {
|
||||
let value: String
|
||||
let locale: String
|
||||
}
|
||||
}
|
||||
|
||||
struct AppsPanelPreviousVersionConfig: Codable {
|
||||
let ListInPreviousVersion: Bool
|
||||
let BrandingName: String
|
||||
}
|
||||
|
||||
struct ProductDescription: Codable {
|
||||
let Tagline: LanguageContainer?
|
||||
let DetailedDescription: LanguageContainer?
|
||||
|
||||
struct LanguageContainer: Codable {
|
||||
let Language: [LanguageValue]
|
||||
|
||||
struct LanguageValue: Codable {
|
||||
let value: String
|
||||
let locale: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppsPanelFullAppUpdateConfig: Codable {
|
||||
let PreviousVersionRange: VersionRange
|
||||
let ShowDialogBox: Bool
|
||||
let ImportPreferenceCheckBox: PreferenceCheckBox
|
||||
let RemovePreviousVersionCheckBox: PreferenceCheckBox
|
||||
|
||||
struct VersionRange: Codable {
|
||||
let min: String
|
||||
}
|
||||
|
||||
struct PreferenceCheckBox: Codable {
|
||||
let DefaultValue: Bool
|
||||
let Show: Bool
|
||||
let AllowToggle: Bool
|
||||
}
|
||||
}
|
||||
|
||||
struct SupportedLanguages: Codable {
|
||||
let Language: [LanguageInfo]
|
||||
|
||||
struct LanguageInfo: Codable {
|
||||
let value: String
|
||||
let locale: String
|
||||
}
|
||||
}
|
||||
|
||||
struct ConflictingProcesses: Codable {
|
||||
let ConflictingProcess: [ConflictingProcess]
|
||||
|
||||
struct ConflictingProcess: Codable {
|
||||
let RegularExpression: String
|
||||
let ProcessDisplayName: String
|
||||
let Reason: String
|
||||
let RelativePath: String
|
||||
let headless: Bool
|
||||
let forceKillAllowed: Bool
|
||||
let adobeOwned: Bool
|
||||
}
|
||||
}
|
||||
|
||||
struct AMTConfig: Codable {
|
||||
let path: String
|
||||
let LEID: String
|
||||
let appID: String
|
||||
}
|
||||
|
||||
struct SystemRequirement: Codable {
|
||||
let OsVersion: OsVersion?
|
||||
let SupportedOsVersionRange: [OsVersionRange]?
|
||||
let ExternalUrl: ExternalUrl
|
||||
let CheckCompatibility: CheckCompatibility
|
||||
|
||||
struct OsVersion: Codable {
|
||||
let min: String
|
||||
}
|
||||
|
||||
struct OsVersionRange: Codable {
|
||||
let min: String
|
||||
}
|
||||
|
||||
struct ExternalUrl: Codable {
|
||||
let Stage: LanguageUrls
|
||||
let Prod: LanguageUrls
|
||||
|
||||
struct LanguageUrls: Codable {
|
||||
let Language: [LanguageUrl]
|
||||
|
||||
struct LanguageUrl: Codable {
|
||||
let value: String
|
||||
let locale: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckCompatibility: Codable {
|
||||
let Content: String
|
||||
}
|
||||
}
|
||||
|
||||
struct NglLicensingInfo: Codable {
|
||||
let AppId: String
|
||||
let AppVersion: String
|
||||
let LibVersion: String
|
||||
let BuildId: String
|
||||
let ImsClientId: String
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ struct ContentView: View {
|
||||
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
||||
@State private var showLanguagePicker = false
|
||||
|
||||
private var filteredProducts: [Product] {
|
||||
let products = networkManager.products.values
|
||||
private var filteredProducts: [Sap] {
|
||||
let products = networkManager.saps.values
|
||||
.filter { !$0.hidden && !$0.versions.isEmpty }
|
||||
.sorted { $0.displayName < $1.displayName }
|
||||
|
||||
@@ -133,8 +133,8 @@ struct ContentView: View {
|
||||
columns: [GridItem(.adaptive(minimum: 250))],
|
||||
spacing: 20
|
||||
) {
|
||||
ForEach(filteredProducts) { product in
|
||||
AppCardView(product: product)
|
||||
ForEach(filteredProducts, id: \.sapCode) { sap in
|
||||
AppCardView(sap: sap)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -154,7 +154,8 @@ struct ContentView: View {
|
||||
.environmentObject(networkManager)
|
||||
}
|
||||
.onAppear {
|
||||
if networkManager.products.isEmpty {
|
||||
|
||||
if networkManager.saps.isEmpty {
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
@@ -216,80 +217,6 @@ struct SearchField: View {
|
||||
#Preview {
|
||||
let networkManager = NetworkManager()
|
||||
|
||||
let mockProducts: [String: Product] = [
|
||||
"PHSP": Product(
|
||||
id: "PHSP",
|
||||
hidden: false,
|
||||
displayName: "Photoshop",
|
||||
sapCode: "PHSP",
|
||||
versions: [
|
||||
"25.0.0": Product.ProductVersion(
|
||||
sapCode: "PHSP",
|
||||
baseVersion: "25.0.0",
|
||||
productVersion: "25.0.0",
|
||||
apPlatform: "macuniversal",
|
||||
dependencies: [],
|
||||
buildGuid: ""
|
||||
)
|
||||
],
|
||||
icons: [
|
||||
Product.ProductIcon(
|
||||
size: "192x192",
|
||||
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/PHSP/25.0.0/192x192.png"
|
||||
)
|
||||
]
|
||||
),
|
||||
"ILST": Product(
|
||||
id: "ILST",
|
||||
hidden: false,
|
||||
displayName: "Illustrator",
|
||||
sapCode: "ILST",
|
||||
versions: [
|
||||
"28.0.0": Product.ProductVersion(
|
||||
sapCode: "ILST",
|
||||
baseVersion: "28.0.0",
|
||||
productVersion: "28.0.0",
|
||||
apPlatform: "macuniversal",
|
||||
dependencies: [],
|
||||
buildGuid: ""
|
||||
)
|
||||
],
|
||||
icons: [
|
||||
Product.ProductIcon(
|
||||
size: "192x192",
|
||||
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/ILST/28.0.0/192x192.png"
|
||||
)
|
||||
]
|
||||
),
|
||||
"AEFT": Product(
|
||||
id: "AEFT",
|
||||
hidden: false,
|
||||
displayName: "After Effects",
|
||||
sapCode: "AEFT",
|
||||
versions: [
|
||||
"24.0.0": Product.ProductVersion(
|
||||
sapCode: "AEFT",
|
||||
baseVersion: "24.0.0",
|
||||
productVersion: "24.0.0",
|
||||
apPlatform: "macuniversal",
|
||||
dependencies: [],
|
||||
buildGuid: ""
|
||||
)
|
||||
],
|
||||
icons: [
|
||||
Product.ProductIcon(
|
||||
size: "192x192",
|
||||
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/AEFT/24.0.0/192x192.png"
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Task { @MainActor in
|
||||
networkManager.products = mockProducts
|
||||
networkManager.loadingState = .success
|
||||
}
|
||||
|
||||
return ContentView()
|
||||
.environmentObject(networkManager)
|
||||
.frame(width: 850, height: 700)
|
||||
|
||||
@@ -5,165 +5,7 @@
|
||||
//
|
||||
import Foundation
|
||||
|
||||
class DownloadTask: Identifiable, ObservableObject, Equatable {
|
||||
let id = UUID()
|
||||
let sapCode: String
|
||||
let version: String
|
||||
let language: String
|
||||
let productName: String
|
||||
@Published var status: DownloadStatus
|
||||
@Published var progress: Double
|
||||
@Published var downloadedSize: Int64
|
||||
@Published var totalSize: Int64
|
||||
@Published var speed: Double
|
||||
@Published var currentFileName: String
|
||||
let destinationURL: URL
|
||||
var priority: Priority
|
||||
var retryCount: Int
|
||||
let createdAt: Date
|
||||
@Published var lastUpdated: Date
|
||||
@Published var lastRecordedSize: Int64
|
||||
@Published var packages: [Package]
|
||||
@Published var detailedStatus: String = ""
|
||||
|
||||
enum Priority: Int {
|
||||
case low = 0
|
||||
case normal = 1
|
||||
case high = 2
|
||||
}
|
||||
|
||||
enum DownloadStatus {
|
||||
case waiting
|
||||
case preparing(PrepareInfo)
|
||||
case downloading(DownloadInfo)
|
||||
case paused(PauseInfo)
|
||||
case completed(CompletionInfo)
|
||||
case failed(FailureInfo)
|
||||
case retrying(RetryInfo)
|
||||
|
||||
struct PrepareInfo: Equatable {
|
||||
let message: String
|
||||
let timestamp: Date
|
||||
let stage: PrepareStage
|
||||
|
||||
enum PrepareStage: Equatable {
|
||||
case initializing
|
||||
case creatingInstaller
|
||||
case signingApp
|
||||
case fetchingInfo
|
||||
case validatingSetup
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadInfo: Equatable {
|
||||
let fileName: String
|
||||
let currentPackageIndex: Int
|
||||
let totalPackages: Int
|
||||
let startTime: Date
|
||||
let estimatedTimeRemaining: TimeInterval?
|
||||
}
|
||||
|
||||
struct PauseInfo: Equatable {
|
||||
let reason: PauseReason
|
||||
let timestamp: Date
|
||||
let resumable: Bool
|
||||
|
||||
enum PauseReason: Equatable {
|
||||
case userRequested
|
||||
case networkIssue
|
||||
case systemSleep
|
||||
case other(String)
|
||||
}
|
||||
}
|
||||
|
||||
struct CompletionInfo: Equatable {
|
||||
let timestamp: Date
|
||||
let totalTime: TimeInterval
|
||||
let totalSize: Int64
|
||||
}
|
||||
|
||||
struct FailureInfo: Equatable {
|
||||
let message: String
|
||||
let error: Error?
|
||||
let timestamp: Date
|
||||
let recoverable: Bool
|
||||
|
||||
static func == (lhs: FailureInfo, rhs: FailureInfo) -> Bool {
|
||||
lhs.message == rhs.message &&
|
||||
lhs.timestamp == rhs.timestamp &&
|
||||
lhs.recoverable == rhs.recoverable
|
||||
}
|
||||
}
|
||||
|
||||
struct RetryInfo: Equatable {
|
||||
let attempt: Int
|
||||
let maxAttempts: Int
|
||||
let reason: String
|
||||
let nextRetryDate: Date
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .waiting:
|
||||
return "等待中"
|
||||
case .preparing(let info):
|
||||
return "准备中: \(info.message)"
|
||||
case .downloading(let info):
|
||||
return "下载中: \(info.fileName) (\(info.currentPackageIndex + 1)/\(info.totalPackages))"
|
||||
case .paused(let info):
|
||||
switch info.reason {
|
||||
case .userRequested: return "已暂停"
|
||||
case .networkIssue: return "网络中断"
|
||||
case .systemSleep: return "系统休眠"
|
||||
case .other(let reason): return "已暂停: \(reason)"
|
||||
}
|
||||
case .completed(let info):
|
||||
let duration = String(format: "%.1f", info.totalTime)
|
||||
return "已完成 (用时: \(duration)秒)"
|
||||
case .failed(let info):
|
||||
return "失败: \(info.message)"
|
||||
case .retrying(let info):
|
||||
return "重试中 (\(info.attempt)/\(info.maxAttempts))"
|
||||
}
|
||||
}
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .downloading: return 0
|
||||
case .preparing: return 1
|
||||
case .waiting: return 2
|
||||
case .paused: return 3
|
||||
case .retrying: return 4
|
||||
case .failed: return 5
|
||||
case .completed: return 6
|
||||
}
|
||||
}
|
||||
|
||||
var isFinished: Bool {
|
||||
switch self {
|
||||
case .completed, .failed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
if case .paused = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .downloading, .preparing, .retrying:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadStatus {
|
||||
var isCompleted: Bool {
|
||||
if case .completed = self {
|
||||
return true
|
||||
@@ -177,92 +19,89 @@ class DownloadTask: Identifiable, ObservableObject, Equatable {
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PackageStatus {
|
||||
case waiting
|
||||
case downloading
|
||||
case paused
|
||||
case completed
|
||||
case failed(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .waiting: return "等待中"
|
||||
case .downloading: return "下载中"
|
||||
case .paused: return "已暂停"
|
||||
case .completed: return "已完成"
|
||||
case .failed(let message): return "失败: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Package: Identifiable {
|
||||
class NewDownloadTask: Identifiable, ObservableObject, Equatable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var Path: String
|
||||
var size: Int64
|
||||
var downloadedSize: Int64 = 0
|
||||
var progress: Double = 0
|
||||
var speed: Double = 0
|
||||
var status: PackageStatus = .waiting
|
||||
var type: String
|
||||
var downloaded: Bool = false
|
||||
var lastUpdated: Date = Date()
|
||||
var lastRecordedSize: Int64 = 0
|
||||
var sapCode: String
|
||||
let version: String
|
||||
let language: String
|
||||
let displayName: String
|
||||
let directory: URL
|
||||
var productsToDownload: [ProductsToDownload]
|
||||
var retryCount: Int
|
||||
let createAt: Date
|
||||
@Published var totalStatus: DownloadStatus?
|
||||
@Published var totalProgress: Double
|
||||
@Published var totalDownloadedSize: Int64
|
||||
@Published var totalSize: Int64
|
||||
@Published var totalSpeed: Double
|
||||
@Published var currentPackage: Package? {
|
||||
didSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
init(sapCode: String, version: String, language: String, productName: String,
|
||||
status: DownloadStatus = .waiting, progress: Double = 0,
|
||||
downloadedSize: Int64 = 0, totalSize: Int64 = 0, speed: Double = 0,
|
||||
currentFileName: String = "", destinationURL: URL,
|
||||
priority: Priority = .normal, retryCount: Int = 0,
|
||||
packages: [Package] = [], detailedStatus: String = "") {
|
||||
var status: DownloadStatus {
|
||||
totalStatus ?? .waiting
|
||||
}
|
||||
|
||||
var destinationURL: URL { directory }
|
||||
|
||||
var downloadedSize: Int64 {
|
||||
get { totalDownloadedSize }
|
||||
set { totalDownloadedSize = newValue }
|
||||
}
|
||||
|
||||
var progress: Double {
|
||||
get { totalProgress }
|
||||
set { totalProgress = newValue }
|
||||
}
|
||||
|
||||
var speed: Double {
|
||||
get { totalSpeed }
|
||||
set { totalSpeed = newValue }
|
||||
}
|
||||
|
||||
var formattedTotalSize: String {
|
||||
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
|
||||
}
|
||||
|
||||
var formattedDownloadedSize: String {
|
||||
ByteCountFormatter.string(fromByteCount: totalDownloadedSize, countStyle: .file)
|
||||
}
|
||||
|
||||
func setStatus(_ newStatus: DownloadStatus) {
|
||||
totalStatus = newStatus
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func updateProgress(downloaded: Int64, total: Int64, speed: Double) {
|
||||
totalDownloadedSize = downloaded
|
||||
totalSize = total
|
||||
totalSpeed = speed
|
||||
totalProgress = total > 0 ? Double(downloaded) / Double(total) : 0
|
||||
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) {
|
||||
self.sapCode = sapCode
|
||||
self.version = version
|
||||
self.language = language
|
||||
self.productName = productName
|
||||
self.status = status
|
||||
self.progress = progress
|
||||
self.downloadedSize = downloadedSize
|
||||
self.totalSize = totalSize
|
||||
self.speed = speed
|
||||
self.currentFileName = currentFileName
|
||||
self.destinationURL = destinationURL
|
||||
self.priority = priority
|
||||
self.displayName = displayName
|
||||
self.directory = directory
|
||||
self.productsToDownload = productsToDownload
|
||||
self.retryCount = retryCount
|
||||
self.createdAt = Date()
|
||||
self.lastUpdated = Date()
|
||||
self.lastRecordedSize = 0
|
||||
self.packages = packages
|
||||
self.detailedStatus = detailedStatus
|
||||
self.createAt = createAt
|
||||
self.totalStatus = totalStatus
|
||||
self.totalProgress = totalProgress
|
||||
self.totalDownloadedSize = totalDownloadedSize
|
||||
self.totalSize = totalSize
|
||||
self.totalSpeed = totalSpeed
|
||||
self.currentPackage = currentPackage
|
||||
}
|
||||
|
||||
private func updateProgress(_ newProgress: Double) {
|
||||
objectWillChange.send()
|
||||
progress = newProgress
|
||||
}
|
||||
|
||||
private func updateSpeed(_ newSpeed: Double) {
|
||||
objectWillChange.send()
|
||||
speed = newSpeed
|
||||
}
|
||||
|
||||
static func == (lhs: DownloadTask, rhs: DownloadTask) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadTask.DownloadStatus: Equatable {
|
||||
static func == (lhs: DownloadTask.DownloadStatus, rhs: DownloadTask.DownloadStatus) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.waiting, .waiting): return true
|
||||
case (.downloading, .downloading): return true
|
||||
case (.paused, .paused): return true
|
||||
case (.completed, .completed): return true
|
||||
case (.failed(let lhsMessage), .failed(let rhsMessage)): return lhsMessage == rhsMessage
|
||||
case (.retrying(let lhsCount), .retrying(let rhsCount)): return lhsCount == rhsCount
|
||||
default: return false
|
||||
}
|
||||
static func == (lhs: NewDownloadTask, rhs: NewDownloadTask) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +78,12 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
||||
class NetworkManager: ObservableObject {
|
||||
typealias ProgressUpdate = (bytesWritten: Int64, totalWritten: Int64, expectedToWrite: Int64)
|
||||
@Published var isConnected = false
|
||||
@Published var products: [String: Product] = [:]
|
||||
@Published var saps: [String: Sap] = [:]
|
||||
@Published var cdn: String = ""
|
||||
@Published var allowedPlatform = ["macuniversal", "macarm64", "osx10-64", "osx10"]
|
||||
@Published var sapCodes: [SapCodes] = []
|
||||
@Published var loadingState: LoadingState = .idle
|
||||
@Published var downloadTasks: [DownloadTask] = []
|
||||
@Published var downloadTasks: [NewDownloadTask] = []
|
||||
@Published var installationState: InstallationState = .idle
|
||||
private let cancelTracker = CancelTracker()
|
||||
internal var downloadUtils: DownloadUtils!
|
||||
@@ -106,27 +108,405 @@ class NetworkManager: ObservableObject {
|
||||
func fetchProducts() async {
|
||||
await fetchProductsWithRetry()
|
||||
}
|
||||
func startDownload(sap: Sap, selectedVersion: String, language: String, destinationURL: URL) async throws {
|
||||
guard let productInfo = self.saps[sap.sapCode]?.versions[selectedVersion] else {
|
||||
throw NetworkError.invalidData("无法获取产品信息")
|
||||
}
|
||||
|
||||
func startDownload(sapCode: String, version: String, language: String, destinationURL: URL) async throws {
|
||||
try await validateAndStartDownload(sapCode: sapCode, version: version, language: language, destinationURL: destinationURL)
|
||||
// 1. 创建下载任务
|
||||
let task = NewDownloadTask(
|
||||
sapCode: sap.sapCode,
|
||||
version: selectedVersion,
|
||||
language: language,
|
||||
displayName: sap.displayName,
|
||||
directory: destinationURL,
|
||||
productsToDownload: [],
|
||||
createAt: Date(),
|
||||
totalStatus: .preparing(DownloadStatus.PrepareInfo(
|
||||
message: "正在准备下载...",
|
||||
timestamp: Date(),
|
||||
stage: .initializing
|
||||
)),
|
||||
totalProgress: 0,
|
||||
totalDownloadedSize: 0,
|
||||
totalSize: 0,
|
||||
totalSpeed: 0
|
||||
)
|
||||
|
||||
downloadTasks.append(task)
|
||||
|
||||
do {
|
||||
// 2. 创建基础目录结构
|
||||
// print("Creating installer app structure at: \(destinationURL.path)")
|
||||
try downloadUtils.createInstallerApp(
|
||||
for: task.sapCode,
|
||||
version: task.version,
|
||||
language: task.language,
|
||||
at: task.directory
|
||||
)
|
||||
|
||||
// 3. 收集所有需要下载的产品
|
||||
var productsToDownload: [ProductsToDownload] = []
|
||||
|
||||
// 添加主产品
|
||||
productsToDownload.append(ProductsToDownload(
|
||||
sapCode: sap.sapCode,
|
||||
version: selectedVersion,
|
||||
buildGuid: productInfo.buildGuid
|
||||
))
|
||||
|
||||
// 添加依赖
|
||||
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
|
||||
}
|
||||
|
||||
var buildGuid = ""
|
||||
for (_, versionInfo) in sortedVersions where versionInfo.baseVersion == dependency.version {
|
||||
if allowedPlatform.contains(versionInfo.apPlatform) {
|
||||
buildGuid = versionInfo.buildGuid
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !buildGuid.isEmpty {
|
||||
productsToDownload.append(ProductsToDownload(
|
||||
sapCode: dependency.sapCode,
|
||||
version: dependency.version,
|
||||
buildGuid: buildGuid
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 为每个产品创建目录并下载 application.json
|
||||
for product in productsToDownload {
|
||||
task.setStatus(.preparing(DownloadStatus.PrepareInfo(
|
||||
message: "正在处理 \(product.sapCode) 的产品信息...",
|
||||
timestamp: Date(),
|
||||
stage: .fetchingInfo
|
||||
)))
|
||||
|
||||
// 创建产品目录
|
||||
let productDir = task.directory.appendingPathComponent("Contents/Resources/products/\(product.sapCode)")
|
||||
// print("Creating product directory: \(productDir.path)")
|
||||
|
||||
if !FileManager.default.fileExists(atPath: productDir.path) {
|
||||
try FileManager.default.createDirectory(at: productDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// 下载 application.json
|
||||
// print("Downloading application.json for \(product.sapCode)")
|
||||
let jsonString = try await getApplicationInfo(buildGuid: product.buildGuid)
|
||||
|
||||
// 保存 application.json
|
||||
let jsonURL = productDir.appendingPathComponent("application.json")
|
||||
// print("Saving application.json to: \(jsonURL.path)")
|
||||
try jsonString.write(to: jsonURL, atomically: true, encoding: .utf8)
|
||||
|
||||
// 解析包信息
|
||||
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("无法解析产品信息")
|
||||
}
|
||||
|
||||
// 解析每个包的信息
|
||||
for package in packageArray {
|
||||
// 获取包名,优先使用 fullPackageName,如果为空则使用 PackageName
|
||||
let fullPackageName: String
|
||||
if let name = package["fullPackageName"] as? String, !name.isEmpty {
|
||||
fullPackageName = name
|
||||
} else if let name = package["PackageName"] as? String, !name.isEmpty {
|
||||
fullPackageName = name
|
||||
// print("Using PackageName instead of fullPackageName for package in \(product.sapCode): \(name)")
|
||||
} else {
|
||||
// print("Warning: Skipping package with empty name in \(product.sapCode)")
|
||||
continue
|
||||
}
|
||||
|
||||
let packageType = package["Type"] as? String ?? "non-core"
|
||||
|
||||
// 解析下载大小
|
||||
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 {
|
||||
// print("Warning: Invalid download size for package: \(fullPackageName) in \(product.sapCode)")
|
||||
continue // 跳过无效的包
|
||||
}
|
||||
|
||||
guard let downloadURL = package["Path"] as? String,
|
||||
!downloadURL.isEmpty else {
|
||||
print("Warning: Missing download URL for package: \(fullPackageName) in \(product.sapCode)")
|
||||
continue
|
||||
}
|
||||
|
||||
// print("Valid package found - Name: \(fullPackageName), Type: \(packageType), Size: \(downloadSize), URL: \(downloadURL)")
|
||||
|
||||
let newPackage = Package(
|
||||
type: packageType,
|
||||
fullPackageName: fullPackageName,
|
||||
downloadSize: downloadSize,
|
||||
downloadURL: downloadURL
|
||||
)
|
||||
product.packages.append(newPackage)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 更新任务信息
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
print("Total download size: \(task.totalSize) bytes")
|
||||
print("Starting download process...")
|
||||
|
||||
// 6. 开始下载过程
|
||||
await downloadUtils.startDownloadProcess(task: task)
|
||||
|
||||
} catch {
|
||||
print("Error during download preparation: \(error.localizedDescription)")
|
||||
task.setStatus(.failed(DownloadStatus.FailureInfo(
|
||||
message: error.localizedDescription,
|
||||
error: error,
|
||||
timestamp: Date(),
|
||||
recoverable: true
|
||||
)))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func validateAndStartDownload(task: NewDownloadTask) async throws {
|
||||
// 创建安装程序目录结构
|
||||
try downloadUtils.createInstallerApp(
|
||||
for: task.sapCode,
|
||||
version: task.version,
|
||||
language: task.language,
|
||||
at: task.directory
|
||||
)
|
||||
await startDownloadProcess(task: task)
|
||||
}
|
||||
|
||||
internal func startDownloadProcess(task: NewDownloadTask) async {
|
||||
// 1. 更新任务状态为准备中
|
||||
task.totalStatus = .preparing(DownloadStatus.PrepareInfo(
|
||||
message: "正在准备下载...",
|
||||
timestamp: Date(),
|
||||
stage: .initializing
|
||||
))
|
||||
|
||||
// 2. 遍历所有产品的包
|
||||
for product in task.productsToDownload {
|
||||
let sapCode = product.sapCode
|
||||
let version = product.version
|
||||
|
||||
// 3. 创建产品目录
|
||||
let productDir = task.directory.appendingPathComponent("Contents/Resources/products/\(sapCode)")
|
||||
try? FileManager.default.createDirectory(at: productDir, withIntermediateDirectories: true)
|
||||
|
||||
// 4. 开始下载包
|
||||
for package in product.packages {
|
||||
// 更新当前包信息
|
||||
task.currentPackage = package
|
||||
|
||||
// 构建下载 URL
|
||||
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
|
||||
}
|
||||
|
||||
// 更新包状态
|
||||
package.downloaded = true
|
||||
package.progress = 1.0
|
||||
package.status = .completed
|
||||
|
||||
// 更新总进度
|
||||
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()
|
||||
|
||||
// 等待下载完成
|
||||
await withCheckedContinuation { continuation in
|
||||
delegate.completionHandler = { _, _, _ in
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 所有包下载完成后,生成 driver.xml
|
||||
let driverXml = downloadUtils.generateDriverXML(
|
||||
sapCode: task.sapCode,
|
||||
version: task.version,
|
||||
language: task.language,
|
||||
productInfo: (self.saps[task.sapCode]?.versions[task.version])!,
|
||||
displayName: task.displayName
|
||||
)
|
||||
|
||||
try? driverXml.write(
|
||||
to: task.directory.appendingPathComponent("Contents/Resources/products/driver.xml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
|
||||
// 6. 更新任务状态为完成
|
||||
task.totalStatus = .completed(DownloadStatus.CompletionInfo(
|
||||
timestamp: Date(),
|
||||
totalTime: Date().timeIntervalSince(task.createAt),
|
||||
totalSize: task.totalSize
|
||||
))
|
||||
}
|
||||
|
||||
private func performDownload(task: NewDownloadTask) async throws {
|
||||
if task.sapCode == "APRO" {
|
||||
// APRO 的特殊处理
|
||||
// 暂时移除 APRO 的处理,或者实现新的处理逻辑
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
print("\nPreparing...\n")
|
||||
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)
|
||||
|
||||
// ... 其他处理逻辑 ...
|
||||
}
|
||||
|
||||
print("\nGenerating driver.xml")
|
||||
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 {
|
||||
await downloadUtils.pauseDownloadTask(
|
||||
taskId: taskId,
|
||||
reason: DownloadTask.DownloadStatus.PauseInfo.PauseReason.userRequested
|
||||
)
|
||||
if let task = downloadTasks.first(where: { $0.id == taskId }) {
|
||||
await MainActor.run {
|
||||
task.setStatus(.paused(DownloadStatus.PauseInfo(
|
||||
reason: .userRequested,
|
||||
timestamp: Date(),
|
||||
resumable: true
|
||||
)))
|
||||
objectWillChange.send()
|
||||
}
|
||||
await cancelTracker.pause(taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resumeDownload(taskId: UUID) async {
|
||||
await downloadUtils.resumeDownloadTask(taskId: taskId)
|
||||
if let task = 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
|
||||
)))
|
||||
// ... 实现下载逻辑 ...
|
||||
}
|
||||
}
|
||||
|
||||
func cancelDownload(taskId: UUID, removeFiles: Bool = false) {
|
||||
Task {
|
||||
await downloadUtils.cancelDownloadTask(taskId: taskId, removeFiles: removeFiles)
|
||||
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
|
||||
)))
|
||||
objectWillChange.send()
|
||||
}
|
||||
await cancelTracker.cancel(taskId)
|
||||
if removeFiles {
|
||||
try? FileManager.default.removeItem(at: task.directory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,204 +520,21 @@ class NetworkManager: ObservableObject {
|
||||
configureNetworkMonitor()
|
||||
}
|
||||
|
||||
private func validateAndStartDownload(sapCode: String, version: String, language: String, destinationURL: URL) async throws {
|
||||
if downloadTasks.contains(where: { task in
|
||||
task.sapCode == sapCode &&
|
||||
task.version == version &&
|
||||
!({
|
||||
if case .failed = task.status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}())
|
||||
}) {
|
||||
throw NetworkError.downloadError("该版本已在下载队列中", nil)
|
||||
}
|
||||
|
||||
guard let productInfo = products[sapCode]?.versions[version] else {
|
||||
throw NetworkError.invalidData("无法获取产品信息")
|
||||
}
|
||||
|
||||
let installerURL: URL
|
||||
if sapCode == "APRO" {
|
||||
let fileName = "Acrobat_DC_Web_WWMUI.dmg"
|
||||
installerURL = destinationURL.appendingPathComponent(fileName)
|
||||
} else {
|
||||
let appName = "Install \(sapCode)_\(version)-\(language)-\(productInfo.apPlatform).app"
|
||||
let baseDirectory: URL
|
||||
if destinationURL.pathExtension == "app" {
|
||||
baseDirectory = destinationURL.deletingLastPathComponent()
|
||||
} else {
|
||||
baseDirectory = destinationURL
|
||||
}
|
||||
installerURL = baseDirectory.appendingPathComponent(appName)
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: installerURL.path) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "安装程序已存在"
|
||||
alert.informativeText = "在目标位置已找到相同版本的安装程序,您想要如何处理?"
|
||||
alert.addButton(withTitle: "使用已有程序")
|
||||
alert.addButton(withTitle: "重新下载")
|
||||
alert.addButton(withTitle: "取消")
|
||||
|
||||
let response = await MainActor.run {
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
let task = DownloadTask(
|
||||
sapCode: sapCode,
|
||||
version: version,
|
||||
language: language,
|
||||
productName: products[sapCode]?.displayName ?? "",
|
||||
status: .completed(DownloadTask.DownloadStatus.CompletionInfo(
|
||||
timestamp: Date(),
|
||||
totalTime: 0,
|
||||
totalSize: 0
|
||||
)),
|
||||
progress: 1.0,
|
||||
downloadedSize: 0,
|
||||
totalSize: 0,
|
||||
speed: 0,
|
||||
currentFileName: "",
|
||||
destinationURL: installerURL,
|
||||
packages: []
|
||||
)
|
||||
downloadTasks.append(task)
|
||||
return
|
||||
|
||||
case .alertSecondButtonReturn:
|
||||
try? FileManager.default.removeItem(at: installerURL)
|
||||
default:
|
||||
throw NetworkError.downloadCancelled
|
||||
}
|
||||
}
|
||||
|
||||
let task = DownloadTask(
|
||||
sapCode: sapCode,
|
||||
version: version,
|
||||
language: language,
|
||||
productName: products[sapCode]?.displayName ?? "",
|
||||
status: .preparing(DownloadTask.DownloadStatus.PrepareInfo(
|
||||
message: "正在初始化...",
|
||||
timestamp: Date(),
|
||||
stage: .initializing
|
||||
)),
|
||||
progress: 0,
|
||||
downloadedSize: 0,
|
||||
totalSize: 0,
|
||||
speed: 0,
|
||||
currentFileName: "",
|
||||
destinationURL: installerURL,
|
||||
packages: []
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
downloadTasks.append(task)
|
||||
updateDockBadge()
|
||||
}
|
||||
|
||||
try await performDownload(task: task, productInfo: productInfo)
|
||||
}
|
||||
|
||||
private func performDownload(task: DownloadTask, productInfo: Product.ProductVersion) async throws {
|
||||
if task.sapCode == "APRO" {
|
||||
try await downloadUtils.downloadAPRO(task: task, productInfo: productInfo)
|
||||
return
|
||||
}
|
||||
|
||||
try downloadUtils.createInstallerApp(
|
||||
for: task.sapCode,
|
||||
version: task.version,
|
||||
language: task.language,
|
||||
at: task.destinationURL
|
||||
)
|
||||
|
||||
try await downloadUtils.signApp(at: task.destinationURL)
|
||||
|
||||
await updateTaskStatus(task.id, .preparing(DownloadTask.DownloadStatus.PrepareInfo(
|
||||
message: "正在获取 \(task.productName) 的下载信息...",
|
||||
timestamp: Date(),
|
||||
stage: .fetchingInfo
|
||||
)))
|
||||
let appInfo = try await getApplicationInfo(buildGuid: productInfo.buildGuid)
|
||||
|
||||
let packages = appInfo.Packages.Package.map { package in
|
||||
DownloadTask.Package(
|
||||
name: package.PackageName ?? "",
|
||||
Path: package.Path,
|
||||
size: package.size,
|
||||
downloadedSize: 0,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
status: .waiting,
|
||||
type: package.PackageType ?? "",
|
||||
downloaded: false,
|
||||
lastUpdated: Date(),
|
||||
lastRecordedSize: 0
|
||||
)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
if let index = downloadTasks.firstIndex(where: { $0.id == task.id }) {
|
||||
downloadTasks[index].packages = packages
|
||||
downloadTasks[index].totalSize = packages.reduce(0) { $0 + $1.size }
|
||||
}
|
||||
}
|
||||
|
||||
let productDir = task.destinationURL.appendingPathComponent("Contents/Resources/products/\(task.sapCode)")
|
||||
try FileManager.default.createDirectory(at: productDir, withIntermediateDirectories: true)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let jsonData = try encoder.encode(appInfo)
|
||||
try jsonData.write(to: productDir.appendingPathComponent("application.json"))
|
||||
|
||||
await MainActor.run {
|
||||
if let taskIndex = downloadTasks.firstIndex(where: { $0.id == task.id }) {
|
||||
downloadTasks[taskIndex].status = .downloading(DownloadTask.DownloadStatus.DownloadInfo(
|
||||
fileName: packages[0].name,
|
||||
currentPackageIndex: 0,
|
||||
totalPackages: packages.count,
|
||||
startTime: Date(),
|
||||
estimatedTimeRemaining: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
await resumeDownload(taskId: task.id)
|
||||
|
||||
let driverXml = downloadUtils.generateDriverXML(
|
||||
sapCode: task.sapCode,
|
||||
version: task.version,
|
||||
language: task.language,
|
||||
productInfo: productInfo,
|
||||
displayName: task.productName
|
||||
)
|
||||
|
||||
let productsDir = task.destinationURL.appendingPathComponent("Contents/Resources/products")
|
||||
try driverXml.write(to: productsDir.appendingPathComponent("driver.xml"),
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
}
|
||||
|
||||
private func handleDownloadError(taskId: UUID, error: Error) async {
|
||||
await MainActor.run {
|
||||
guard let index = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
|
||||
let task = downloadTasks[index]
|
||||
|
||||
let (errorMessage, isRecoverable) = classifyError(error)
|
||||
|
||||
if isRecoverable && downloadTasks[index].retryCount < NetworkConstants.maxRetryAttempts {
|
||||
downloadTasks[index].retryCount += 1
|
||||
if isRecoverable && task.retryCount < NetworkConstants.maxRetryAttempts {
|
||||
task.retryCount += 1
|
||||
let nextRetryDate = Date().addingTimeInterval(TimeInterval(NetworkConstants.retryDelay / 1_000_000_000))
|
||||
downloadTasks[index].status = .retrying(DownloadTask.DownloadStatus.RetryInfo(
|
||||
attempt: downloadTasks[index].retryCount,
|
||||
task.setStatus(.retrying(DownloadStatus.RetryInfo(
|
||||
attempt: task.retryCount,
|
||||
maxAttempts: NetworkConstants.maxRetryAttempts,
|
||||
reason: errorMessage,
|
||||
nextRetryDate: nextRetryDate
|
||||
))
|
||||
)))
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -350,21 +547,20 @@ class NetworkManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
downloadTasks[index].status = .failed(DownloadTask.DownloadStatus.FailureInfo(
|
||||
task.setStatus(.failed(DownloadStatus.FailureInfo(
|
||||
message: errorMessage,
|
||||
error: error,
|
||||
timestamp: Date(),
|
||||
recoverable: isRecoverable
|
||||
))
|
||||
)))
|
||||
|
||||
progressObservers[taskId]?.invalidate()
|
||||
progressObservers.removeValue(forKey: taskId)
|
||||
|
||||
if let currentPackage = downloadTasks[index].packages.first(where: { !$0.downloaded }) {
|
||||
let destinationDir = downloadTasks[index].destinationURL
|
||||
.appendingPathComponent("Contents/Resources/products/\(downloadTasks[index].sapCode)")
|
||||
let fileName = currentPackage.Path.components(separatedBy: "/").last ?? ""
|
||||
let fileURL = destinationDir.appendingPathComponent(fileName)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -372,7 +568,6 @@ class NetworkManager: ObservableObject {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func classifyError(_ error: Error) -> (message: String, recoverable: Bool) {
|
||||
switch error {
|
||||
@@ -394,7 +589,7 @@ class NetworkManager: ObservableObject {
|
||||
case let urlError as URLError:
|
||||
switch urlError.code {
|
||||
case .notConnectedToInternet:
|
||||
return ("网络连接已断开", true)
|
||||
return ("网络连接已开", true)
|
||||
case .timedOut:
|
||||
return ("连接超时", true)
|
||||
case .cancelled:
|
||||
@@ -410,44 +605,65 @@ class NetworkManager: ObservableObject {
|
||||
private func updateProgress(for taskId: UUID, progress: ProgressUpdate) {
|
||||
guard let index = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
|
||||
let task = downloadTasks[index]
|
||||
guard let packageIndex = task.packages.firstIndex(where: { !$0.downloaded }) else { return }
|
||||
|
||||
// 找到当前正在下载的包
|
||||
guard let currentPackage = task.currentPackage else { return }
|
||||
|
||||
let now = Date()
|
||||
let timeDiff = now.timeIntervalSince(task.packages[packageIndex].lastUpdated)
|
||||
let timeDiff = now.timeIntervalSince(currentPackage.lastUpdated)
|
||||
guard timeDiff >= NetworkConstants.progressUpdateInterval else { return }
|
||||
downloadTasks[index].packages[packageIndex].downloadedSize = progress.totalWritten
|
||||
downloadTasks[index].packages[packageIndex].progress =
|
||||
clampProgress(Double(progress.totalWritten) / Double(progress.expectedToWrite))
|
||||
let byteDiff = progress.totalWritten - task.packages[packageIndex].lastRecordedSize
|
||||
|
||||
// 更当前包的进度
|
||||
currentPackage.downloadedSize = progress.totalWritten
|
||||
currentPackage.progress = clampProgress(Double(progress.totalWritten) / Double(progress.expectedToWrite))
|
||||
|
||||
// 更新速度
|
||||
let byteDiff = progress.totalWritten - currentPackage.lastRecordedSize
|
||||
if byteDiff > 0 {
|
||||
let speed = Double(byteDiff) / timeDiff
|
||||
downloadTasks[index].packages[packageIndex].speed = speed
|
||||
downloadTasks[index].speed = speed
|
||||
currentPackage.speed = speed
|
||||
task.totalSpeed = speed
|
||||
}
|
||||
|
||||
// 计算总下载进度
|
||||
var totalDownloaded: Int64 = 0
|
||||
for (i, package) in task.packages.enumerated() {
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
for product in task.productsToDownload {
|
||||
for package in product.packages {
|
||||
if package.downloaded {
|
||||
totalDownloaded += package.size
|
||||
} else if i == packageIndex {
|
||||
totalDownloaded += min(progress.totalWritten, package.size)
|
||||
totalDownloaded += package.downloadSize
|
||||
} else if package.id == currentPackage.id {
|
||||
totalDownloaded += progress.totalWritten
|
||||
}
|
||||
totalSize += package.downloadSize
|
||||
}
|
||||
}
|
||||
downloadTasks[index].downloadedSize = totalDownloaded
|
||||
downloadTasks[index].progress = clampProgress(Double(totalDownloaded) / Double(task.totalSize))
|
||||
|
||||
// 更新任务总进度
|
||||
task.totalDownloadedSize = totalDownloaded
|
||||
task.totalProgress = clampProgress(Double(totalDownloaded) / Double(totalSize))
|
||||
|
||||
// 检查当前包是否下载完成
|
||||
if progress.totalWritten >= progress.expectedToWrite {
|
||||
downloadTasks[index].packages[packageIndex].downloaded = true
|
||||
downloadTasks[index].packages[packageIndex].downloadedSize = downloadTasks[index].packages[packageIndex].size
|
||||
downloadTasks[index].packages[packageIndex].progress = 1.0
|
||||
downloadTasks[index].packages[packageIndex].speed = 0
|
||||
currentPackage.downloaded = true
|
||||
currentPackage.downloadedSize = currentPackage.downloadSize
|
||||
currentPackage.progress = 1.0
|
||||
currentPackage.speed = 0
|
||||
}
|
||||
downloadTasks[index].packages[packageIndex].lastRecordedSize = progress.totalWritten
|
||||
downloadTasks[index].packages[packageIndex].lastUpdated = now
|
||||
|
||||
// 更新包的记录
|
||||
currentPackage.lastRecordedSize = progress.totalWritten
|
||||
currentPackage.lastUpdated = now
|
||||
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
private func updateTaskStatus(_ taskId: UUID, _ status: DownloadTask.DownloadStatus) async {
|
||||
private func updateTaskStatus(_ taskId: UUID, _ status: DownloadStatus) async {
|
||||
await MainActor.run {
|
||||
guard let index = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
|
||||
downloadTasks[index].status = status
|
||||
if let index = downloadTasks.firstIndex(where: { $0.id == taskId }) {
|
||||
downloadTasks[index].setStatus(status)
|
||||
|
||||
switch status {
|
||||
case .completed:
|
||||
progressObservers[taskId]?.invalidate()
|
||||
@@ -479,6 +695,7 @@ class NetworkManager: ObservableObject {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clampProgress(_ value: Double) -> Double {
|
||||
min(1.0, max(0.0, value))
|
||||
@@ -496,16 +713,61 @@ class NetworkManager: ObservableObject {
|
||||
await MainActor.run { activeDownloadTaskId }
|
||||
}
|
||||
|
||||
func setTaskStatus(_ taskId: UUID, _ status: DownloadTask.DownloadStatus) async {
|
||||
func setTaskStatus(_ taskId: UUID, _ status: DownloadStatus) async {
|
||||
await updateTaskStatus(taskId, status)
|
||||
}
|
||||
|
||||
func getTasks() async -> [DownloadTask] {
|
||||
func getTasks() async -> [NewDownloadTask] {
|
||||
await MainActor.run { downloadTasks }
|
||||
}
|
||||
|
||||
func handleError(_ taskId: UUID, _ error: Error) async {
|
||||
await handleDownloadError(taskId: taskId, error: error)
|
||||
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)
|
||||
@@ -517,11 +779,23 @@ class NetworkManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func removeTask(taskId: UUID, removeFiles: Bool = false) {
|
||||
func removeTask(taskId: UUID, removeFiles: Bool = true) {
|
||||
Task {
|
||||
if removeFiles {
|
||||
await cancelTracker.cancel(taskId)
|
||||
|
||||
if let task = downloadTasks.first(where: { $0.id == taskId }) {
|
||||
try? FileManager.default.removeItem(at: task.destinationURL)
|
||||
if removeFiles {
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: task.directory.path) {
|
||||
try FileManager.default.removeItem(at: task.directory)
|
||||
}
|
||||
|
||||
let productsPath = task.directory.appendingPathComponent("Contents/Resources/products/\(task.sapCode)")
|
||||
if FileManager.default.fileExists(atPath: productsPath.path) {
|
||||
try FileManager.default.removeItem(at: productsPath)
|
||||
}
|
||||
} catch {
|
||||
print("Error removing files for task \(taskId): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,6 +806,7 @@ class NetworkManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchProductsWithRetry() async {
|
||||
guard !isFetchingProducts else { return }
|
||||
@@ -544,10 +819,12 @@ class NetworkManager: ObservableObject {
|
||||
|
||||
while retryCount < maxRetries {
|
||||
do {
|
||||
let (products, cdn) = try await fetchProductsData()
|
||||
let (saps, cdn, sapCodes) = try await fetchProductsData()
|
||||
|
||||
await MainActor.run {
|
||||
self.products = products
|
||||
self.saps = saps
|
||||
self.cdn = cdn
|
||||
self.sapCodes = sapCodes
|
||||
self.loadingState = .success
|
||||
self.isFetchingProducts = false
|
||||
}
|
||||
@@ -618,7 +895,7 @@ class NetworkManager: ObservableObject {
|
||||
}
|
||||
|
||||
do {
|
||||
// 先尝试使用 retry 方法(利用 sudo 缓存)
|
||||
// 先尝试使用 retry 方法利用 sudo 缓存)
|
||||
try await installManager.retry(at: path) { progress, status in
|
||||
Task { @MainActor in
|
||||
if status.contains("完成") || status.contains("成功") {
|
||||
@@ -649,4 +926,105 @@ 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["Accept"] = "application/json"
|
||||
headers["Connection"] = "keep-alive"
|
||||
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("无法将响应数据转换为字符串")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ class DownloadUtils {
|
||||
var progressHandler: ((Int64, Int64, Int64) -> Void)?
|
||||
var destinationDirectory: URL
|
||||
var fileName: String
|
||||
private var hasCompleted = false
|
||||
private let completionLock = NSLock()
|
||||
|
||||
init(destinationDirectory: URL,
|
||||
fileName: String,
|
||||
@@ -37,6 +39,12 @@ class DownloadUtils {
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
completionLock.lock()
|
||||
defer { completionLock.unlock() }
|
||||
|
||||
guard !hasCompleted else { return }
|
||||
hasCompleted = true
|
||||
|
||||
do {
|
||||
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
|
||||
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
|
||||
@@ -50,16 +58,18 @@ class DownloadUtils {
|
||||
|
||||
try FileManager.default.moveItem(at: location, to: destinationURL)
|
||||
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
let expectedSize = downloadTask.countOfBytesExpectedToReceive
|
||||
if expectedSize > 0,
|
||||
let fileSize = try? FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 {
|
||||
print("File size verification - Expected: \(expectedSize), Actual: \(fileSize)")
|
||||
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
let fileSize = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0
|
||||
print("File size verification - Expected: \(downloadTask.countOfBytesExpectedToReceive), Actual: \(fileSize)")
|
||||
if fileSize != expectedSize {
|
||||
print("Warning: File size mismatch - Expected: \(expectedSize), Actual: \(fileSize)")
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(destinationURL, downloadTask.response, nil)
|
||||
} else {
|
||||
completionHandler(nil, downloadTask.response, NetworkError.fileSystemError("文件移动后不存在", nil))
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("File operation error in delegate: \(error.localizedDescription)")
|
||||
completionHandler(nil, downloadTask.response, error)
|
||||
@@ -67,8 +77,13 @@ class DownloadUtils {
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let error = error else { return }
|
||||
completionLock.lock()
|
||||
defer { completionLock.unlock() }
|
||||
|
||||
guard !hasCompleted else { return }
|
||||
hasCompleted = true
|
||||
|
||||
if let error = error {
|
||||
switch (error as NSError).code {
|
||||
case NSURLErrorCancelled:
|
||||
return
|
||||
@@ -80,6 +95,7 @@ class DownloadUtils {
|
||||
completionHandler(nil, task.response, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
@@ -90,178 +106,51 @@ class DownloadUtils {
|
||||
|
||||
progressHandler?(bytesWritten, totalBytesWritten, totalBytesExpectedToWrite)
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
completionHandler = { _, _, _ in }
|
||||
progressHandler = nil
|
||||
}
|
||||
}
|
||||
|
||||
func pauseDownloadTask(taskId: UUID, reason: DownloadTask.DownloadStatus.PauseInfo.PauseReason = .userRequested) async {
|
||||
await cancelTracker.pause(taskId)
|
||||
await networkManager?.setTaskStatus(taskId, .paused(DownloadTask.DownloadStatus.PauseInfo(
|
||||
func pauseDownloadTask(taskId: UUID, reason: DownloadStatus.PauseInfo.PauseReason) async {
|
||||
if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) {
|
||||
task.setStatus(.paused(DownloadStatus.PauseInfo(
|
||||
reason: reason,
|
||||
timestamp: Date(),
|
||||
resumable: true
|
||||
)))
|
||||
await cancelTracker.pause(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
func resumeDownloadTask(taskId: UUID) async {
|
||||
guard let networkManager = networkManager,
|
||||
let task = await networkManager.getTasks().first(where: { $0.id == taskId }) else { return }
|
||||
|
||||
if let activeId = await networkManager.getActiveTaskId(), activeId != taskId {
|
||||
await cancelTracker.cancel(activeId)
|
||||
}
|
||||
|
||||
guard let packageIndex = task.packages.firstIndex(where: { !$0.downloaded }) else {
|
||||
await networkManager.setTaskStatus(taskId, .completed(DownloadTask.DownloadStatus.CompletionInfo(
|
||||
timestamp: Date(),
|
||||
totalTime: Date().timeIntervalSince(task.startTime),
|
||||
totalSize: task.totalSize
|
||||
)))
|
||||
return
|
||||
}
|
||||
|
||||
let package = task.packages[packageIndex]
|
||||
|
||||
let delegate = DownloadDelegate(
|
||||
destinationDirectory: task.destinationURL.appendingPathComponent("Contents/Resources/products/\(task.sapCode)"),
|
||||
fileName: package.Path.components(separatedBy: "/").last ?? "",
|
||||
completionHandler: { [weak networkManager] localURL, response, error in
|
||||
guard let networkManager = networkManager else { return }
|
||||
|
||||
Task {
|
||||
if let error = error {
|
||||
await networkManager.handleError(taskId, error)
|
||||
return
|
||||
}
|
||||
|
||||
if let localURL = localURL {
|
||||
do {
|
||||
let fileSize = try FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64 ?? 0
|
||||
guard fileSize >= package.size else {
|
||||
throw NetworkError.dataValidationError("文件大小不正确")
|
||||
}
|
||||
|
||||
await networkManager.handleDownloadCompletion(taskId: taskId, packageIndex: packageIndex)
|
||||
} catch {
|
||||
print("File validation error: \(error.localizedDescription)")
|
||||
await networkManager.handleError(taskId, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
progressHandler: { [weak networkManager] bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in
|
||||
guard let networkManager = networkManager else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
networkManager.updateDownloadProgress(for: taskId, progress: (
|
||||
bytesWritten: bytesWritten,
|
||||
totalWritten: totalBytesWritten,
|
||||
expectedToWrite: totalBytesExpectedToWrite
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForResource = NetworkConstants.downloadTimeout
|
||||
config.timeoutIntervalForRequest = NetworkConstants.downloadTimeout
|
||||
|
||||
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
var downloadTask: URLSessionDownloadTask
|
||||
|
||||
if let resumeData = await cancelTracker.getResumeData(taskId) {
|
||||
downloadTask = session.downloadTask(withResumeData: resumeData)
|
||||
} else {
|
||||
let downloadURL: String
|
||||
if task.sapCode == "APRO" {
|
||||
downloadURL = await package.Path.hasPrefix("https://") ? package.Path : networkManager.cdn + package.Path
|
||||
} else {
|
||||
downloadURL = await networkManager.cdn + package.Path
|
||||
}
|
||||
|
||||
guard let url = URL(string: downloadURL) else {
|
||||
await networkManager.handleError(taskId, NetworkError.invalidURL(downloadURL))
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
NetworkConstants.downloadHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
||||
|
||||
downloadTask = session.downloadTask(with: request)
|
||||
}
|
||||
|
||||
await cancelTracker.registerTask(taskId, task: downloadTask, session: session)
|
||||
|
||||
await networkManager.setTaskStatus(taskId, .downloading(DownloadTask.DownloadStatus.DownloadInfo(
|
||||
fileName: package.name,
|
||||
currentPackageIndex: packageIndex,
|
||||
totalPackages: task.packages.count,
|
||||
if let task = await 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
|
||||
)))
|
||||
|
||||
downloadTask.resume()
|
||||
// 实现下载逻辑
|
||||
await startDownloadProcess(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelDownloadTask(taskId: UUID, removeFiles: Bool = false) async {
|
||||
await cancelTracker.cancel(taskId)
|
||||
|
||||
if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) {
|
||||
if removeFiles {
|
||||
if let task = await networkManager?.getTasks().first(where: { $0.id == taskId }) {
|
||||
try? FileManager.default.removeItem(at: task.destinationURL)
|
||||
try? FileManager.default.removeItem(at: task.directory)
|
||||
}
|
||||
}
|
||||
|
||||
await networkManager?.setTaskStatus(taskId, .failed(DownloadTask.DownloadStatus.FailureInfo(
|
||||
task.setStatus(.failed(DownloadStatus.FailureInfo(
|
||||
message: "下载已取消",
|
||||
error: NetworkError.downloadCancelled,
|
||||
timestamp: Date(),
|
||||
recoverable: false
|
||||
)))
|
||||
}
|
||||
|
||||
func downloadAPRO(task: DownloadTask, productInfo: Product.ProductVersion) async throws {
|
||||
guard let networkManager = networkManager else { return }
|
||||
|
||||
let manifestURL = await networkManager.cdnUrl + productInfo.buildGuid
|
||||
print("Manifest URL:", manifestURL)
|
||||
guard let url = URL(string: manifestURL) else {
|
||||
throw NetworkError.invalidURL(manifestURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
NetworkConstants.adobeRequestHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
||||
|
||||
let (manifestData, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
let manifestXML = try XMLDocument(data: manifestData)
|
||||
|
||||
guard let downloadPath = try manifestXML.nodes(forXPath: "//asset_list/asset/asset_path").first?.stringValue,
|
||||
let assetSizeStr = try manifestXML.nodes(forXPath: "//asset_list/asset/asset_size").first?.stringValue,
|
||||
let assetSize = Int64(assetSizeStr) else {
|
||||
throw NetworkError.invalidData("无法从manifest中获取下载信息")
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
if let index = networkManager.downloadTasks.firstIndex(where: { $0.id == task.id }) {
|
||||
networkManager.downloadTasks[index].packages = [
|
||||
DownloadTask.Package(
|
||||
name: "Acrobat_DC_Web_WWMUI.dmg",
|
||||
Path: downloadPath,
|
||||
size: assetSize,
|
||||
downloadedSize: 0,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
status: .waiting,
|
||||
type: "core",
|
||||
downloaded: false,
|
||||
lastUpdated: Date(),
|
||||
lastRecordedSize: 0
|
||||
)
|
||||
]
|
||||
networkManager.downloadTasks[index].totalSize = assetSize
|
||||
}
|
||||
}
|
||||
|
||||
await networkManager.resumeDownload(taskId: task.id)
|
||||
}
|
||||
|
||||
func signApp(at url: URL) async throws {
|
||||
@@ -329,8 +218,7 @@ class DownloadUtils {
|
||||
)
|
||||
}
|
||||
|
||||
func generateDriverXML(sapCode: String, version: String, language: String,
|
||||
productInfo: Product.ProductVersion, displayName: String) -> String {
|
||||
func generateDriverXML(sapCode: String, version: String, language: String, productInfo: Sap.Versions, displayName: String) -> String {
|
||||
let dependencies = productInfo.dependencies.map { dependency in
|
||||
"""
|
||||
<Dependency>
|
||||
@@ -391,4 +279,189 @@ class DownloadUtils {
|
||||
print("Error executing xattr command:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
internal func startDownloadProcess(task: NewDownloadTask) async {
|
||||
// 在开始下载前更新状态
|
||||
await MainActor.run {
|
||||
let totalPackages = task.productsToDownload.reduce(0) { $0 + $1.packages.count }
|
||||
task.setStatus(.downloading(DownloadStatus.DownloadInfo(
|
||||
fileName: task.currentPackage?.fullPackageName ?? "",
|
||||
currentPackageIndex: 0,
|
||||
totalPackages: totalPackages,
|
||||
startTime: Date(),
|
||||
estimatedTimeRemaining: nil
|
||||
)))
|
||||
task.objectWillChange.send()
|
||||
}
|
||||
|
||||
var currentPackageIndex = 0
|
||||
let totalPackages = task.productsToDownload.reduce(0) { $0 + $1.packages.count }
|
||||
|
||||
for product in task.productsToDownload {
|
||||
for package in product.packages where !package.downloaded {
|
||||
await MainActor.run {
|
||||
task.currentPackage = package
|
||||
task.setStatus(.downloading(DownloadStatus.DownloadInfo(
|
||||
fileName: package.fullPackageName,
|
||||
currentPackageIndex: currentPackageIndex,
|
||||
totalPackages: totalPackages,
|
||||
startTime: Date(),
|
||||
estimatedTimeRemaining: nil
|
||||
)))
|
||||
}
|
||||
currentPackageIndex += 1
|
||||
|
||||
// 验证包信息
|
||||
guard !package.fullPackageName.isEmpty,
|
||||
!package.downloadURL.isEmpty,
|
||||
package.downloadSize > 0 else {
|
||||
print("Warning: Skipping invalid package in \(product.sapCode)")
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建下载 URL
|
||||
let cdn = await networkManager?.cdn ?? ""
|
||||
let cleanCdn = cdn.hasSuffix("/") ? String(cdn.dropLast()) : cdn
|
||||
let cleanPath = package.downloadURL.hasPrefix("/") ? package.downloadURL : "/\(package.downloadURL)"
|
||||
let downloadURL = cleanCdn + cleanPath
|
||||
|
||||
guard let url = URL(string: downloadURL) else {
|
||||
print("Error: Invalid download URL: \(downloadURL)")
|
||||
continue
|
||||
}
|
||||
|
||||
print("Starting download for \(package.fullPackageName) from \(downloadURL)")
|
||||
|
||||
// 使用 async/await 等待每个下载完成
|
||||
do {
|
||||
try await downloadPackage(package: package, task: task, product: product, url: url)
|
||||
print("Completed download for \(package.fullPackageName)")
|
||||
} catch {
|
||||
print("Error downloading \(package.fullPackageName): \(error.localizedDescription)")
|
||||
await networkManager?.handleError(task.id, error)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否所有包都已下载完成
|
||||
let allPackagesDownloaded = task.productsToDownload.allSatisfy { product in
|
||||
product.packages.allSatisfy { $0.downloaded }
|
||||
}
|
||||
|
||||
if allPackagesDownloaded {
|
||||
task.setStatus(.completed(DownloadStatus.CompletionInfo(
|
||||
timestamp: Date(),
|
||||
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("Contents/Resources/products/\(product.sapCode)"),
|
||||
fileName: package.fullPackageName,
|
||||
completionHandler: { [weak networkManager] localURL, response, error in
|
||||
Task { @MainActor in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
package.downloaded = true
|
||||
package.progress = 1.0
|
||||
package.status = .completed
|
||||
package.speed = 0
|
||||
|
||||
// 更新总进度
|
||||
let totalDownloaded = task.productsToDownload.reduce(0) { sum, product in
|
||||
sum + product.packages.reduce(0) { sum, pkg in
|
||||
sum + (pkg.downloaded ? pkg.downloadSize : 0)
|
||||
}
|
||||
}
|
||||
task.totalDownloadedSize = totalDownloaded
|
||||
task.totalProgress = Double(totalDownloaded) / Double(task.totalSize)
|
||||
task.totalSpeed = 0
|
||||
|
||||
task.objectWillChange.send()
|
||||
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 {
|
||||
// 计算速度 (bytes/s)
|
||||
let bytesDiff = totalBytesWritten - lastBytes
|
||||
let speed = Double(bytesDiff) / timeDiff
|
||||
|
||||
// 更新包进度
|
||||
package.downloadedSize = totalBytesWritten
|
||||
package.progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||
package.speed = speed
|
||||
package.status = .downloading
|
||||
|
||||
// 更新产品总进度
|
||||
let productTotalSize = product.packages.reduce(Int64(0)) { $0 + $1.downloadSize }
|
||||
let productDownloaded = product.packages.reduce(Int64(0)) { sum, pkg in
|
||||
if pkg.downloaded {
|
||||
return sum + pkg.downloadSize
|
||||
} else if pkg.id == package.id {
|
||||
return sum + totalBytesWritten
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// 更新任务总进度
|
||||
let totalDownloaded = task.productsToDownload.reduce(Int64(0)) { sum, prod in
|
||||
if prod.sapCode == product.sapCode {
|
||||
return sum + productDownloaded
|
||||
} else {
|
||||
return sum + prod.packages.reduce(Int64(0)) { sum, pkg in
|
||||
sum + (pkg.downloaded ? pkg.downloadSize : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task.totalDownloadedSize = totalDownloaded
|
||||
task.totalProgress = Double(totalDownloaded) / Double(task.totalSize)
|
||||
task.totalSpeed = speed
|
||||
|
||||
// 更新时间和字节数
|
||||
lastUpdateTime = now
|
||||
lastBytes = totalBytesWritten
|
||||
|
||||
// 触发 UI 更新
|
||||
package.objectWillChange.send()
|
||||
task.objectWillChange.send()
|
||||
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)
|
||||
let downloadTask = session.downloadTask(with: request)
|
||||
|
||||
Task {
|
||||
await cancelTracker.registerTask(task.id, task: downloadTask, session: session)
|
||||
}
|
||||
|
||||
downloadTask.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ actor InstallManager {
|
||||
let installProcess = Process()
|
||||
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||
installProcess.arguments = ["-S", setupPath, "--install=1", "--driverXML=\(driverPath)"]
|
||||
|
||||
print("执行安装命令: \(installProcess.executableURL!.path) \(installProcess.arguments!.joined(separator: " "))")
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
installProcess.standardInput = inputPipe
|
||||
@@ -217,6 +220,9 @@ actor InstallManager {
|
||||
let installProcess = Process()
|
||||
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||
installProcess.arguments = [setupPath, "--install=1", "--driverXML=\(driverPath)"]
|
||||
|
||||
print("执行重试命令: \(installProcess.executableURL!.path) \(installProcess.arguments!.joined(separator: " "))")
|
||||
|
||||
let outputPipe = Pipe()
|
||||
installProcess.standardOutput = outputPipe
|
||||
installProcess.standardError = outputPipe
|
||||
|
||||
@@ -5,79 +5,94 @@
|
||||
//
|
||||
import Foundation
|
||||
|
||||
struct Product: Identifiable {
|
||||
let id: String
|
||||
var hidden: Bool
|
||||
var displayName: String
|
||||
var sapCode: String
|
||||
var versions: [String: ProductVersion]
|
||||
var icons: [ProductIcon]
|
||||
// struct Product: Identifiable {
|
||||
// let id: String
|
||||
// var hidden: Bool
|
||||
// var displayName: String
|
||||
// var sapCode: String
|
||||
// var versions: [String: ProductVersion]
|
||||
// var icons: [ProductIcon]
|
||||
// var dependencyType: String?
|
||||
// var family: String?
|
||||
// var familyName: String?
|
||||
// var appLineage: String?
|
||||
// var type: String?
|
||||
// var categories: [String]?
|
||||
|
||||
struct ProductVersion {
|
||||
var sapCode: String
|
||||
var baseVersion: String
|
||||
var productVersion: String
|
||||
var apPlatform: String
|
||||
var dependencies: [Dependency]
|
||||
var buildGuid: String
|
||||
}
|
||||
// struct ProductVersion {
|
||||
// var sapCode: String
|
||||
// var baseVersion: String
|
||||
// var productVersion: String
|
||||
// var apPlatform: String
|
||||
// var dependencies: [Dependency]
|
||||
// var buildGuid: String
|
||||
// var packageCode: String?
|
||||
// var productCode: String?
|
||||
// var installSize: Int?
|
||||
// var esdData: EsdData?
|
||||
// }
|
||||
|
||||
struct Dependency {
|
||||
var sapCode: String
|
||||
var version: String
|
||||
}
|
||||
// struct EsdData {
|
||||
// var name: String
|
||||
// var size: Int64
|
||||
// var assetGuid: String
|
||||
// }
|
||||
|
||||
struct ProductIcon {
|
||||
let size: String
|
||||
let url: String
|
||||
// struct Dependency {
|
||||
// var sapCode: String
|
||||
// var version: String
|
||||
// var esdDirectory: String?
|
||||
// }
|
||||
|
||||
var dimension: Int {
|
||||
let components = size.split(separator: "x")
|
||||
if components.count == 2,
|
||||
let dimension = Int(components[0]) {
|
||||
return dimension
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
// struct ProductIcon {
|
||||
// let size: String
|
||||
// let url: String
|
||||
|
||||
var isValid: Bool {
|
||||
return !sapCode.isEmpty &&
|
||||
!displayName.isEmpty &&
|
||||
!versions.isEmpty
|
||||
}
|
||||
// var dimension: Int {
|
||||
// let components = size.split(separator: "x")
|
||||
// if components.count == 2,
|
||||
// let dimension = Int(components[0]) {
|
||||
// return dimension
|
||||
// }
|
||||
// return 0
|
||||
// }
|
||||
// }
|
||||
|
||||
func getBestIcon() -> ProductIcon? {
|
||||
if let icon = icons.first(where: { $0.size == "192x192" }) {
|
||||
return icon
|
||||
}
|
||||
// var isValid: Bool {
|
||||
// return !sapCode.isEmpty &&
|
||||
// !displayName.isEmpty &&
|
||||
// !versions.isEmpty
|
||||
// }
|
||||
|
||||
return icons.max(by: { $0.dimension < $1.dimension })
|
||||
}
|
||||
}
|
||||
// func getBestIcon() -> ProductIcon? {
|
||||
// if let icon = icons.first(where: { $0.size == "192x192" }) {
|
||||
// return icon
|
||||
// }
|
||||
|
||||
// return icons.max(by: { $0.dimension < $1.dimension })
|
||||
// }
|
||||
// }
|
||||
|
||||
struct ParseResult {
|
||||
var products: [String: Product]
|
||||
var products: [String: Sap]
|
||||
var cdn: String
|
||||
}
|
||||
|
||||
class XHXMLParser {
|
||||
|
||||
static func parseProductsXML(xmlData: Data, urlVersion: Int, allowedPlatforms: Set<String>) throws -> ParseResult {
|
||||
static func parseProductsXML(xmlData: Data) throws -> ParseResult {
|
||||
let xml = try XMLDocument(data: xmlData)
|
||||
|
||||
let prefix = urlVersion == 6 ? "channels/" : ""
|
||||
|
||||
guard let cdn = try xml.nodes(forXPath: "//" + prefix + "channel/cdn/secure").first?.stringValue else {
|
||||
let allowedPlatforms = Set(["osx10-64", "osx10", "macuniversal", "macarm64"])
|
||||
guard let cdn = try xml.nodes(forXPath: "//channels/channel/cdn/secure").first?.stringValue else {
|
||||
throw ParserError.missingCDN
|
||||
}
|
||||
print("parseProductsXML - cdn: \(cdn)")
|
||||
|
||||
var products: [String: Product] = [:]
|
||||
|
||||
let productNodes = try xml.nodes(forXPath: "//" + prefix + "channel/products/product")
|
||||
var products: [String: Sap] = [:]
|
||||
|
||||
let productNodes = try xml.nodes(forXPath: "//channels/channel/products/product")
|
||||
let parentMap = createParentMap(xml.rootElement())
|
||||
|
||||
for productNode in productNodes {
|
||||
guard let element = productNode as? XMLElement else { continue }
|
||||
|
||||
@@ -85,113 +100,81 @@ class XHXMLParser {
|
||||
let parentElement = parentMap[parentMap[element] ?? element]
|
||||
let hidden = (parentElement as? XMLElement)?.attribute(forName: "name")?.stringValue != "ccm"
|
||||
let displayName = try element.nodes(forXPath: "displayName").first?.stringValue ?? ""
|
||||
let productVersion = element.attribute(forName: "version")?.stringValue ?? ""
|
||||
var productVersion = element.attribute(forName: "version")?.stringValue ?? ""
|
||||
|
||||
if products[sap] == nil {
|
||||
let productIcons = try element.nodes(forXPath: "productIcons/icon").compactMap { iconNode -> Product.ProductIcon? in
|
||||
guard let iconElement = iconNode as? XMLElement,
|
||||
let size = iconElement.attribute(forName: "size")?.stringValue,
|
||||
let url = iconElement.stringValue
|
||||
else { return nil }
|
||||
return Product.ProductIcon(size: size, url: url)
|
||||
let icons = try element.nodes(forXPath: "productIcons/icon").compactMap { node -> Sap.ProductIcon? in
|
||||
guard let element = node as? XMLElement,
|
||||
let size = element.attribute(forName: "size")?.stringValue,
|
||||
let url = element.stringValue else {
|
||||
return nil
|
||||
}
|
||||
return Sap.ProductIcon(size: size, url: url)
|
||||
}
|
||||
|
||||
products[sap] = Product(
|
||||
id: sap,
|
||||
products[sap] = Sap(
|
||||
hidden: hidden,
|
||||
displayName: displayName,
|
||||
sapCode: sap,
|
||||
versions: [:],
|
||||
icons: productIcons
|
||||
icons: icons
|
||||
)
|
||||
}
|
||||
|
||||
let platforms = try element.nodes(forXPath: "platforms/platform")
|
||||
for platformNode in platforms {
|
||||
guard let platform = platformNode as? XMLElement else { continue }
|
||||
guard let platform = platformNode as? XMLElement,
|
||||
let languageSet = try platform.nodes(forXPath: "languageSet").first as? XMLElement else { continue }
|
||||
|
||||
let appPlatform = platform.attribute(forName: "id")?.stringValue ?? ""
|
||||
|
||||
guard let languageSet = try platform.nodes(forXPath: "languageSet").first as? XMLElement else { continue }
|
||||
|
||||
let baseVersion = languageSet.attribute(forName: "baseVersion")?.stringValue ?? ""
|
||||
var baseVersion = languageSet.attribute(forName: "baseVersion")?.stringValue ?? ""
|
||||
var buildGuid = languageSet.attribute(forName: "buildGuid")?.stringValue ?? ""
|
||||
let currentProductVersion = productVersion
|
||||
let appPlatform = platform.attribute(forName: "id")?.stringValue ?? ""
|
||||
let dependencies = try languageSet.nodes(forXPath: "dependencies/dependency").compactMap { node -> Sap.Versions.Dependencies? in
|
||||
guard let element = node as? XMLElement,
|
||||
let sapCode = try element.nodes(forXPath: "sapCode").first?.stringValue,
|
||||
let version = try element.nodes(forXPath: "baseVersion").first?.stringValue else {
|
||||
return nil
|
||||
}
|
||||
return Sap.Versions.Dependencies(sapCode: sapCode, version: version)
|
||||
}
|
||||
|
||||
if let existingVersion = products[sap]?.versions[productVersion],
|
||||
allowedPlatforms.contains(existingVersion.apPlatform) {
|
||||
continue
|
||||
break
|
||||
}
|
||||
|
||||
if sap == "APRO" {
|
||||
let baseVersion = productVersion
|
||||
var currentProductVersion = productVersion
|
||||
|
||||
if urlVersion == 4 || urlVersion == 5 {
|
||||
if let appVersion = try languageSet.nodes(forXPath: "nglLicensingInfo/appVersion").first?.stringValue {
|
||||
currentProductVersion = appVersion
|
||||
}
|
||||
} else if urlVersion == 6 {
|
||||
currentProductVersion = productVersion
|
||||
|
||||
let builds = try xml.nodes(forXPath: "//builds/build")
|
||||
for build in builds {
|
||||
guard let buildElement = build as? XMLElement,
|
||||
baseVersion = productVersion
|
||||
let buildNodes = try xml.nodes(forXPath: "//builds/build")
|
||||
for buildNode in buildNodes {
|
||||
guard let buildElement = buildNode as? XMLElement,
|
||||
buildElement.attribute(forName: "id")?.stringValue == sap,
|
||||
buildElement.attribute(forName: "version")?.stringValue == baseVersion else {
|
||||
continue
|
||||
}
|
||||
if let appVersion = try buildElement.nodes(forXPath: "nglLicensingInfo/appVersion").first?.stringValue {
|
||||
productVersion = appVersion
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
buildGuid = try languageSet.nodes(forXPath: "urls/manifestURL").first?.stringValue ?? buildGuid
|
||||
|
||||
if !buildGuid.isEmpty && allowedPlatforms.contains(appPlatform) {
|
||||
let version = Product.ProductVersion(
|
||||
sapCode: sap,
|
||||
baseVersion: baseVersion,
|
||||
productVersion: currentProductVersion,
|
||||
apPlatform: appPlatform,
|
||||
dependencies: [],
|
||||
buildGuid: buildGuid
|
||||
)
|
||||
|
||||
products[sap]?.versions[currentProductVersion] = version
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let dependencies = try languageSet.nodes(forXPath: "dependencies/dependency").compactMap { node -> Product.Dependency? in
|
||||
guard let element = node as? XMLElement,
|
||||
let sapCode = try element.nodes(forXPath: "sapCode").first?.stringValue,
|
||||
let version = try element.nodes(forXPath: "baseVersion").first?.stringValue
|
||||
else { return nil }
|
||||
return Product.Dependency(sapCode: sapCode, version: version)
|
||||
buildGuid = try languageSet.nodes(forXPath: "urls/manifestURL").first?.stringValue ?? ""
|
||||
}
|
||||
|
||||
if !buildGuid.isEmpty && allowedPlatforms.contains(appPlatform) {
|
||||
let version = Product.ProductVersion(
|
||||
let version = Sap.Versions(
|
||||
sapCode: sap,
|
||||
baseVersion: baseVersion,
|
||||
productVersion: currentProductVersion,
|
||||
productVersion: productVersion,
|
||||
apPlatform: appPlatform,
|
||||
dependencies: dependencies,
|
||||
buildGuid: buildGuid
|
||||
)
|
||||
|
||||
products[sap]?.versions[currentProductVersion] = version
|
||||
products[sap]?.versions[productVersion] = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let validProducts = products.filter { product in
|
||||
!product.value.hidden &&
|
||||
product.value.isValid &&
|
||||
!product.value.versions.isEmpty
|
||||
}
|
||||
|
||||
return ParseResult(products: validProducts, cdn: cdn)
|
||||
return ParseResult(products: products, cdn: cdn)
|
||||
}
|
||||
|
||||
private static func createParentMap(_ root: XMLNode?) -> [XMLNode: XMLNode] {
|
||||
@@ -219,10 +202,10 @@ enum ParserError: Error {
|
||||
}
|
||||
|
||||
extension XHXMLParser {
|
||||
static func parse(xmlString: String, urlVersion: Int, allowedPlatforms: Set<String>) throws -> ParseResult {
|
||||
static func parse(xmlString: String) throws -> ParseResult {
|
||||
guard let data = xmlString.data(using: .utf8) else {
|
||||
throw ParserError.invalidXML
|
||||
}
|
||||
return try parseProductsXML(xmlData: data, urlVersion: urlVersion, allowedPlatforms: allowedPlatforms)
|
||||
return try parseProductsXML(xmlData: data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,79 +25,54 @@ class IconCache {
|
||||
}
|
||||
|
||||
struct AppCardView: View {
|
||||
let product: Product
|
||||
let sap: Sap
|
||||
@EnvironmentObject private var networkManager: NetworkManager
|
||||
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
||||
@AppStorage("useDefaultDirectory") private var useDefaultDirectory: Bool = true
|
||||
@AppStorage("useDefaultLanguage") private var useDefaultLanguage: Bool = true
|
||||
@AppStorage("defaultLanguage") private var defaultLanguage: String = "zh_CN"
|
||||
@State private var showError: Bool = false
|
||||
@State private var errorMessage: String = ""
|
||||
@State private var showVersionPicker = false
|
||||
@State private var selectedVersion: String = ""
|
||||
@State private var iconImage: NSImage? = nil
|
||||
@State private var showLanguagePicker = false
|
||||
@State private var selectedLanguage = ""
|
||||
|
||||
private var isDownloading: Bool {
|
||||
networkManager.downloadTasks.contains { task in
|
||||
if task.sapCode == product.sapCode {
|
||||
if case .downloading = task.status {
|
||||
networkManager.downloadTasks.contains(where: isTaskDownloading)
|
||||
}
|
||||
|
||||
private func isTaskDownloading(_ task: NewDownloadTask) -> Bool {
|
||||
guard task.sapCode == sap.sapCode else { return false }
|
||||
|
||||
switch task.totalStatus {
|
||||
case .downloading, .preparing, .waiting, .retrying:
|
||||
return true
|
||||
}
|
||||
if case .preparing = task.status {
|
||||
return true
|
||||
}
|
||||
if case .waiting = task.status {
|
||||
return true
|
||||
}
|
||||
if case .retrying = task.status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var dependenciesCount: Int {
|
||||
if let firstVersion = sap.versions.first?.value {
|
||||
return firstVersion.dependencies.count
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Group {
|
||||
if let iconImage = iconImage {
|
||||
Image(nsImage: iconImage)
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Image(systemName: "app.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 64)
|
||||
.onAppear {
|
||||
loadIcon()
|
||||
}
|
||||
IconView(iconImage: iconImage, loadIcon: loadIcon)
|
||||
|
||||
Text(product.displayName)
|
||||
.font(.system(size: 16))
|
||||
.fontWeight(.bold)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("可用版本: \(product.versions.count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 20)
|
||||
ProductInfoView(sap: sap, dependenciesCount: dependenciesCount)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { showVersionPicker = true }) {
|
||||
Label(isDownloading ? "下载中" : "下载",
|
||||
systemImage: isDownloading ? "hourglass.circle.fill" : "arrow.down.circle")
|
||||
.font(.system(size: 14))
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.frame(height: 32)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(isDownloading ? .gray : .blue)
|
||||
.disabled(isDownloading)
|
||||
DownloadButton(
|
||||
isDownloading: isDownloading,
|
||||
showVersionPicker: $showVersionPicker
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 250, height: 200)
|
||||
@@ -107,9 +82,22 @@ struct AppCardView: View {
|
||||
.stroke(Color.gray.opacity(0.1), lineWidth: 2)
|
||||
)
|
||||
.sheet(isPresented: $showVersionPicker) {
|
||||
VersionPickerView(product: product) { version in
|
||||
selectedVersion = version
|
||||
VersionPickerView(sap: sap) { version in
|
||||
if useDefaultLanguage {
|
||||
startDownload(version)
|
||||
} else {
|
||||
selectedVersion = version
|
||||
showLanguagePicker = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showLanguagePicker) {
|
||||
LanguagePickerView(languages: AppStatics.supportedLanguages) { language in
|
||||
selectedLanguage = language
|
||||
showLanguagePicker = false
|
||||
if !selectedVersion.isEmpty {
|
||||
startDownloadWithLanguage(selectedVersion, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("下载错误", isPresented: $showError) {
|
||||
@@ -125,10 +113,8 @@ struct AppCardView: View {
|
||||
}
|
||||
|
||||
private func loadIcon() {
|
||||
guard let bestIcon = product.getBestIcon(),
|
||||
let iconURL = URL(string: bestIcon.url) else {
|
||||
return
|
||||
}
|
||||
if let bestIcon = sap.getBestIcon(),
|
||||
let iconURL = URL(string: bestIcon.url) {
|
||||
|
||||
if let cachedImage = IconCache.shared.getIcon(for: bestIcon.url) {
|
||||
self.iconImage = cachedImage
|
||||
@@ -154,22 +140,34 @@ struct AppCardView: View {
|
||||
self.iconImage = image
|
||||
}
|
||||
} catch {
|
||||
if let localImage = NSImage(named: product.displayName) {
|
||||
if let localImage = NSImage(named: sap.sapCode) {
|
||||
await MainActor.run {
|
||||
self.iconImage = localImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let localImage = NSImage(named: sap.sapCode) {
|
||||
self.iconImage = localImage
|
||||
}
|
||||
}
|
||||
|
||||
private func startDownload(_ version: String) {
|
||||
if useDefaultLanguage {
|
||||
startDownloadWithLanguage(version, defaultLanguage)
|
||||
} else {
|
||||
selectedVersion = version
|
||||
showLanguagePicker = true
|
||||
}
|
||||
}
|
||||
|
||||
private func startDownloadWithLanguage(_ version: String, _ language: String) {
|
||||
Task {
|
||||
do {
|
||||
let destinationURL: URL
|
||||
if useDefaultDirectory && !defaultDirectory.isEmpty {
|
||||
destinationURL = URL(fileURLWithPath: defaultDirectory)
|
||||
.appendingPathComponent("Install \(product.displayName)_\(version)-zh_CN.app")
|
||||
.appendingPathComponent("Install \(sap.displayName)_\(version)-\(language).app")
|
||||
} else {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "选择保存位置"
|
||||
@@ -183,15 +181,14 @@ struct AppCardView: View {
|
||||
return
|
||||
}
|
||||
destinationURL = selectedURL
|
||||
.appendingPathComponent("Install \(product.displayName)_\(version)-zh_CN.app")
|
||||
.appendingPathComponent("Install \(sap.displayName)_\(version)-\(language).app")
|
||||
}
|
||||
try await networkManager.startDownload(
|
||||
sapCode: product.sapCode,
|
||||
version: version,
|
||||
language: "zh_CN",
|
||||
sap: sap,
|
||||
selectedVersion: version,
|
||||
language: language,
|
||||
destinationURL: destinationURL
|
||||
)
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
@@ -202,24 +199,93 @@ struct AppCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 子视图
|
||||
private struct IconView: View {
|
||||
let iconImage: NSImage?
|
||||
let loadIcon: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let iconImage = iconImage {
|
||||
Image(nsImage: iconImage)
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Image(systemName: "app.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 64)
|
||||
.onAppear(perform: loadIcon)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProductInfoView: View {
|
||||
let sap: Sap
|
||||
let dependenciesCount: Int
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(sap.displayName)
|
||||
.font(.system(size: 16))
|
||||
.fontWeight(.bold)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("可用版本: \(sap.versions.count)")
|
||||
Text("|")
|
||||
Text("依赖包: \(dependenciesCount)")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DownloadButton: View {
|
||||
let isDownloading: Bool
|
||||
@Binding var showVersionPicker: Bool
|
||||
|
||||
var body: some View {
|
||||
Button(action: { showVersionPicker = true }) {
|
||||
Label(isDownloading ? "下载中" : "下载",
|
||||
systemImage: isDownloading ? "hourglass.circle.fill" : "arrow.down.circle")
|
||||
.font(.system(size: 14))
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.frame(height: 32)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(isDownloading ? .gray : .blue)
|
||||
.disabled(isDownloading)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AppCardView(product: Product(
|
||||
id: "PHSP",
|
||||
AppCardView(sap: Sap(
|
||||
hidden: false,
|
||||
displayName: "Photoshop",
|
||||
sapCode: "PHSP",
|
||||
versions: [
|
||||
"25.0.0": Product.ProductVersion(
|
||||
"25.0.0": Sap.Versions(
|
||||
sapCode: "PHSP",
|
||||
baseVersion: "25.0.0",
|
||||
productVersion: "25.0.0",
|
||||
apPlatform: "macuniversal",
|
||||
dependencies: [],
|
||||
dependencies: [
|
||||
Sap.Versions.Dependencies(sapCode: "ACR", version: "9.6"),
|
||||
Sap.Versions.Dependencies(sapCode: "COCM", version: "1.0"),
|
||||
Sap.Versions.Dependencies(sapCode: "COSY", version: "2.4.1")
|
||||
],
|
||||
buildGuid: ""
|
||||
)
|
||||
],
|
||||
icons: [
|
||||
Product.ProductIcon(
|
||||
Sap.ProductIcon(
|
||||
size: "192x192",
|
||||
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/PHSP/25.0.0/192x192.png"
|
||||
)
|
||||
|
||||
@@ -26,18 +26,23 @@ struct DownloadManagerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTask(_ task: DownloadTask) {
|
||||
networkManager.removeTask(taskId: task.id)
|
||||
private func removeTask(_ task: NewDownloadTask) {
|
||||
networkManager.downloadTasks.removeAll { $0.id == task.id }
|
||||
networkManager.updateDockBadge()
|
||||
}
|
||||
|
||||
private func sortTasks(_ tasks: [DownloadTask]) -> [DownloadTask] {
|
||||
private func sortTasks(_ tasks: [NewDownloadTask]) -> [NewDownloadTask] {
|
||||
switch sortOrder {
|
||||
case .addTime:
|
||||
return tasks
|
||||
case .name:
|
||||
return tasks.sorted { $0.productName < $1.productName }
|
||||
return tasks.sorted { task1, task2 in
|
||||
task1.displayName < task2.displayName
|
||||
}
|
||||
case .status:
|
||||
return tasks.sorted { $0.status.sortOrder < $1.status.sortOrder }
|
||||
return tasks.sorted { task1, task2 in
|
||||
task1.status.sortOrder < task2.status.sortOrder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +76,13 @@ struct DownloadManagerView: View {
|
||||
Button("全部暂停", action: {})
|
||||
Button("全部继续", action: {})
|
||||
Button("清理已完成", action: {
|
||||
Task {
|
||||
networkManager.clearCompletedTasks()
|
||||
networkManager.downloadTasks.removeAll { task in
|
||||
if case .completed = task.status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
networkManager.updateDockBadge()
|
||||
})
|
||||
|
||||
Button("关闭") {
|
||||
@@ -112,7 +121,7 @@ struct DownloadManagerView: View {
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.frame(width: 600, height: 400)
|
||||
.frame(width: 600, height: 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import SwiftUI
|
||||
|
||||
struct DownloadProgressView: View {
|
||||
@EnvironmentObject private var networkManager: NetworkManager
|
||||
let task: DownloadTask
|
||||
let task: NewDownloadTask
|
||||
let onCancel: () -> Void
|
||||
let onPause: () -> Void
|
||||
let onResume: () -> Void
|
||||
@@ -118,9 +118,7 @@ struct DownloadProgressView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
|
||||
Button(action: {
|
||||
networkManager.removeTask(taskId: task.id, removeFiles: true)
|
||||
}) {
|
||||
Button(action: onRemove) {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -138,7 +136,7 @@ struct DownloadProgressView: View {
|
||||
.controlSize(.small)
|
||||
.sheet(isPresented: $showInstallPrompt) {
|
||||
VStack(spacing: 20) {
|
||||
Text("是否要安装 \(task.productName)?")
|
||||
Text("是否要安装 \(task.displayName)?")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
@@ -151,7 +149,7 @@ struct DownloadProgressView: View {
|
||||
showInstallPrompt = false
|
||||
isInstalling = true
|
||||
Task {
|
||||
await networkManager.installProduct(at: task.destinationURL)
|
||||
await networkManager.installProduct(at: task.directory)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -164,7 +162,7 @@ struct DownloadProgressView: View {
|
||||
Group {
|
||||
if case .installing(let progress, let status) = networkManager.installationState {
|
||||
InstallProgressView(
|
||||
productName: task.productName,
|
||||
productName: task.displayName,
|
||||
progress: progress,
|
||||
status: status,
|
||||
onCancel: {
|
||||
@@ -175,7 +173,7 @@ struct DownloadProgressView: View {
|
||||
)
|
||||
} else if case .completed = networkManager.installationState {
|
||||
InstallProgressView(
|
||||
productName: task.productName,
|
||||
productName: task.displayName,
|
||||
progress: 1.0,
|
||||
status: "安装完成",
|
||||
onCancel: {
|
||||
@@ -185,7 +183,7 @@ struct DownloadProgressView: View {
|
||||
)
|
||||
} else if case .failed(let error) = networkManager.installationState {
|
||||
InstallProgressView(
|
||||
productName: task.productName,
|
||||
productName: task.displayName,
|
||||
progress: 0,
|
||||
status: "安装失败: \(error.localizedDescription)",
|
||||
onCancel: {
|
||||
@@ -193,13 +191,13 @@ struct DownloadProgressView: View {
|
||||
},
|
||||
onRetry: {
|
||||
Task {
|
||||
await networkManager.retryInstallation(at: task.destinationURL)
|
||||
await networkManager.retryInstallation(at: task.directory)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
InstallProgressView(
|
||||
productName: task.productName,
|
||||
productName: task.displayName,
|
||||
progress: 0,
|
||||
status: "准备安装...",
|
||||
onCancel: {
|
||||
@@ -233,69 +231,78 @@ struct DownloadProgressView: View {
|
||||
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)
|
||||
}
|
||||
|
||||
private func formatRemainingTime(totalSize: Int64, downloadedSize: Int64, speed: Double) -> String {
|
||||
guard speed > 0 else { return "" }
|
||||
|
||||
let remainingBytes = Double(totalSize - downloadedSize)
|
||||
let remainingSeconds = Int(remainingBytes / speed)
|
||||
|
||||
let minutes = remainingSeconds / 60
|
||||
let seconds = remainingSeconds % 60
|
||||
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(task.productName)
|
||||
Text(task.displayName)
|
||||
.font(.headline)
|
||||
Text(task.destinationURL.path)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(task.version)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// 下载目录
|
||||
Text(task.directory.path)
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.onTapGesture {
|
||||
openInFinder(task.destinationURL)
|
||||
}
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
NSCursor.pointingHand.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
openInFinder(task.directory)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(task.version)
|
||||
.foregroundColor(.secondary)
|
||||
// 状态标签(移到目录下方)
|
||||
statusLabel
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 6)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
// 进度信息
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Text(formatFileSize(task.downloadedSize))
|
||||
Text(formatFileSize(task.totalDownloadedSize))
|
||||
Text("/")
|
||||
Text(formatFileSize(task.totalSize))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("\(Int(task.progress * 100))%")
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if task.speed > 0 {
|
||||
Text(formatSpeed(task.speed))
|
||||
if task.totalSpeed > 0 {
|
||||
Text(formatRemainingTime(
|
||||
totalSize: task.totalSize,
|
||||
downloadedSize: task.totalDownloadedSize,
|
||||
speed: task.totalSpeed
|
||||
))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text("\(Int(task.totalProgress * 100))%")
|
||||
|
||||
if task.totalSpeed > 0 {
|
||||
Text(formatSpeed(task.totalSpeed))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
ProgressView(value: task.progress)
|
||||
ProgressView(value: task.totalProgress)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
|
||||
if task.packages.count > 0 {
|
||||
if !task.productsToDownload.isEmpty {
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -307,7 +314,7 @@ struct DownloadProgressView: View {
|
||||
HStack {
|
||||
Image(systemName: isPackageListExpanded ? "chevron.down" : "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
Text("包列表 (\(task.packages.count))")
|
||||
Text("产品和包列表")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -317,14 +324,55 @@ struct DownloadProgressView: View {
|
||||
|
||||
if isPackageListExpanded {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(task.packages.indices, id: \.self) { index in
|
||||
let package = task.packages[index]
|
||||
PackageProgressView(package: package, index: index, total: task.packages.count)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(task.productsToDownload.indices, id: \.self) { productIndex in
|
||||
let product = task.productsToDownload[productIndex]
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// 产品标题
|
||||
HStack {
|
||||
Image(systemName: "cube.box")
|
||||
.foregroundColor(.blue)
|
||||
Text("\(product.sapCode) (\(product.version))")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 显示产品下载进度
|
||||
let productProgress = product.packages.reduce(0.0) { sum, pkg in
|
||||
sum + (pkg.downloaded ? 1.0 : pkg.progress)
|
||||
} / Double(product.packages.count)
|
||||
|
||||
Text("\(Int(productProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
// 包列表
|
||||
ForEach(product.packages.indices, id: \.self) { packageIndex in
|
||||
let package = product.packages[packageIndex]
|
||||
PackageProgressView(
|
||||
package: package,
|
||||
index: packageIndex + 1,
|
||||
total: product.packages.count,
|
||||
isCurrentPackage: task.currentPackage?.id == package.id
|
||||
)
|
||||
.padding(.leading, 24)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.primary.opacity(0.03))
|
||||
.cornerRadius(6)
|
||||
|
||||
if productIndex < task.productsToDownload.count - 1 {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,23 +394,32 @@ struct DownloadProgressView: View {
|
||||
}
|
||||
|
||||
struct PackageProgressView: View {
|
||||
let package: DownloadTask.Package
|
||||
let package: Package
|
||||
let index: Int
|
||||
let total: Int
|
||||
let isCurrentPackage: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text("\(package.name)")
|
||||
// 包名和类型标签
|
||||
HStack(spacing: 4) {
|
||||
Text("\(package.fullPackageName)")
|
||||
.font(.caption)
|
||||
.foregroundColor(package.downloaded ? .secondary : .primary)
|
||||
.foregroundColor(package.downloaded ? .secondary : (isCurrentPackage ? .blue : .primary))
|
||||
|
||||
Text("(\(index + 1)/\(total))")
|
||||
Text(package.type)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(package.type == "core" ? Color.blue.opacity(0.1) : Color.secondary.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
.foregroundColor(package.type == "core" ? .blue : .secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 状态和进度
|
||||
if package.downloaded {
|
||||
Text("已完成")
|
||||
.font(.caption)
|
||||
@@ -373,7 +430,7 @@ struct PackageProgressView: View {
|
||||
Text(formatSpeed(package.speed))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
.foregroundColor(isCurrentPackage ? .blue : .secondary)
|
||||
} else {
|
||||
Text("等待中")
|
||||
.font(.caption)
|
||||
@@ -384,17 +441,21 @@ struct PackageProgressView: View {
|
||||
if !package.downloaded && package.downloadedSize > 0 {
|
||||
ProgressView(value: package.progress)
|
||||
.scaleEffect(x: 1, y: 0.5, anchor: .center)
|
||||
.tint(isCurrentPackage ? .blue : .gray)
|
||||
|
||||
HStack {
|
||||
Text(formatFileSize(package.downloadedSize))
|
||||
Text("/")
|
||||
Text(formatFileSize(package.size))
|
||||
Text(formatFileSize(package.downloadSize))
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 4)
|
||||
.background(isCurrentPackage ? Color.blue.opacity(0.05) : Color.clear)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
private func formatFileSize(_ size: Int64) -> String {
|
||||
@@ -412,115 +473,310 @@ struct PackageProgressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 在文件末尾添加预览
|
||||
#Preview("下载中") {
|
||||
DownloadProgressView(
|
||||
task: DownloadTask(
|
||||
let task = NewDownloadTask(
|
||||
sapCode: "PHSP",
|
||||
version: "25.0.0",
|
||||
version: "26.0.0",
|
||||
language: "zh_CN",
|
||||
productName: "Photoshop",
|
||||
status: .downloading(DownloadTask.DownloadStatus.DownloadInfo(
|
||||
fileName: "package1.zip",
|
||||
displayName: "Adobe Photoshop",
|
||||
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
|
||||
productsToDownload: [
|
||||
ProductsToDownload(
|
||||
sapCode: "PHSP",
|
||||
version: "26.0.0",
|
||||
buildGuid: "123",
|
||||
applicationJson: ""
|
||||
),
|
||||
ProductsToDownload(
|
||||
sapCode: "ACR",
|
||||
version: "9.6.0",
|
||||
buildGuid: "456",
|
||||
applicationJson: ""
|
||||
)
|
||||
],
|
||||
retryCount: 0,
|
||||
createAt: Date(),
|
||||
totalStatus: .downloading(DownloadStatus.DownloadInfo(
|
||||
fileName: "AdobePhotoshop26-Core.zip",
|
||||
currentPackageIndex: 0,
|
||||
totalPackages: 3,
|
||||
totalPackages: 8,
|
||||
startTime: Date(),
|
||||
estimatedTimeRemaining: nil
|
||||
)),
|
||||
progress: 0.3,
|
||||
downloadedSize: 100_000_000,
|
||||
totalSize: 300_000_000,
|
||||
speed: 1_000_000,
|
||||
currentFileName: "package1.zip",
|
||||
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
|
||||
totalProgress: 0.35,
|
||||
totalDownloadedSize: 738_197_504,
|
||||
totalSize: 2_147_483_648,
|
||||
totalSpeed: 1_048_576
|
||||
)
|
||||
|
||||
// 添加一些包
|
||||
task.productsToDownload[0].packages = [
|
||||
Package(
|
||||
type: "core",
|
||||
fullPackageName: "AdobePhotoshop26-Core.zip",
|
||||
downloadSize: 1_073_741_824,
|
||||
downloadURL: "/products/PHSP/AdobePhotoshop26-Core.zip"
|
||||
),
|
||||
Package(
|
||||
type: "non-core",
|
||||
fullPackageName: "AdobePhotoshop26-Support.zip",
|
||||
downloadSize: 536_870_912,
|
||||
downloadURL: "/products/PHSP/AdobePhotoshop26-Support.zip"
|
||||
)
|
||||
]
|
||||
|
||||
task.productsToDownload[1].packages = [
|
||||
Package(
|
||||
type: "core",
|
||||
fullPackageName: "ACR-Core.zip",
|
||||
downloadSize: 268_435_456,
|
||||
downloadURL: "/products/ACR/ACR-Core.zip"
|
||||
)
|
||||
]
|
||||
|
||||
// 设置当前包和进度
|
||||
task.currentPackage = task.productsToDownload[0].packages[0]
|
||||
task.currentPackage?.downloadedSize = 738_197_504
|
||||
task.currentPackage?.progress = 0.35
|
||||
task.currentPackage?.speed = 1_048_576
|
||||
task.currentPackage?.status = .downloading
|
||||
|
||||
return DownloadProgressView(
|
||||
task: task,
|
||||
onCancel: {},
|
||||
onPause: {},
|
||||
onResume: {},
|
||||
onRetry: {},
|
||||
onRemove: {}
|
||||
)
|
||||
.environmentObject(NetworkManager())
|
||||
.padding()
|
||||
.frame(width: 600)
|
||||
// 添加一个修饰器来模拟用户点击展开包列表
|
||||
.onAppear {
|
||||
// 注意:这种方式在预览中可能不会立即生效,因为 @State 属性在预览中的行为可能不太一致
|
||||
// 作为替代方案,我们可以创建一个新的初始化方法来设置初始状态
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("准备下载") {
|
||||
// 添加一个新的预览,默认展开包列表
|
||||
#Preview("下载中(展开包列表)") {
|
||||
struct PreviewWrapper: View {
|
||||
@State private var isExpanded = true
|
||||
let task: NewDownloadTask
|
||||
|
||||
var body: some View {
|
||||
DownloadProgressView(
|
||||
task: DownloadTask(
|
||||
task: task,
|
||||
onCancel: {},
|
||||
onPause: {},
|
||||
onResume: {},
|
||||
onRetry: {},
|
||||
onRemove: {}
|
||||
)
|
||||
.environmentObject(NetworkManager())
|
||||
.padding()
|
||||
.frame(width: 600)
|
||||
}
|
||||
}
|
||||
|
||||
let task = NewDownloadTask(
|
||||
sapCode: "PHSP",
|
||||
version: "25.0.0",
|
||||
version: "26.0.0",
|
||||
language: "zh_CN",
|
||||
productName: "Photoshop",
|
||||
status: .preparing(DownloadTask.DownloadStatus.PrepareInfo(
|
||||
displayName: "Adobe Photoshop",
|
||||
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
|
||||
productsToDownload: [
|
||||
ProductsToDownload(
|
||||
sapCode: "PHSP",
|
||||
version: "26.0.0",
|
||||
buildGuid: "123",
|
||||
applicationJson: ""
|
||||
),
|
||||
ProductsToDownload(
|
||||
sapCode: "ACR",
|
||||
version: "9.6.0",
|
||||
buildGuid: "456",
|
||||
applicationJson: ""
|
||||
)
|
||||
],
|
||||
retryCount: 0,
|
||||
createAt: Date(),
|
||||
totalStatus: .downloading(DownloadStatus.DownloadInfo(
|
||||
fileName: "AdobePhotoshop26-Core.zip",
|
||||
currentPackageIndex: 0,
|
||||
totalPackages: 8,
|
||||
startTime: Date(),
|
||||
estimatedTimeRemaining: nil
|
||||
)),
|
||||
totalProgress: 0.35,
|
||||
totalDownloadedSize: 738_197_504,
|
||||
totalSize: 2_147_483_648,
|
||||
totalSpeed: 1_048_576
|
||||
)
|
||||
|
||||
// 添加包
|
||||
task.productsToDownload[0].packages = [
|
||||
Package(
|
||||
type: "core",
|
||||
fullPackageName: "AdobePhotoshop26-Core.zip",
|
||||
downloadSize: 1_073_741_824,
|
||||
downloadURL: "/products/PHSP/AdobePhotoshop26-Core.zip"
|
||||
),
|
||||
Package(
|
||||
type: "non-core",
|
||||
fullPackageName: "AdobePhotoshop26-Support.zip",
|
||||
downloadSize: 536_870_912,
|
||||
downloadURL: "/products/PHSP/AdobePhotoshop26-Support.zip"
|
||||
)
|
||||
]
|
||||
|
||||
task.productsToDownload[1].packages = [
|
||||
Package(
|
||||
type: "core",
|
||||
fullPackageName: "ACR-Core.zip",
|
||||
downloadSize: 268_435_456,
|
||||
downloadURL: "/products/ACR/ACR-Core.zip"
|
||||
)
|
||||
]
|
||||
|
||||
// 设置当前包和进度
|
||||
task.currentPackage = task.productsToDownload[0].packages[0]
|
||||
task.currentPackage?.downloadedSize = 738_197_504
|
||||
task.currentPackage?.progress = 0.35
|
||||
task.currentPackage?.speed = 1_048_576
|
||||
task.currentPackage?.status = .downloading
|
||||
|
||||
return PreviewWrapper(task: task)
|
||||
}
|
||||
|
||||
#Preview("准备中") {
|
||||
let task = NewDownloadTask(
|
||||
sapCode: "PHSP",
|
||||
version: "26.0.0",
|
||||
language: "zh_CN",
|
||||
displayName: "Adobe Photoshop",
|
||||
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
|
||||
productsToDownload: [],
|
||||
retryCount: 0,
|
||||
createAt: Date(),
|
||||
totalStatus: .preparing(DownloadStatus.PrepareInfo(
|
||||
message: "正在准备下载...",
|
||||
timestamp: Date(),
|
||||
stage: .initializing
|
||||
)),
|
||||
progress: 0.0,
|
||||
downloadedSize: 0,
|
||||
totalSize: 300_000_000,
|
||||
speed: 0,
|
||||
currentFileName: "",
|
||||
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
|
||||
),
|
||||
totalProgress: 0,
|
||||
totalDownloadedSize: 0,
|
||||
totalSize: 2_147_483_648,
|
||||
totalSpeed: 0
|
||||
)
|
||||
|
||||
return DownloadProgressView(
|
||||
task: task,
|
||||
onCancel: {},
|
||||
onPause: {},
|
||||
onResume: {},
|
||||
onRetry: {},
|
||||
onRemove: {}
|
||||
)
|
||||
.environmentObject(NetworkManager())
|
||||
.padding()
|
||||
.frame(width: 600)
|
||||
}
|
||||
|
||||
#Preview("下载完成") {
|
||||
DownloadProgressView(
|
||||
task: DownloadTask(
|
||||
#Preview("已完成") {
|
||||
let task = NewDownloadTask(
|
||||
sapCode: "PHSP",
|
||||
version: "25.0.0",
|
||||
version: "26.0.0",
|
||||
language: "zh_CN",
|
||||
productName: "Photoshop",
|
||||
status: .completed(DownloadTask.DownloadStatus.CompletionInfo(
|
||||
displayName: "Adobe Photoshop",
|
||||
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
|
||||
productsToDownload: [
|
||||
ProductsToDownload(
|
||||
sapCode: "PHSP",
|
||||
version: "26.0.0",
|
||||
buildGuid: "123",
|
||||
applicationJson: ""
|
||||
)
|
||||
],
|
||||
retryCount: 0,
|
||||
createAt: Date().addingTimeInterval(-3600),
|
||||
totalStatus: .completed(DownloadStatus.CompletionInfo(
|
||||
timestamp: Date(),
|
||||
totalTime: 120,
|
||||
totalSize: 300_000_000
|
||||
totalTime: 3600,
|
||||
totalSize: 2_147_483_648
|
||||
)),
|
||||
progress: 1.0,
|
||||
downloadedSize: 300_000_000,
|
||||
totalSize: 300_000_000,
|
||||
speed: 0,
|
||||
currentFileName: "",
|
||||
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
|
||||
),
|
||||
totalProgress: 1.0,
|
||||
totalDownloadedSize: 2_147_483_648,
|
||||
totalSize: 2_147_483_648,
|
||||
totalSpeed: 0
|
||||
)
|
||||
|
||||
// 添加已完成的包
|
||||
task.productsToDownload[0].packages = [
|
||||
Package(
|
||||
type: "core",
|
||||
fullPackageName: "AdobePhotoshop26-Core.zip",
|
||||
downloadSize: 1_073_741_824,
|
||||
downloadURL: "/products/PHSP/AdobePhotoshop26-Core.zip"
|
||||
)
|
||||
]
|
||||
task.productsToDownload[0].packages[0].downloaded = true
|
||||
task.productsToDownload[0].packages[0].progress = 1.0
|
||||
task.productsToDownload[0].packages[0].status = .completed
|
||||
|
||||
return DownloadProgressView(
|
||||
task: task,
|
||||
onCancel: {},
|
||||
onPause: {},
|
||||
onResume: {},
|
||||
onRetry: {},
|
||||
onRemove: {}
|
||||
)
|
||||
.environmentObject(NetworkManager())
|
||||
.padding()
|
||||
.frame(width: 600)
|
||||
}
|
||||
|
||||
#Preview("深色模式") {
|
||||
DownloadProgressView(
|
||||
task: DownloadTask(
|
||||
#Preview("失败") {
|
||||
let task = NewDownloadTask(
|
||||
sapCode: "PHSP",
|
||||
version: "25.0.0",
|
||||
version: "26.0.0",
|
||||
language: "zh_CN",
|
||||
productName: "Photoshop",
|
||||
status: .downloading(DownloadTask.DownloadStatus.DownloadInfo(
|
||||
fileName: "package1.zip",
|
||||
currentPackageIndex: 0,
|
||||
totalPackages: 3,
|
||||
startTime: Date(),
|
||||
estimatedTimeRemaining: nil
|
||||
displayName: "Adobe Photoshop",
|
||||
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
|
||||
productsToDownload: [
|
||||
ProductsToDownload(
|
||||
sapCode: "PHSP",
|
||||
version: "26.0.0",
|
||||
buildGuid: "123",
|
||||
applicationJson: ""
|
||||
)
|
||||
],
|
||||
retryCount: 3,
|
||||
createAt: Date(),
|
||||
totalStatus: .failed(DownloadStatus.FailureInfo(
|
||||
message: "网络连接已断开",
|
||||
error: NetworkError.noConnection,
|
||||
timestamp: Date(),
|
||||
recoverable: true
|
||||
)),
|
||||
progress: 0.3,
|
||||
downloadedSize: 100_000_000,
|
||||
totalSize: 300_000_000,
|
||||
speed: 1_000_000,
|
||||
currentFileName: "package1.zip",
|
||||
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
|
||||
),
|
||||
totalProgress: 0.5,
|
||||
totalDownloadedSize: 1_073_741_824,
|
||||
totalSize: 2_147_483_648,
|
||||
totalSpeed: 0
|
||||
)
|
||||
|
||||
return DownloadProgressView(
|
||||
task: task,
|
||||
onCancel: {},
|
||||
onPause: {},
|
||||
onResume: {},
|
||||
onRetry: {},
|
||||
onRemove: {}
|
||||
)
|
||||
.preferredColorScheme(.dark)
|
||||
.environmentObject(NetworkManager())
|
||||
.padding()
|
||||
.frame(width: 600)
|
||||
}
|
||||
|
||||
@@ -6,72 +6,175 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VersionPickerView: View {
|
||||
let product: Product
|
||||
let sap: Sap
|
||||
let onVersionSelected: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
||||
@AppStorage("useDefaultDirectory") private var useDefaultDirectory: Bool = true
|
||||
@AppStorage("defaultLanguage") private var defaultLanguage: String = "zh_CN"
|
||||
@State private var expandedVersions: Set<String> = []
|
||||
|
||||
private var sortedVersions: [(version: String, platform: String, exists: Bool)] {
|
||||
product.versions
|
||||
.map { version -> (version: String, platform: String, exists: Bool) in
|
||||
let installerPath: String
|
||||
let appName = "Install \(product.sapCode)_\(version.key)-\(defaultLanguage)-\(version.value.apPlatform).app"
|
||||
private func getInstallerPath(version: String, platform: String) -> String {
|
||||
let appName = "Install \(sap.sapCode)_\(version)-\(defaultLanguage)-\(platform).app"
|
||||
if useDefaultDirectory && !defaultDirectory.isEmpty {
|
||||
installerPath = (defaultDirectory as NSString).appendingPathComponent(appName)
|
||||
return (defaultDirectory as NSString).appendingPathComponent(appName)
|
||||
} else {
|
||||
let downloadsPath = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.path ?? ""
|
||||
installerPath = (downloadsPath as NSString).appendingPathComponent(appName)
|
||||
return (downloadsPath as NSString).appendingPathComponent(appName)
|
||||
}
|
||||
}
|
||||
|
||||
private func mapVersion(_ version: (key: String, value: Sap.Versions)) -> (version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies]) {
|
||||
let installerPath = getInstallerPath(version: version.key, platform: version.value.apPlatform)
|
||||
return (
|
||||
version: version.key,
|
||||
platform: version.value.apPlatform,
|
||||
exists: FileManager.default.fileExists(atPath: installerPath)
|
||||
exists: FileManager.default.fileExists(atPath: installerPath),
|
||||
dependencies: version.value.dependencies
|
||||
)
|
||||
}
|
||||
|
||||
private var sortedVersions: [(version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies])] {
|
||||
sap.versions
|
||||
.map(mapVersion)
|
||||
.sorted { $0.version.compare($1.version, options: .numeric) == .orderedDescending }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HeaderView(
|
||||
displayName: sap.displayName,
|
||||
onDismiss: { dismiss() }
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
VersionListView(
|
||||
versions: sortedVersions,
|
||||
expandedVersions: $expandedVersions,
|
||||
onVersionSelected: onVersionSelected,
|
||||
onDismiss: { dismiss() }
|
||||
)
|
||||
}
|
||||
.frame(width: 360, height: 400)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 子视图
|
||||
private struct HeaderView: View {
|
||||
let displayName: String
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(product.displayName)
|
||||
Text(displayName)
|
||||
.font(.headline)
|
||||
Text("选择版本")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("取消") {
|
||||
dismiss()
|
||||
}
|
||||
Button("取消", action: onDismiss)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
private struct VersionListView: View {
|
||||
let versions: [(version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies])]
|
||||
@Binding var expandedVersions: Set<String>
|
||||
let onVersionSelected: (String) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(sortedVersions, id: \.version) { version in
|
||||
Button(action: {
|
||||
ForEach(versions, id: \.version) { version in
|
||||
VersionRowView(
|
||||
version: version,
|
||||
isExpanded: expandedVersions.contains(version.version),
|
||||
onToggleExpand: {
|
||||
withAnimation {
|
||||
if expandedVersions.contains(version.version) {
|
||||
expandedVersions.remove(version.version)
|
||||
} else {
|
||||
expandedVersions.insert(version.version)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelect: {
|
||||
onVersionSelected(version.version)
|
||||
dismiss()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VersionRowView: View {
|
||||
let version: (version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies])
|
||||
let isExpanded: Bool
|
||||
let onToggleExpand: () -> Void
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button(action: {
|
||||
if version.dependencies.isEmpty {
|
||||
onSelect()
|
||||
} else {
|
||||
onToggleExpand()
|
||||
}
|
||||
}) {
|
||||
VersionRowContent(
|
||||
version: version.version,
|
||||
platform: version.platform,
|
||||
exists: version.exists,
|
||||
hasDependencies: !version.dependencies.isEmpty,
|
||||
isExpanded: isExpanded
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color(NSColor.controlBackgroundColor).opacity(0.01))
|
||||
|
||||
if isExpanded {
|
||||
DependenciesView(
|
||||
dependencies: version.dependencies,
|
||||
onSelect: onSelect
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VersionRowContent: View {
|
||||
let version: String
|
||||
let platform: String
|
||||
let exists: Bool
|
||||
let hasDependencies: Bool
|
||||
let isExpanded: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(version.version)
|
||||
Text(version)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: getPlatformIcon(version.platform))
|
||||
Image(systemName: getPlatformIcon(platform))
|
||||
.foregroundColor(.secondary)
|
||||
Text(getPlatformDisplayName(version.platform))
|
||||
Text(getPlatformDisplayName(platform))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -79,7 +182,7 @@ struct VersionPickerView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if version.exists {
|
||||
if exists {
|
||||
Text("已下载")
|
||||
.font(.caption)
|
||||
.foregroundColor(.green)
|
||||
@@ -89,30 +192,56 @@ struct VersionPickerView: View {
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
if hasDependencies {
|
||||
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color(NSColor.controlBackgroundColor).opacity(0.01))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
Divider()
|
||||
}
|
||||
|
||||
private struct DependenciesView: View {
|
||||
let dependencies: [Sap.Versions.Dependencies]
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("依赖包:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
|
||||
ForEach(dependencies, id: \.sapCode) { dependency in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "cube.box")
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 16)
|
||||
Text("\(dependency.sapCode) (\(dependency.version))")
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.frame(width: 360, height: 400)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func getPlatformDisplayName(_ platform: String) -> String {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("下载此版本", action: onSelect)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(NSColor.controlBackgroundColor).opacity(0.05))
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数移到外部
|
||||
private func getPlatformDisplayName(_ platform: String) -> String {
|
||||
switch platform {
|
||||
case "macuniversal":
|
||||
return "Universal (Intel/Apple Silicon)"
|
||||
@@ -123,9 +252,9 @@ struct VersionPickerView: View {
|
||||
default:
|
||||
return platform
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getPlatformIcon(_ platform: String) -> String {
|
||||
private func getPlatformIcon(_ platform: String) -> String {
|
||||
switch platform {
|
||||
case "macuniversal":
|
||||
return "cpu"
|
||||
@@ -136,44 +265,56 @@ struct VersionPickerView: View {
|
||||
default:
|
||||
return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VersionPickerView(
|
||||
product: Product(
|
||||
id: "PHSP",
|
||||
sap: Sap(
|
||||
hidden: false,
|
||||
displayName: "Photoshop",
|
||||
sapCode: "PHSP",
|
||||
versions: [
|
||||
"25.0.0": Product.ProductVersion(
|
||||
"26.0.0": Sap.Versions(
|
||||
sapCode: "PHSP",
|
||||
baseVersion: "26.0.0",
|
||||
productVersion: "26.0.0",
|
||||
apPlatform: "macuniversal",
|
||||
dependencies: [
|
||||
Sap.Versions.Dependencies(sapCode: "ACR", version: "9.6"),
|
||||
Sap.Versions.Dependencies(sapCode: "COCM", version: "1.0"),
|
||||
Sap.Versions.Dependencies(sapCode: "COSY", version: "2.4.1")
|
||||
],
|
||||
buildGuid: "b382ef03-c44a-4fd4-a9a1-3119ab0474b4"
|
||||
),
|
||||
"25.0.0": Sap.Versions(
|
||||
sapCode: "PHSP",
|
||||
baseVersion: "25.0.0",
|
||||
productVersion: "25.0.0",
|
||||
apPlatform: "macuniversal",
|
||||
dependencies: [],
|
||||
buildGuid: ""
|
||||
dependencies: [
|
||||
Sap.Versions.Dependencies(sapCode: "ACR", version: "9.5"),
|
||||
Sap.Versions.Dependencies(sapCode: "COCM", version: "1.0")
|
||||
],
|
||||
buildGuid: "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6"
|
||||
),
|
||||
"24.6.0": Product.ProductVersion(
|
||||
"24.0.0": Sap.Versions(
|
||||
sapCode: "PHSP",
|
||||
baseVersion: "24.6.0",
|
||||
productVersion: "24.6.0",
|
||||
baseVersion: "24.0.0",
|
||||
productVersion: "24.0.0",
|
||||
apPlatform: "macuniversal",
|
||||
dependencies: [],
|
||||
buildGuid: ""
|
||||
),
|
||||
"24.5.0": Product.ProductVersion(
|
||||
sapCode: "PHSP",
|
||||
baseVersion: "24.5.0",
|
||||
productVersion: "24.5.0",
|
||||
apPlatform: "macuniversal",
|
||||
dependencies: [],
|
||||
buildGuid: ""
|
||||
buildGuid: "q1w2e3r4-t5y6-u7i8-o9p0-a1s2d3f4g5h6"
|
||||
)
|
||||
],
|
||||
icons: []
|
||||
icons: [
|
||||
Sap.ProductIcon(
|
||||
size: "192x192",
|
||||
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/PHSP/26.0.0/192x192.png"
|
||||
)
|
||||
]
|
||||
),
|
||||
onVersionSelected: { _ in }
|
||||
onVersionSelected: { version in
|
||||
print("Selected version: \(version)")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"(%lld/%lld)" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "(%1$lld/%2$lld)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/" : {
|
||||
|
||||
},
|
||||
@@ -24,6 +14,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ (%@)" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@ (%2$@)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ 安装失败" : {
|
||||
|
||||
},
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"|" : {
|
||||
|
||||
},
|
||||
"About" : {
|
||||
"localizations" : {
|
||||
@@ -89,6 +92,9 @@
|
||||
},
|
||||
"下载已取消" : {
|
||||
"comment" : "Download cancelled"
|
||||
},
|
||||
"下载此版本" : {
|
||||
|
||||
},
|
||||
"下载管理" : {
|
||||
|
||||
@@ -98,12 +104,21 @@
|
||||
},
|
||||
"下载错误" : {
|
||||
|
||||
},
|
||||
"产品和包列表" : {
|
||||
|
||||
},
|
||||
"使用默认目录" : {
|
||||
|
||||
},
|
||||
"使用默认语言" : {
|
||||
|
||||
},
|
||||
"依赖包:" : {
|
||||
|
||||
},
|
||||
"依赖包: %lld" : {
|
||||
|
||||
},
|
||||
"全部暂停" : {
|
||||
|
||||
@@ -119,9 +134,6 @@
|
||||
},
|
||||
"加载失败" : {
|
||||
|
||||
},
|
||||
"包列表 (%lld)" : {
|
||||
|
||||
},
|
||||
"取消" : {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user