mirror of
https://github.com/X1a0He/Adobe-Downloader.git
synced 2025-11-25 03:14:57 +08:00
feat: 优化版本选择和自定义下载界面
This commit is contained in:
@@ -61,50 +61,6 @@ class NetworkManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
func startDownload(productId: String, selectedVersion: String, language: String, destinationURL: URL) async throws {
|
||||
// 从 globalCcmResult 中获取 productId 对应的 ProductInfo
|
||||
guard let productInfo = globalCcmResult.products.first(where: { $0.id == productId && $0.version == selectedVersion }) else {
|
||||
throw NetworkError.productNotFound
|
||||
}
|
||||
|
||||
let task = NewDownloadTask(
|
||||
productId: productInfo.id,
|
||||
productVersion: selectedVersion,
|
||||
language: language,
|
||||
displayName: productInfo.displayName,
|
||||
directory: destinationURL,
|
||||
dependenciesToDownload: [],
|
||||
createAt: Date(),
|
||||
totalStatus: .preparing(DownloadStatus.PrepareInfo(
|
||||
message: "正在准备下载...",
|
||||
timestamp: Date(),
|
||||
stage: .initializing
|
||||
)),
|
||||
totalProgress: 0,
|
||||
totalDownloadedSize: 0,
|
||||
totalSize: 0,
|
||||
totalSpeed: 0,
|
||||
platform: globalProducts.first(where: { $0.id == productId })?.platforms.first?.id ?? "unknown")
|
||||
|
||||
downloadTasks.append(task)
|
||||
updateDockBadge()
|
||||
await saveTask(task)
|
||||
|
||||
do {
|
||||
try await globalNewDownloadUtils.handleDownload(task: task, productInfo: productInfo, allowedPlatform: StorageData.shared.allowedPlatform)
|
||||
} catch {
|
||||
task.setStatus(.failed(DownloadStatus.FailureInfo(
|
||||
message: error.localizedDescription,
|
||||
error: error,
|
||||
timestamp: Date(),
|
||||
recoverable: true
|
||||
)))
|
||||
await saveTask(task)
|
||||
await MainActor.run {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startCustomDownload(productId: String, selectedVersion: String, language: String, destinationURL: URL, customDependencies: [DependenciesToDownload]) async throws {
|
||||
guard let productInfo = globalCcmResult.products.first(where: { $0.id == productId && $0.version == selectedVersion }) else {
|
||||
|
||||
@@ -210,230 +210,6 @@ class NewDownloadUtils {
|
||||
}
|
||||
}
|
||||
|
||||
func handleDownload(task: NewDownloadTask, productInfo: Product, allowedPlatform: [String]) async throws {
|
||||
if productInfo.id == "APRO" {
|
||||
try await downloadAPRO(task: task, productInfo: productInfo)
|
||||
return
|
||||
}
|
||||
|
||||
var dependenciesToDownload: [DependenciesToDownload] = []
|
||||
let firstPlatform = productInfo.platforms.first
|
||||
let buildGuid = firstPlatform?.languageSet.first?.buildGuid ?? ""
|
||||
|
||||
dependenciesToDownload.append(DependenciesToDownload(sapCode: productInfo.id, version: productInfo.version, buildGuid: buildGuid))
|
||||
|
||||
let dependencies = firstPlatform?.languageSet.first?.dependencies
|
||||
|
||||
if let dependencies = dependencies {
|
||||
for dependency in dependencies {
|
||||
dependenciesToDownload.append(DependenciesToDownload(sapCode: dependency.sapCode, version: dependency.productVersion, buildGuid: dependency.buildGuid))
|
||||
}
|
||||
}
|
||||
|
||||
for dependencyToDownload in dependenciesToDownload {
|
||||
await MainActor.run {
|
||||
task.setStatus(.preparing(DownloadStatus.PrepareInfo(
|
||||
message: String(localized: "正在处理 \(dependencyToDownload.sapCode) 的包信息..."),
|
||||
timestamp: Date(),
|
||||
stage: .fetchingInfo
|
||||
)))
|
||||
}
|
||||
|
||||
let jsonString = try await getApplicationInfo(buildGuid: dependencyToDownload.buildGuid)
|
||||
let productDir = task.directory.appendingPathComponent("\(dependencyToDownload.sapCode)")
|
||||
if !FileManager.default.fileExists(atPath: productDir.path) {
|
||||
try FileManager.default.createDirectory(at: productDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
var processedJsonString = jsonString
|
||||
if dependencyToDownload.sapCode == productInfo.id {
|
||||
if let jsonData = jsonString.data(using: .utf8),
|
||||
var appInfo = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] {
|
||||
|
||||
if var modules = appInfo["Modules"] as? [String: Any] {
|
||||
modules["Module"] = [] as [[String: Any]]
|
||||
appInfo["Modules"] = modules
|
||||
}
|
||||
|
||||
if var packages = appInfo["Packages"] as? [String: Any],
|
||||
let packageArray = packages["Package"] as? [[String: Any]] {
|
||||
|
||||
var filteredPackages: [[String: Any]] = []
|
||||
|
||||
for package in packageArray {
|
||||
var shouldKeep = false
|
||||
let packageType = package["Type"] as? String ?? "non-core"
|
||||
let isCore = packageType == "core"
|
||||
|
||||
let condition = package["Condition"] as? String ?? ""
|
||||
let targetArchitecture = StorageData.shared.downloadAppleSilicon ? "arm64" : "x64"
|
||||
if isCore {
|
||||
if condition.isEmpty {
|
||||
shouldKeep = true
|
||||
} else {
|
||||
if condition.contains("[OSArchitecture]==\(targetArchitecture)") {
|
||||
shouldKeep = true
|
||||
}
|
||||
if condition.contains("[installLanguage]==\(task.language)") || task.language == "ALL" {
|
||||
shouldKeep = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldKeep = (condition.contains("[installLanguage]==\(task.language)") || task.language == "ALL")
|
||||
}
|
||||
|
||||
if shouldKeep {
|
||||
filteredPackages.append(package)
|
||||
}
|
||||
}
|
||||
|
||||
packages["Package"] = filteredPackages
|
||||
appInfo["Packages"] = packages
|
||||
}
|
||||
|
||||
if let processedData = try? JSONSerialization.data(withJSONObject: appInfo, options: .prettyPrinted),
|
||||
let processedString = String(data: processedData, encoding: .utf8) {
|
||||
processedJsonString = processedString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let jsonURL = productDir.appendingPathComponent("application.json")
|
||||
try processedJsonString.write(to: jsonURL, atomically: true, encoding: String.Encoding.utf8)
|
||||
|
||||
guard let jsonData = processedJsonString.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("无法解析产品信息")
|
||||
}
|
||||
// Module已在2.1.0版本中由自定义下载处理,只测试了PS的Remove Tool components
|
||||
|
||||
var corePackageCount = 0
|
||||
var nonCorePackageCount = 0
|
||||
|
||||
/*
|
||||
这里是对包的过滤,我的规则是,一般产品分为主产品和依赖产品
|
||||
主产品逻辑
|
||||
1. 如果是 non-core,默认不下载
|
||||
2. 如果是 core,就继续判断
|
||||
a. 如果没有Condition,就下载
|
||||
b. 如果有Condition
|
||||
i. 先判断架构是否一致,只下载对应的架构
|
||||
ii. 判断是否为目标语言
|
||||
|
||||
依赖产品逻辑
|
||||
1. 因为依赖产品没有core和non-core之分的
|
||||
2. 如果没有Condition,就下载
|
||||
3. 如果有Condition,目前分析到的基本上是语言之分
|
||||
i. 判断是否为目标语言
|
||||
*/
|
||||
|
||||
for package in packageArray {
|
||||
guard let downloadURL = package["Path"] as? String, !downloadURL.isEmpty else { continue }
|
||||
|
||||
let packageVersion: String = package["PackageVersion"] as? String ?? ""
|
||||
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).zip"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
let downloadSize: Int64
|
||||
switch package["DownloadSize"] {
|
||||
case let sizeNumber as NSNumber:
|
||||
downloadSize = sizeNumber.int64Value
|
||||
case let sizeString as String:
|
||||
guard let parsedSize = Int64(sizeString) else { continue }
|
||||
downloadSize = parsedSize
|
||||
case let sizeInt as Int:
|
||||
downloadSize = Int64(sizeInt)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
let packageType = package["Type"] as? String ?? "non-core"
|
||||
let isCore = packageType == "core"
|
||||
let installLanguage = "[installLanguage]==\(task.language)"
|
||||
let condition = package["Condition"] as? String ?? ""
|
||||
|
||||
var shouldDownload = false
|
||||
|
||||
if dependencyToDownload.sapCode == productInfo.id {
|
||||
if isCore {
|
||||
let targetArchitecture = StorageData.shared.downloadAppleSilicon ? "arm64" : "x64"
|
||||
shouldDownload = condition.isEmpty ||
|
||||
condition.contains("[OSArchitecture]==\(targetArchitecture)") ||
|
||||
condition.contains(installLanguage) || task.language == "ALL"
|
||||
} else {
|
||||
shouldDownload = condition.contains(installLanguage) || task.language == "ALL"
|
||||
}
|
||||
} else {
|
||||
shouldDownload = condition.isEmpty ||
|
||||
(condition.contains("[OSVersion]") && checkOSVersionCondition(condition)) ||
|
||||
condition.contains(installLanguage) || task.language == "ALL"
|
||||
}
|
||||
|
||||
isCore ? (corePackageCount += 1) : (nonCorePackageCount += 1)
|
||||
|
||||
if shouldDownload {
|
||||
dependencyToDownload.packages.append(Package(
|
||||
type: packageType,
|
||||
fullPackageName: fullPackageName,
|
||||
downloadSize: downloadSize,
|
||||
downloadURL: downloadURL,
|
||||
packageVersion: packageVersion,
|
||||
condition: condition,
|
||||
isRequired: dependencyToDownload.sapCode == productInfo.id
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func checkOSVersionCondition(_ condition: String) -> Bool {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let currentVersion = Double("\(osVersion.majorVersion).\(osVersion.minorVersion)") ?? 0.0
|
||||
|
||||
let versionPattern = #"\[OSVersion\](>=|<=|<|>|==)([\d.]+)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: versionPattern) else { return false }
|
||||
|
||||
let nsRange = NSRange(condition.startIndex..<condition.endIndex, in: condition)
|
||||
let matches = regex.matches(in: condition, range: nsRange)
|
||||
|
||||
for match in matches {
|
||||
guard match.numberOfRanges >= 3,
|
||||
let operatorRange = Range(match.range(at: 1), in: condition),
|
||||
let versionRange = Range(match.range(at: 2), in: condition),
|
||||
let requiredVersion = Double(condition[versionRange]) else { continue }
|
||||
|
||||
let operatorSymbol = String(condition[operatorRange])
|
||||
if !compareVersions(current: currentVersion, required: requiredVersion, operator: operatorSymbol) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return !matches.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
let finalProducts = dependenciesToDownload
|
||||
let totalSize = finalProducts.reduce(0) { productSum, product in
|
||||
productSum + product.packages.reduce(0) { packageSum, pkg in
|
||||
packageSum + (pkg.downloadSize > 0 ? pkg.downloadSize : 0)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
task.dependenciesToDownload = finalProducts
|
||||
task.totalSize = totalSize
|
||||
}
|
||||
|
||||
await startDownloadProcess(task: task)
|
||||
}
|
||||
|
||||
func handleCustomDownload(task: NewDownloadTask, customDependencies: [DependenciesToDownload]) async throws {
|
||||
await MainActor.run {
|
||||
task.setStatus(.preparing(DownloadStatus.PrepareInfo(
|
||||
|
||||
@@ -228,16 +228,10 @@ final class AppCardViewModel: ObservableObject {
|
||||
showExistingFileAlert = true
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
let destinationURL = try await getDestinationURL(version: version, language: language)
|
||||
try await globalNetworkManager.startDownload(
|
||||
productId: uniqueProduct.id,
|
||||
selectedVersion: version,
|
||||
language: language,
|
||||
destinationURL: destinationURL
|
||||
)
|
||||
} catch {
|
||||
handleError(error)
|
||||
await MainActor.run {
|
||||
selectedVersion = version
|
||||
selectedLanguage = language
|
||||
showVersionPicker = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,7 +514,7 @@ private struct SheetModifier: ViewModifier {
|
||||
content
|
||||
.sheet(isPresented: $viewModel.showVersionPicker) {
|
||||
if findProduct(id: viewModel.uniqueProduct.id) != nil {
|
||||
VersionPickerView(productId: viewModel.uniqueProduct.id) { version in
|
||||
NavigationVersionPickerView(productId: viewModel.uniqueProduct.id) { version in
|
||||
Task {
|
||||
await viewModel.handleDownloadRequest(
|
||||
version,
|
||||
@@ -626,17 +620,11 @@ struct AlertModifier: ViewModifier {
|
||||
try? FileManager.default.removeItem(at: existingPath)
|
||||
}
|
||||
|
||||
let destinationURL = try await viewModel.getDestinationURL(
|
||||
version: viewModel.pendingVersion,
|
||||
language: viewModel.pendingLanguage
|
||||
)
|
||||
|
||||
try await globalNetworkManager.startDownload(
|
||||
productId: viewModel.uniqueProduct.id,
|
||||
selectedVersion: viewModel.pendingVersion,
|
||||
language: viewModel.pendingLanguage,
|
||||
destinationURL: destinationURL
|
||||
)
|
||||
await MainActor.run {
|
||||
viewModel.selectedVersion = viewModel.pendingVersion
|
||||
viewModel.selectedLanguage = viewModel.pendingLanguage
|
||||
viewModel.showVersionPicker = true
|
||||
}
|
||||
} catch {
|
||||
viewModel.handleError(error)
|
||||
}
|
||||
|
||||
@@ -400,6 +400,61 @@ struct DownloadProgressView: View {
|
||||
showSetupProcessAlert: $showSetupProcessAlert,
|
||||
actionButtons: AnyView(actionButtons)
|
||||
)
|
||||
} else {
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer()
|
||||
#if DEBUG
|
||||
Button(action: {
|
||||
let containerURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let tasksDirectory = containerURL.appendingPathComponent("Adobe Downloader/tasks", isDirectory: true)
|
||||
let fileName = "\(task.productId == "APRO" ? "Adobe Downloader \(task.productId)_\(task.productVersion)_\(task.platform)" : "Adobe Downloader \(task.productId)_\(task.productVersion)-\(task.language)-\(task.platform)")-task.json"
|
||||
let fileURL = tasksDirectory.appendingPathComponent(fileName)
|
||||
NSWorkspace.shared.selectFile(fileURL.path, inFileViewerRootedAtPath: tasksDirectory.path)
|
||||
}) {
|
||||
Label("持久化文件", systemImage: "doc.text.magnifyingglass")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: .blue))
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
if case .completed = task.status, task.productId != "APRO" {
|
||||
CommandLineInstallButton(
|
||||
task: task,
|
||||
showCommandLineInstall: $showCommandLineInstall,
|
||||
showCopiedAlert: $showCopiedAlert
|
||||
)
|
||||
}
|
||||
#else
|
||||
if !ModifySetup.isSetupModified(), case .completed = task.status {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.yellow)
|
||||
Text("Setup 组件未处理,无法安装")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color.black.opacity(0.1))
|
||||
.cornerRadius(5)
|
||||
.onTapGesture {
|
||||
showSetupProcessAlert = true
|
||||
}
|
||||
.alert("Setup 组件未处理", isPresented: $showSetupProcessAlert) {
|
||||
Button("确定") { }
|
||||
} message: {
|
||||
Text("未对 Setup 组件进行处理或者 Setup 组件不存在,无法使用安装功能\n你可以通过设置页面对 Setup 组件进行处理")
|
||||
.font(.system(size: 18))
|
||||
}
|
||||
.padding(.top, 5)
|
||||
}
|
||||
#endif
|
||||
actionButtons
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
855
Adobe Downloader/Views/NavigationCustomDownloadView.swift
Normal file
855
Adobe Downloader/Views/NavigationCustomDownloadView.swift
Normal file
@@ -0,0 +1,855 @@
|
||||
//
|
||||
// NavigationCustomDownloadView.swift
|
||||
// Adobe Downloader
|
||||
//
|
||||
// Created by X1a0He on 2025/07/19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NavigationCustomDownloadView: View {
|
||||
@StateObject private var loadingState = CustomDownloadLoadingState()
|
||||
@State private var allPackages: [Package] = []
|
||||
@State private var dependenciesToDownload: [DependenciesToDownload] = []
|
||||
@State private var showExistingFileAlert = false
|
||||
@State private var existingFilePath: URL?
|
||||
@State private var pendingDependencies: [DependenciesToDownload] = []
|
||||
@State private var productIcon: NSImage? = nil
|
||||
|
||||
let productId: String
|
||||
let version: String
|
||||
let onDownloadStart: ([DependenciesToDownload]) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if loadingState.isLoading {
|
||||
NavigationCustomDownloadLoadingView(
|
||||
loadingState: loadingState,
|
||||
productId: productId,
|
||||
version: version,
|
||||
onCancel: onDismiss
|
||||
)
|
||||
} else if loadingState.error != nil {
|
||||
VStack {
|
||||
Text("加载失败")
|
||||
.font(.headline)
|
||||
Text(loadingState.error!)
|
||||
.foregroundColor(.secondary)
|
||||
Button("重试") {
|
||||
loadingState.error = nil
|
||||
loadingState.isLoading = true
|
||||
loadPackageInfo()
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.blue))
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("自定义下载")
|
||||
} else {
|
||||
NavigationCustomPackageSelectorView(
|
||||
productId: productId,
|
||||
version: version,
|
||||
packages: allPackages,
|
||||
dependenciesToDownload: dependenciesToDownload,
|
||||
onDownloadStart: { dependencies in
|
||||
onDownloadStart(dependencies)
|
||||
},
|
||||
onCancel: onDismiss,
|
||||
onFileExists: { path, dependencies in
|
||||
existingFilePath = path
|
||||
pendingDependencies = dependencies
|
||||
showExistingFileAlert = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if loadingState.isLoading {
|
||||
loadPackageInfo()
|
||||
}
|
||||
loadProductIcon()
|
||||
}
|
||||
.sheet(isPresented: $showExistingFileAlert) {
|
||||
if let existingPath = existingFilePath {
|
||||
ExistingFileAlertView(
|
||||
path: existingPath,
|
||||
onUseExisting: {
|
||||
showExistingFileAlert = false
|
||||
if let existingPath = existingFilePath {
|
||||
Task {
|
||||
await createCompletedCustomTask(
|
||||
path: existingPath,
|
||||
dependencies: pendingDependencies
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingDependencies = []
|
||||
onDismiss()
|
||||
},
|
||||
onRedownload: {
|
||||
showExistingFileAlert = false
|
||||
startCustomDownloadProcess(dependencies: pendingDependencies)
|
||||
},
|
||||
onCancel: {
|
||||
showExistingFileAlert = false
|
||||
pendingDependencies = []
|
||||
},
|
||||
iconImage: productIcon
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPackageInfo() {
|
||||
Task {
|
||||
do {
|
||||
let (packages, dependencies) = try await fetchPackageInfo()
|
||||
await MainActor.run {
|
||||
allPackages = packages
|
||||
dependenciesToDownload = dependencies
|
||||
loadingState.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
loadingState.isLoading = false
|
||||
loadingState.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPackageInfo() async throws -> ([Package], [DependenciesToDownload]) {
|
||||
guard let product = findProduct(id: productId) else {
|
||||
throw NetworkError.invalidData("找不到产品信息")
|
||||
}
|
||||
|
||||
var allPackages: [Package] = []
|
||||
var dependenciesToDownload: [DependenciesToDownload] = []
|
||||
|
||||
let firstPlatform = product.platforms.first
|
||||
let buildGuid = firstPlatform?.languageSet.first?.buildGuid ?? ""
|
||||
|
||||
var dependencyInfos: [DependenciesToDownload] = []
|
||||
dependencyInfos.append(DependenciesToDownload(sapCode: product.id, version: product.version, buildGuid: buildGuid))
|
||||
|
||||
let dependencies = firstPlatform?.languageSet.first?.dependencies
|
||||
if let dependencies = dependencies {
|
||||
for dependency in dependencies {
|
||||
dependencyInfos.append(DependenciesToDownload(sapCode: dependency.sapCode, version: dependency.productVersion, buildGuid: dependency.buildGuid))
|
||||
}
|
||||
}
|
||||
|
||||
for dependencyInfo in dependencyInfos {
|
||||
await MainActor.run {
|
||||
loadingState.currentTask = "正在处理 \(dependencyInfo.sapCode) 的包信息..."
|
||||
}
|
||||
|
||||
let jsonString = try await globalNetworkService.getApplicationInfo(buildGuid: dependencyInfo.buildGuid)
|
||||
dependencyInfo.applicationJson = jsonString
|
||||
|
||||
var processedJsonString = jsonString
|
||||
if dependencyInfo.sapCode == product.id {
|
||||
if let jsonData = jsonString.data(using: .utf8),
|
||||
var appInfo = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] {
|
||||
|
||||
if var modules = appInfo["Modules"] as? [String: Any] {
|
||||
modules["Module"] = [] as [[String: Any]]
|
||||
appInfo["Modules"] = modules
|
||||
}
|
||||
|
||||
if let processedData = try? JSONSerialization.data(withJSONObject: appInfo, options: .prettyPrinted),
|
||||
let processedString = String(data: processedData, encoding: .utf8) {
|
||||
processedJsonString = processedString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let jsonData = processedJsonString.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 {
|
||||
guard let downloadURL = package["Path"] as? String, !downloadURL.isEmpty else { continue }
|
||||
|
||||
let packageVersion: String = package["PackageVersion"] as? String ?? ""
|
||||
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).zip"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
let downloadSize: Int64
|
||||
switch package["DownloadSize"] {
|
||||
case let sizeNumber as NSNumber:
|
||||
downloadSize = sizeNumber.int64Value
|
||||
case let sizeString as String:
|
||||
downloadSize = Int64(sizeString) ?? 0
|
||||
default:
|
||||
downloadSize = 0
|
||||
}
|
||||
|
||||
let packageType = package["Type"] as? String ?? "non-core"
|
||||
let condition = package["Condition"] as? String ?? ""
|
||||
|
||||
let isCore = packageType == "core"
|
||||
let targetArchitecture = StorageData.shared.downloadAppleSilicon ? "arm64" : "x64"
|
||||
let language = StorageData.shared.defaultLanguage
|
||||
let installLanguage = "[installLanguage]==\(language)"
|
||||
|
||||
var shouldDefaultSelect = false
|
||||
var isRequired = false
|
||||
|
||||
if dependencyInfo.sapCode == product.id {
|
||||
if isCore {
|
||||
shouldDefaultSelect = condition.isEmpty ||
|
||||
condition.contains("[OSArchitecture]==\(targetArchitecture)") ||
|
||||
condition.contains(installLanguage) || language == "ALL"
|
||||
isRequired = shouldDefaultSelect
|
||||
} else {
|
||||
shouldDefaultSelect = condition.contains(installLanguage) || language == "ALL"
|
||||
}
|
||||
} else {
|
||||
shouldDefaultSelect = condition.isEmpty ||
|
||||
(condition.contains("[OSVersion]") && checkOSVersionCondition(condition)) ||
|
||||
condition.contains(installLanguage) || language == "ALL"
|
||||
}
|
||||
|
||||
let packageObj = Package(
|
||||
type: packageType,
|
||||
fullPackageName: fullPackageName,
|
||||
downloadSize: downloadSize,
|
||||
downloadURL: downloadURL,
|
||||
packageVersion: packageVersion,
|
||||
condition: condition,
|
||||
isRequired: isRequired
|
||||
)
|
||||
|
||||
packageObj.isSelected = shouldDefaultSelect
|
||||
|
||||
dependencyInfo.packages.append(packageObj)
|
||||
allPackages.append(packageObj)
|
||||
}
|
||||
|
||||
dependenciesToDownload.append(dependencyInfo)
|
||||
}
|
||||
|
||||
return (allPackages, dependenciesToDownload)
|
||||
}
|
||||
|
||||
private func createCompletedCustomTask(path: URL, dependencies: [DependenciesToDownload]) async {
|
||||
let existingTask = globalNetworkManager.downloadTasks.first { task in
|
||||
return task.productId == productId &&
|
||||
task.productVersion == version &&
|
||||
task.language == StorageData.shared.defaultLanguage &&
|
||||
task.directory == path
|
||||
}
|
||||
|
||||
if existingTask != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let platform = globalProducts.first(where: { $0.id == productId && $0.version == version })?.platforms.first?.id ?? "unknown"
|
||||
|
||||
let task = NewDownloadTask(
|
||||
productId: productId,
|
||||
productVersion: version,
|
||||
language: StorageData.shared.defaultLanguage,
|
||||
displayName: findProduct(id: productId)?.displayName ?? productId,
|
||||
directory: path,
|
||||
dependenciesToDownload: dependencies,
|
||||
retryCount: 0,
|
||||
createAt: Date(),
|
||||
totalProgress: 1.0,
|
||||
platform: platform
|
||||
)
|
||||
|
||||
task.dependenciesToDownload = dependencies
|
||||
|
||||
let totalSize = dependencies.reduce(0) { productSum, product in
|
||||
productSum + product.packages.reduce(0) { packageSum, pkg in
|
||||
packageSum + (pkg.downloadSize > 0 ? pkg.downloadSize : 0)
|
||||
}
|
||||
}
|
||||
task.totalSize = totalSize
|
||||
task.totalDownloadedSize = totalSize
|
||||
task.totalProgress = 1.0
|
||||
|
||||
for dependency in dependencies {
|
||||
for package in dependency.packages where package.isSelected {
|
||||
package.downloaded = true
|
||||
package.progress = 1.0
|
||||
package.downloadedSize = package.downloadSize
|
||||
package.status = .completed
|
||||
}
|
||||
}
|
||||
|
||||
task.setStatus(DownloadStatus.completed(DownloadStatus.CompletionInfo(
|
||||
timestamp: Date(),
|
||||
totalTime: 0,
|
||||
totalSize: totalSize
|
||||
)))
|
||||
|
||||
await MainActor.run {
|
||||
globalNetworkManager.downloadTasks.append(task)
|
||||
globalNetworkManager.updateDockBadge()
|
||||
globalNetworkManager.objectWillChange.send()
|
||||
}
|
||||
|
||||
await globalNetworkManager.saveTask(task)
|
||||
}
|
||||
|
||||
private func startCustomDownloadProcess(dependencies: [DependenciesToDownload]) {
|
||||
Task {
|
||||
let destinationURL: URL
|
||||
do {
|
||||
destinationURL = try await getDestinationURL(
|
||||
productId: productId,
|
||||
version: version,
|
||||
language: StorageData.shared.defaultLanguage
|
||||
)
|
||||
} catch {
|
||||
await MainActor.run { onDismiss() }
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await globalNetworkManager.startCustomDownload(
|
||||
productId: productId,
|
||||
selectedVersion: version,
|
||||
language: StorageData.shared.defaultLanguage,
|
||||
destinationURL: destinationURL,
|
||||
customDependencies: dependencies
|
||||
)
|
||||
} catch {
|
||||
print("自定义下载失败: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getDestinationURL(productId: String, version: String, language: String) async throws -> URL {
|
||||
let platform = globalProducts.first(where: { $0.id == productId && $0.version == version })?.platforms.first?.id ?? "unknown"
|
||||
let installerName = productId == "APRO"
|
||||
? "Adobe Downloader \(productId)_\(version)_\(platform).dmg"
|
||||
: "Adobe Downloader \(productId)_\(version)-\(language)-\(platform)"
|
||||
|
||||
if StorageData.shared.useDefaultDirectory && !StorageData.shared.defaultDirectory.isEmpty {
|
||||
return URL(fileURLWithPath: StorageData.shared.defaultDirectory)
|
||||
.appendingPathComponent(installerName)
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "选择保存位置"
|
||||
panel.canCreateDirectories = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
|
||||
if let downloadsDir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first {
|
||||
panel.directoryURL = downloadsDir
|
||||
}
|
||||
|
||||
let result = panel.runModal()
|
||||
if result == .OK, let selectedURL = panel.url {
|
||||
continuation.resume(returning: selectedURL.appendingPathComponent(installerName))
|
||||
} else {
|
||||
continuation.resume(throwing: NetworkError.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProductIcon() {
|
||||
guard let product = findProduct(id: productId),
|
||||
let icon = product.getBestIcon(),
|
||||
let iconURL = URL(string: icon.value) else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: iconURL)
|
||||
if let image = NSImage(data: data) {
|
||||
await MainActor.run {
|
||||
productIcon = image
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("加载产品图标失败: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkOSVersionCondition(_ condition: String) -> Bool {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let currentVersion = Double("\(osVersion.majorVersion).\(osVersion.minorVersion)") ?? 0.0
|
||||
|
||||
let versionPattern = #"\[OSVersion\](>=|<=|<|>|==)([\d.]+)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: versionPattern) else { return false }
|
||||
|
||||
let nsRange = NSRange(condition.startIndex..<condition.endIndex, in: condition)
|
||||
let matches = regex.matches(in: condition, range: nsRange)
|
||||
|
||||
for match in matches {
|
||||
guard match.numberOfRanges >= 3,
|
||||
let operatorRange = Range(match.range(at: 1), in: condition),
|
||||
let versionRange = Range(match.range(at: 2), in: condition),
|
||||
let requiredVersion = Double(condition[versionRange]) else { continue }
|
||||
|
||||
let operatorSymbol = String(condition[operatorRange])
|
||||
if !compareVersions(current: currentVersion, required: requiredVersion, operator: operatorSymbol) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return !matches.isEmpty
|
||||
}
|
||||
|
||||
private func compareVersions(current: Double, required: Double, operator: String) -> Bool {
|
||||
switch `operator` {
|
||||
case ">=":
|
||||
return current >= required
|
||||
case "<=":
|
||||
return current <= required
|
||||
case ">":
|
||||
return current > required
|
||||
case "<":
|
||||
return current < required
|
||||
case "==":
|
||||
return current == required
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationCustomDownloadLoadingView: View {
|
||||
@ObservedObject var loadingState: CustomDownloadLoadingState
|
||||
|
||||
let productId: String
|
||||
let version: String
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
VStack {
|
||||
if let product = findProduct(id: productId) {
|
||||
HStack {
|
||||
if let icon = product.getBestIcon() {
|
||||
AsyncImage(url: URL(string: icon.value)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 32, height: 32)
|
||||
} placeholder: {
|
||||
Image(systemName: "app.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(product.displayName)
|
||||
.font(.headline)
|
||||
Text("版本 \(version)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(spacing: 15) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Text("正在获取包信息...")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if !loadingState.currentTask.isEmpty {
|
||||
Text(loadingState.currentTask)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Button("取消") {
|
||||
onCancel()
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.gray.opacity(0.2)))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(width: 500, height: 400)
|
||||
.navigationTitle("自定义下载")
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationCustomPackageSelectorView: View {
|
||||
@State private var selectedPackages: Set<UUID> = []
|
||||
@State private var searchText = ""
|
||||
@State private var showCopiedAlert = false
|
||||
@State private var isDownloading = false
|
||||
@State private var requiredPackages: Set<UUID> = []
|
||||
|
||||
let productId: String
|
||||
let version: String
|
||||
let packages: [Package]
|
||||
let dependenciesToDownload: [DependenciesToDownload]
|
||||
let onDownloadStart: ([DependenciesToDownload]) -> Void
|
||||
let onCancel: () -> Void
|
||||
let onFileExists: (URL, [DependenciesToDownload]) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("选择要下载的包")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
|
||||
Button(action: copyAllInfo) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 12))
|
||||
Text("复制全部")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.blue))
|
||||
.help("复制所有包信息")
|
||||
|
||||
Button("取消") {
|
||||
onCancel()
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.gray.opacity(0.2)))
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
ForEach(dependenciesToDownload, id: \.sapCode) { dependency in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "cube.box.fill")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.blue.opacity(0.8))
|
||||
|
||||
Text("\(dependency.sapCode) \(dependency.version)\(dependency.sapCode != "APRO" ? " - (\(dependency.buildGuid))" : "")")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.primary.opacity(0.8))
|
||||
.textSelection(.enabled)
|
||||
|
||||
if dependency.sapCode != "APRO" {
|
||||
Button(action: {
|
||||
copyToClipboard(dependency.buildGuid)
|
||||
}) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: .blue))
|
||||
.help("复制 buildGuid")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
ForEach(dependency.packages) { package in
|
||||
NavigationEnhancedPackageRow(
|
||||
package: package,
|
||||
isSelected: selectedPackages.contains(package.id),
|
||||
onToggle: { isSelected in
|
||||
if !requiredPackages.contains(package.id) {
|
||||
if isSelected {
|
||||
selectedPackages.insert(package.id)
|
||||
} else {
|
||||
selectedPackages.remove(package.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
onCopyPackageInfo: {
|
||||
copyPackageInfo(package)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(NSColor.controlBackgroundColor).opacity(0.3))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button("全选") {
|
||||
selectAllPackages()
|
||||
}
|
||||
.font(.caption)
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.green))
|
||||
.help("选择所有包")
|
||||
|
||||
Button("取消全选") {
|
||||
clearAllSelection()
|
||||
}
|
||||
.font(.caption)
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.red))
|
||||
.help("取消选择所有包")
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
Text("已选择 \(selectedPackages.count) 个包")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("总大小: \(formattedTotalSize)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(isDownloading ? "正在下载..." : "开始下载") {
|
||||
startCustomDownload()
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: isDownloading ? Color.gray : Color.blue))
|
||||
.disabled(selectedPackages.isEmpty || isDownloading)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(width: 800, height: 650)
|
||||
.navigationTitle("自定义下载")
|
||||
.onAppear {
|
||||
initializeSelection()
|
||||
}
|
||||
.popover(isPresented: $showCopiedAlert, arrowEdge: .trailing) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("已复制")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var formattedTotalSize: String {
|
||||
let totalSize = selectedPackages.compactMap { id in
|
||||
packages.first { $0.id == id }?.downloadSize
|
||||
}.reduce(0, +)
|
||||
|
||||
return ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
|
||||
}
|
||||
|
||||
private func initializeSelection() {
|
||||
selectedPackages.removeAll()
|
||||
requiredPackages.removeAll()
|
||||
|
||||
for package in packages {
|
||||
if package.isRequired || package.isSelected {
|
||||
selectedPackages.insert(package.id)
|
||||
requiredPackages.insert(package.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectAllPackages() {
|
||||
selectedPackages = Set(packages.map { $0.id })
|
||||
}
|
||||
|
||||
private func clearAllSelection() {
|
||||
selectedPackages = requiredPackages
|
||||
}
|
||||
|
||||
private func startCustomDownload() {
|
||||
guard !isDownloading else { return }
|
||||
|
||||
isDownloading = true
|
||||
|
||||
for dependency in dependenciesToDownload {
|
||||
for package in dependency.packages {
|
||||
package.isSelected = selectedPackages.contains(package.id)
|
||||
}
|
||||
}
|
||||
|
||||
let finalDependencies = dependenciesToDownload.filter { dependency in
|
||||
dependency.packages.contains { $0.isSelected }
|
||||
}
|
||||
|
||||
if let existingPath = globalNetworkManager.isVersionDownloaded(
|
||||
productId: productId,
|
||||
version: version,
|
||||
language: StorageData.shared.defaultLanguage
|
||||
) {
|
||||
isDownloading = false
|
||||
onFileExists(existingPath, finalDependencies)
|
||||
} else {
|
||||
onDownloadStart(finalDependencies)
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func copyToClipboard(_ text: String) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
showCopiedAlert = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
showCopiedAlert = false
|
||||
}
|
||||
}
|
||||
|
||||
private func copyPackageInfo(_ package: Package) {
|
||||
let packageInfo = "\(package.fullPackageName) (\(package.packageVersion)) - \(package.type)"
|
||||
copyToClipboard(packageInfo)
|
||||
}
|
||||
|
||||
private func copyAllInfo() {
|
||||
var result = ""
|
||||
|
||||
for (index, dependency) in dependenciesToDownload.enumerated() {
|
||||
let dependencyInfo: String
|
||||
if dependency.sapCode == "APRO" {
|
||||
dependencyInfo = "\(dependency.sapCode) \(dependency.version)"
|
||||
} else {
|
||||
dependencyInfo = "\(dependency.sapCode) \(dependency.version) - (\(dependency.buildGuid))"
|
||||
}
|
||||
result += dependencyInfo + "\n"
|
||||
|
||||
for (pkgIndex, package) in dependency.packages.enumerated() {
|
||||
let isLastPackage = pkgIndex == dependency.packages.count - 1
|
||||
let prefix = isLastPackage ? " └── " : " ├── "
|
||||
result += "\(prefix)\(package.fullPackageName) (\(package.packageVersion)) - \(package.type)\n"
|
||||
}
|
||||
|
||||
if index < dependenciesToDownload.count - 1 {
|
||||
result += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
copyToClipboard(result)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationEnhancedPackageRow: View {
|
||||
let package: Package
|
||||
let isSelected: Bool
|
||||
let onToggle: (Bool) -> Void
|
||||
let onCopyPackageInfo: () -> Void
|
||||
|
||||
private var isRequiredPackage: Bool {
|
||||
package.isRequired || package.isSelected
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
if !isRequiredPackage {
|
||||
onToggle(!isSelected)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: isSelected ? "checkmark.square.fill" : "square")
|
||||
.foregroundColor(isRequiredPackage ? .secondary : .blue)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(isRequiredPackage)
|
||||
.help(isRequiredPackage ? "此包为必需包,无法取消选择" : "点击切换选择状态")
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text("\(package.fullPackageName) (\(package.packageVersion))")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary.opacity(0.8))
|
||||
.textSelection(.enabled)
|
||||
|
||||
Text(package.type)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(package.type == "core" ? Color.blue.opacity(0.1) : Color.orange.opacity(0.1))
|
||||
)
|
||||
.foregroundColor(package.type == "core" ? .blue : .orange)
|
||||
|
||||
if isRequiredPackage {
|
||||
Text(package.isRequired ? "必需" : "默认")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(package.isRequired ? Color.red.opacity(0.8) : Color.purple.opacity(0.8))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(package.formattedSize)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button(action: onCopyPackageInfo) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: .gray.opacity(0.6)))
|
||||
.help("复制包信息")
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if !package.condition.isEmpty {
|
||||
Text("条件: \(package.condition)")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary.opacity(0.8))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? Color.blue.opacity(0.1) : Color.clear)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 0.5)
|
||||
.opacity(0.5),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
898
Adobe Downloader/Views/NavigationVersionPickerView.swift
Normal file
898
Adobe Downloader/Views/NavigationVersionPickerView.swift
Normal file
@@ -0,0 +1,898 @@
|
||||
//
|
||||
// NavigationVersionPickerView.swift
|
||||
// Adobe Downloader
|
||||
//
|
||||
// Created by X1a0He on 2025/07/19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private enum NavigationVersionPickerConstants {
|
||||
static let headerPadding: CGFloat = 5
|
||||
static let viewWidth: CGFloat = 500
|
||||
static let viewHeight: CGFloat = 600
|
||||
static let iconSize: CGFloat = 32
|
||||
static let verticalSpacing: CGFloat = 8
|
||||
static let horizontalSpacing: CGFloat = 12
|
||||
static let cornerRadius: CGFloat = 8
|
||||
static let buttonPadding: CGFloat = 8
|
||||
|
||||
static let titleFontSize: CGFloat = 14
|
||||
static let captionFontSize: CGFloat = 12
|
||||
}
|
||||
|
||||
enum VersionPickerDestination: Hashable {
|
||||
case customDownload(productId: String, version: String)
|
||||
case duplicateTaskAlert(productId: String, version: String)
|
||||
|
||||
static func == (lhs: VersionPickerDestination, rhs: VersionPickerDestination) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.customDownload(let lProductId, let lVersion), .customDownload(let rProductId, let rVersion)):
|
||||
return lProductId == rProductId && lVersion == rVersion
|
||||
case (.duplicateTaskAlert(let lProductId, let lVersion), .duplicateTaskAlert(let rProductId, let rVersion)):
|
||||
return lProductId == rProductId && lVersion == rVersion
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .customDownload(let productId, let version):
|
||||
hasher.combine("customDownload")
|
||||
hasher.combine(productId)
|
||||
hasher.combine(version)
|
||||
case .duplicateTaskAlert(let productId, let version):
|
||||
hasher.combine("duplicateTaskAlert")
|
||||
hasher.combine(productId)
|
||||
hasher.combine(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NavigationVersionPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StorageValue(\.defaultLanguage) private var defaultLanguage
|
||||
@StorageValue(\.downloadAppleSilicon) private var downloadAppleSilicon
|
||||
@State private var expandedVersions: Set<String> = []
|
||||
@State private var existingFilePath: URL?
|
||||
@State private var pendingDependencies: [DependenciesToDownload] = []
|
||||
@State private var productIcon: NSImage? = nil
|
||||
@State private var navigationPath = NavigationPath()
|
||||
|
||||
private let productId: String
|
||||
private let onSelect: (String) -> Void
|
||||
|
||||
init(productId: String, onSelect: @escaping (String) -> Void) {
|
||||
self.productId = productId
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
VStack(spacing: 0) {
|
||||
NavigationVersionPickerHeaderView(
|
||||
productId: productId,
|
||||
downloadAppleSilicon: downloadAppleSilicon,
|
||||
onDismiss: { dismiss() }
|
||||
)
|
||||
NavigationVersionListView(
|
||||
productId: productId,
|
||||
expandedVersions: $expandedVersions,
|
||||
onSelect: onSelect,
|
||||
dismiss: dismiss,
|
||||
onCustomDownload: { version in
|
||||
navigationPath.append(VersionPickerDestination.customDownload(productId: productId, version: version))
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(width: NavigationVersionPickerConstants.viewWidth, height: NavigationVersionPickerConstants.viewHeight)
|
||||
.navigationDestination(for: VersionPickerDestination.self) { destination in
|
||||
switch destination {
|
||||
case .customDownload(let productId, let version):
|
||||
NavigationCustomDownloadView(
|
||||
productId: productId,
|
||||
version: version,
|
||||
onDownloadStart: { dependencies in
|
||||
handleCustomDownload(dependencies: dependencies)
|
||||
},
|
||||
onDismiss: {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
case .duplicateTaskAlert(let productId, let version):
|
||||
DuplicateTaskAlertView(
|
||||
productId: productId,
|
||||
version: version,
|
||||
onCancel: {
|
||||
navigationPath.removeLast()
|
||||
},
|
||||
iconImage: productIcon
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadProductIcon()
|
||||
}
|
||||
}
|
||||
|
||||
private func getDestinationURL(productId: String, version: String, language: String) async throws -> URL {
|
||||
let platform = globalProducts.first(where: { $0.id == productId && $0.version == version })?.platforms.first?.id ?? "unknown"
|
||||
let installerName = productId == "APRO"
|
||||
? "Adobe Downloader \(productId)_\(version)_\(platform).dmg"
|
||||
: "Adobe Downloader \(productId)_\(version)-\(language)-\(platform)"
|
||||
|
||||
if StorageData.shared.useDefaultDirectory && !StorageData.shared.defaultDirectory.isEmpty {
|
||||
return URL(fileURLWithPath: StorageData.shared.defaultDirectory)
|
||||
.appendingPathComponent(installerName)
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "选择保存位置"
|
||||
panel.canCreateDirectories = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
|
||||
if let downloadsDir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first {
|
||||
panel.directoryURL = downloadsDir
|
||||
}
|
||||
|
||||
let result = panel.runModal()
|
||||
if result == .OK, let selectedURL = panel.url {
|
||||
continuation.resume(returning: selectedURL.appendingPathComponent(installerName))
|
||||
} else {
|
||||
continuation.resume(throwing: NetworkError.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCustomDownload(dependencies: [DependenciesToDownload]) {
|
||||
guard let firstDependency = dependencies.first else { return }
|
||||
let version = firstDependency.version
|
||||
|
||||
let existingTask = globalNetworkManager.downloadTasks.first { task in
|
||||
task.productId == productId &&
|
||||
task.productVersion == version &&
|
||||
task.language == StorageData.shared.defaultLanguage &&
|
||||
task.status.isActive
|
||||
}
|
||||
|
||||
if existingTask != nil {
|
||||
navigationPath.append(VersionPickerDestination.duplicateTaskAlert(productId: productId, version: version))
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await startCustomDownloadProcess(dependencies: dependencies)
|
||||
}
|
||||
}
|
||||
|
||||
private func startCustomDownloadProcess(dependencies: [DependenciesToDownload]) async {
|
||||
guard let firstDependency = dependencies.first else { return }
|
||||
let version = firstDependency.version
|
||||
|
||||
let destinationURL: URL
|
||||
do {
|
||||
destinationURL = try await getDestinationURL(
|
||||
productId: productId,
|
||||
version: version,
|
||||
language: StorageData.shared.defaultLanguage
|
||||
)
|
||||
} catch {
|
||||
await MainActor.run { dismiss() }
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await globalNetworkManager.startCustomDownload(
|
||||
productId: productId,
|
||||
selectedVersion: version,
|
||||
language: StorageData.shared.defaultLanguage,
|
||||
destinationURL: destinationURL,
|
||||
customDependencies: dependencies
|
||||
)
|
||||
} catch {
|
||||
print("自定义下载失败: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProductIcon() {
|
||||
guard let product = findProduct(id: productId),
|
||||
let icon = product.getBestIcon(),
|
||||
let iconURL = URL(string: icon.value) else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: iconURL)
|
||||
if let image = NSImage(data: data) {
|
||||
await MainActor.run {
|
||||
productIcon = image
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("加载产品图标失败: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationVersionPickerHeaderView: View {
|
||||
let productId: String
|
||||
let downloadAppleSilicon: Bool
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
if let product = findProduct(id: productId) {
|
||||
if let icon = product.getBestIcon() {
|
||||
AsyncImage(url: URL(string: icon.value)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
} placeholder: {
|
||||
Image(systemName: "app.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(product.displayName)")
|
||||
.font(.headline)
|
||||
}
|
||||
Text("选择版本")
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Button("取消") {
|
||||
onDismiss()
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.gray.opacity(0.2)))
|
||||
}
|
||||
.padding(.bottom, NavigationVersionPickerConstants.headerPadding)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: downloadAppleSilicon ? "m.square" : "x.square")
|
||||
.foregroundColor(.blue)
|
||||
Text(downloadAppleSilicon ? "Apple Silicon" : "Intel")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Text("•")
|
||||
.foregroundColor(.secondary)
|
||||
Text(platformText)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
|
||||
private var platformText: String {
|
||||
StorageData.shared.allowedPlatform.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationVersionListView: View {
|
||||
let productId: String
|
||||
@Binding var expandedVersions: Set<String>
|
||||
let onSelect: (String) -> Void
|
||||
let dismiss: DismissAction
|
||||
let onCustomDownload: (String) -> Void
|
||||
@State private var scrollPosition: String?
|
||||
@State private var cachedVersions: [(key: String, value: Product.Platform)] = []
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
LazyVStack(spacing: NavigationVersionPickerConstants.verticalSpacing) {
|
||||
ForEach(getFilteredVersions(), id: \.key) { version, info in
|
||||
NavigationVersionRow(
|
||||
productId: productId,
|
||||
version: version,
|
||||
info: info,
|
||||
isExpanded: expandedVersions.contains(version),
|
||||
onSelect: handleVersionSelect,
|
||||
onToggle: handleVersionToggle,
|
||||
onCustomDownload: handleCustomDownload
|
||||
)
|
||||
.id(version)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Capsule()
|
||||
.fill(Color.green)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("获取到 \(getFilteredVersions().count) 个版本")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
.background(Color(.clear))
|
||||
.onChange(of: expandedVersions) { newValue in
|
||||
if let lastExpanded = newValue.sorted().last {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastExpanded, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if cachedVersions.isEmpty {
|
||||
cachedVersions = loadFilteredVersions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getFilteredVersions() -> [(key: String, value: Product.Platform)] {
|
||||
if !cachedVersions.isEmpty {
|
||||
return cachedVersions
|
||||
}
|
||||
return loadFilteredVersions()
|
||||
}
|
||||
|
||||
private func loadFilteredVersions() -> [(key: String, value: Product.Platform)] {
|
||||
let products = findProducts(id: productId)
|
||||
if products.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
var versionPlatformMap: [String: Product.Platform] = [:]
|
||||
|
||||
for product in products {
|
||||
let platforms = product.platforms.filter { platform in
|
||||
StorageData.shared.allowedPlatform.contains(platform.id)
|
||||
}
|
||||
|
||||
if let firstPlatform = platforms.first {
|
||||
versionPlatformMap[product.version] = firstPlatform
|
||||
}
|
||||
}
|
||||
|
||||
return versionPlatformMap.map { (key: $0.key, value: $0.value) }
|
||||
.sorted { pair1, pair2 in
|
||||
AppStatics.compareVersions(pair1.key, pair2.key) > 0
|
||||
}
|
||||
}
|
||||
|
||||
private func handleVersionSelect(_ version: String) {
|
||||
onSelect(version)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func handleVersionToggle(_ version: String) {
|
||||
withAnimation {
|
||||
if expandedVersions.contains(version) {
|
||||
expandedVersions.remove(version)
|
||||
} else {
|
||||
expandedVersions.insert(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCustomDownload(_ version: String) {
|
||||
onCustomDownload(version)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationVersionRow: View, Equatable {
|
||||
@StorageValue(\.defaultLanguage) private var defaultLanguage
|
||||
|
||||
let productId: String
|
||||
let version: String
|
||||
let info: Product.Platform
|
||||
let isExpanded: Bool
|
||||
let onSelect: (String) -> Void
|
||||
let onToggle: (String) -> Void
|
||||
let onCustomDownload: (String) -> Void
|
||||
|
||||
static func == (lhs: NavigationVersionRow, rhs: NavigationVersionRow) -> Bool {
|
||||
lhs.productId == rhs.productId &&
|
||||
lhs.version == rhs.version &&
|
||||
lhs.isExpanded == rhs.isExpanded
|
||||
}
|
||||
|
||||
@State private var cachedExistingPath: URL? = nil
|
||||
|
||||
private var existingPath: URL? {
|
||||
cachedExistingPath
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
NavigationVersionHeader(
|
||||
version: version,
|
||||
info: info,
|
||||
isExpanded: isExpanded,
|
||||
hasExistingPath: existingPath != nil,
|
||||
onSelect: { onToggle(version) },
|
||||
onToggle: { onToggle(version) }
|
||||
)
|
||||
|
||||
if isExpanded {
|
||||
NavigationVersionDetails(
|
||||
info: info,
|
||||
version: version,
|
||||
onSelect: onSelect,
|
||||
onCustomDownload: onCustomDownload
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(NavigationVersionPickerConstants.cornerRadius)
|
||||
.animation(.easeInOut(duration: 0.2), value: isExpanded)
|
||||
.onAppear {
|
||||
if cachedExistingPath == nil {
|
||||
cachedExistingPath = globalNetworkManager.isVersionDownloaded(
|
||||
productId: productId,
|
||||
version: version,
|
||||
language: defaultLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationVersionHeader: View {
|
||||
let version: String
|
||||
let info: Product.Platform
|
||||
let isExpanded: Bool
|
||||
let hasExistingPath: Bool
|
||||
let onSelect: () -> Void
|
||||
let onToggle: () -> Void
|
||||
|
||||
private var hasDependencies: Bool {
|
||||
!(info.languageSet.first?.dependencies.isEmpty ?? true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack {
|
||||
VersionInfo(version: version, platform: info.id, info: info)
|
||||
Spacer()
|
||||
ExistingPathButton(isVisible: hasExistingPath)
|
||||
ExpandButton(
|
||||
isExpanded: isExpanded,
|
||||
onToggle: onToggle,
|
||||
hasDependencies: hasDependencies
|
||||
)
|
||||
}
|
||||
.padding(.vertical, NavigationVersionPickerConstants.buttonPadding)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationVersionDetails: View {
|
||||
let info: Product.Platform
|
||||
let version: String
|
||||
let onSelect: (String) -> Void
|
||||
let onCustomDownload: (String) -> Void
|
||||
|
||||
private var hasDependencies: Bool {
|
||||
!(info.languageSet.first?.dependencies.isEmpty ?? true)
|
||||
}
|
||||
|
||||
private var hasModules: Bool {
|
||||
!(info.modules.isEmpty)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: NavigationVersionPickerConstants.verticalSpacing) {
|
||||
if hasDependencies || hasModules {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if hasDependencies {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "shippingbox.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.blue.opacity(0.8))
|
||||
Text("依赖组件")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Text("(\(info.languageSet.first?.dependencies.count ?? 0))")
|
||||
.font(.system(size: 11))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
)
|
||||
.foregroundColor(.blue.opacity(0.8))
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
DependenciesList(dependencies: info.languageSet.first?.dependencies ?? [])
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
#if DEBUG
|
||||
if hasModules {
|
||||
if hasDependencies {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "square.stack.3d.up.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.blue.opacity(0.8))
|
||||
Text("可选模块")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Text("(\(info.modules.count))")
|
||||
.font(.system(size: 11))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
)
|
||||
.foregroundColor(.blue.opacity(0.8))
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
ModulesList(modules: info.modules)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
NavigationDownloadButton(
|
||||
version: version,
|
||||
onSelect: onSelect,
|
||||
onCustomDownload: { version in
|
||||
onCustomDownload(version)
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationDownloadButton: View {
|
||||
let version: String
|
||||
let onSelect: (String) -> Void
|
||||
let onCustomDownload: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button("下载") {
|
||||
onCustomDownload(version)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.blue))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct VersionInfo: View {
|
||||
let version: String
|
||||
let platform: String
|
||||
let info: Product.Platform
|
||||
|
||||
private var productVersion: String? {
|
||||
info.languageSet.first?.productVersion
|
||||
}
|
||||
|
||||
private var buildGuid: String? {
|
||||
info.languageSet.first?.buildGuid
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(version)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary.opacity(0.9))
|
||||
|
||||
if let pv = productVersion, pv != version {
|
||||
Text("•")
|
||||
.foregroundColor(.secondary)
|
||||
Text("v\(pv)")
|
||||
.font(.system(size: 12))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
)
|
||||
.foregroundColor(.blue.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(platform)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary.opacity(0.8))
|
||||
|
||||
if let guid = buildGuid {
|
||||
Text("•")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
Text(guid)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExistingPathButton: View {
|
||||
let isVisible: Bool
|
||||
|
||||
var body: some View {
|
||||
if isVisible {
|
||||
Text("已存在")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(.blue.opacity(0.9))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.stroke(Color.blue.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExpandButton: View {
|
||||
let isExpanded: Bool
|
||||
let onToggle: () -> Void
|
||||
let hasDependencies: Bool
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onToggle)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DependenciesList: View {
|
||||
let dependencies: [Product.Platform.LanguageSet.Dependency]
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(dependencies, id: \.sapCode) { dependency in
|
||||
DependencyRow(dependency: dependency)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DependencyRow: View, Equatable {
|
||||
let dependency: Product.Platform.LanguageSet.Dependency
|
||||
|
||||
static func == (lhs: DependencyRow, rhs: DependencyRow) -> Bool {
|
||||
lhs.dependency.sapCode == rhs.dependency.sapCode &&
|
||||
lhs.dependency.productVersion == rhs.dependency.productVersion
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 8) {
|
||||
getPlatformIcon(for: dependency.selectedPlatform)
|
||||
.foregroundColor(.blue.opacity(0.8))
|
||||
.font(.system(size: 12))
|
||||
.frame(width: 16)
|
||||
|
||||
Text(dependency.sapCode)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary.opacity(0.8))
|
||||
|
||||
Text("\(dependency.productVersion)")
|
||||
.font(.system(size: 11))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
)
|
||||
.foregroundColor(.blue.opacity(0.8))
|
||||
|
||||
if dependency.baseVersion != dependency.productVersion {
|
||||
HStack(spacing: 3) {
|
||||
Text("base:")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
Text(dependency.baseVersion)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.secondary.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.secondary.opacity(0.1))
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if !dependency.buildGuid.isEmpty {
|
||||
HStack(spacing: 3) {
|
||||
Text("buildGuid:")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
Text(dependency.buildGuid)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.secondary.opacity(0.9))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.padding(.leading, 24)
|
||||
|
||||
#if DEBUG
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text("Match:")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(dependency.isMatchPlatform ? "✅" : "❌")
|
||||
.font(.caption2)
|
||||
|
||||
Text("•")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Target:")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(dependency.targetPlatform)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
if !dependency.selectedReason.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
Text("Reason:")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(dependency.selectedReason)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 22)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func getPlatformIcon(for platform: String) -> Image {
|
||||
switch platform {
|
||||
case "macarm64":
|
||||
return Image(systemName: "m.square")
|
||||
case "macuniversal":
|
||||
return Image(systemName: "m.circle")
|
||||
case "osx10", "osx10-64":
|
||||
return Image(systemName: "x.square")
|
||||
default:
|
||||
return Image(systemName: "questionmark.square")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModulesList: View {
|
||||
let modules: [Product.Platform.Module]
|
||||
|
||||
var body: some View {
|
||||
ForEach(modules, id: \.id) { module in
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color.blue.opacity(0.3))
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
Text(module.displayName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary.opacity(0.8))
|
||||
|
||||
if !module.deploymentType.isEmpty {
|
||||
Text("(\(module.deploymentType))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DuplicateTaskAlertView: View {
|
||||
let productId: String
|
||||
let version: String
|
||||
let onCancel: () -> Void
|
||||
let iconImage: NSImage?
|
||||
|
||||
private var productName: String {
|
||||
findProduct(id: productId)?.displayName ?? productId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
ZStack {
|
||||
if let iconImage = iconImage {
|
||||
Image(nsImage: iconImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
} else {
|
||||
Image(systemName: "app.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.orange)
|
||||
.background(Color.white)
|
||||
.clipShape(Circle())
|
||||
.offset(x: 24, y: -24)
|
||||
}
|
||||
|
||||
Text("下载任务已存在")
|
||||
.font(.headline)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("产品 \(productName) (版本 \(version)) 已有正在进行的下载任务")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("请在下载管理器中查看进度")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button("确定") {
|
||||
onCancel()
|
||||
}
|
||||
.buttonStyle(BeautifulButtonStyle(baseColor: Color.blue))
|
||||
.frame(width: 200)
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 400, height: 300)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(NSColor.windowBackgroundColor))
|
||||
.shadow(radius: 10)
|
||||
)
|
||||
.navigationTitle("任务提示")
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,17 @@ struct VersionPickerView: View {
|
||||
}
|
||||
|
||||
private func createCompletedCustomTask(path: URL, dependencies: [DependenciesToDownload]) async {
|
||||
let existingTask = globalNetworkManager.downloadTasks.first { task in
|
||||
return task.productId == productId &&
|
||||
task.productVersion == customDownloadVersion &&
|
||||
task.language == StorageData.shared.defaultLanguage &&
|
||||
task.directory == path.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
if existingTask != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let platform = globalProducts.first(where: { $0.id == productId && $0.version == customDownloadVersion })?.platforms.first?.id ?? "unknown"
|
||||
|
||||
let task = NewDownloadTask(
|
||||
|
||||
Reference in New Issue
Block a user