feat: 优化版本选择和自定义下载界面

This commit is contained in:
X1a0He
2025-07-19 22:27:42 +08:00
parent a9528a9bb5
commit b291fbe60f
7 changed files with 1829 additions and 290 deletions

View File

@@ -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 {

View File

@@ -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("无法解析产品信息")
}
// Module2.1.0PSRemove Tool components
var corePackageCount = 0
var nonCorePackageCount = 0
/*
1. non-core
2. core
a. Condition
b. Condition
i.
ii.
1. corenon-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(

View File

@@ -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)
}

View File

@@ -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()

View 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
)
}
}

View 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("任务提示")
}
}

View File

@@ -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(