Rewrite: Rewrite the dependency acquisition logic and product dependency logic, but there is a problem that the package download progress and download status cannot be updated.

This commit is contained in:
X1a0He
2024-11-03 00:12:38 +08:00
parent 3ada2bc26e
commit bc07240bc2
15 changed files with 2432 additions and 1973 deletions

View File

@@ -3,4 +3,22 @@
uuid = "05600D7B-4F3A-44C5-8A39-5E4971936E92"
type = "1"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "DEA1D888-6EDC-4454-A704-4F163A408945"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Adobe Downloader/Utils/XHXMLParser.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "46"
endingLineNumber = "46"
landmarkName = "unknown"
landmarkType = "0">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View File

@@ -4,6 +4,23 @@
// Created by X1a0He on 2024/10/30.
//
import Foundation
enum PackageStatus {
case waiting
case downloading
case paused
case completed
case failed(String)
var description: String {
switch self {
case .waiting: return "等待中"
case .downloading: return "下载中"
case .paused: return "已暂停"
case .completed: return "已完成"
case .failed(let message): return "失败: \(message)"
}
}
}
enum NetworkError: Error, LocalizedError {
case noConnection

View File

@@ -13,15 +13,15 @@ extension FileManager {
}
}
extension Product.ProductVersion {
extension Sap.Versions {
var size: Int64 {
return 0
}
}
extension DownloadTask {
extension NewDownloadTask {
var startTime: Date {
switch status {
switch totalStatus {
case .downloading(let info):
return info.startTime
case .completed(let info):
@@ -36,164 +36,13 @@ extension DownloadTask {
return info.timestamp
case .waiting:
return Date()
case .none:
return createAt
}
}
}
extension NetworkManager {
func handleDownloadCompletion(taskId: UUID, packageIndex: Int) async {
await MainActor.run {
guard let taskIndex = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
downloadTasks[taskIndex].packages[packageIndex].downloaded = true
downloadTasks[taskIndex].packages[packageIndex].progress = 1.0
downloadTasks[taskIndex].packages[packageIndex].status = .completed
if let nextPackageIndex = downloadTasks[taskIndex].packages.firstIndex(where: { !$0.downloaded }) {
downloadTasks[taskIndex].status = .downloading(DownloadTask.DownloadStatus.DownloadInfo(
fileName: downloadTasks[taskIndex].packages[nextPackageIndex].name,
currentPackageIndex: nextPackageIndex,
totalPackages: downloadTasks[taskIndex].packages.count,
startTime: Date(),
estimatedTimeRemaining: nil
))
Task {
await resumeDownload(taskId: taskId)
}
} else {
let startTime = downloadTasks[taskIndex].startTime
let totalTime = Date().timeIntervalSince(startTime)
downloadTasks[taskIndex].status = .completed(DownloadTask.DownloadStatus.CompletionInfo(
timestamp: Date(),
totalTime: totalTime,
totalSize: downloadTasks[taskIndex].totalSize
))
downloadTasks[taskIndex].progress = 1.0
progressObservers[taskId]?.invalidate()
progressObservers.removeValue(forKey: taskId)
if activeDownloadTaskId == taskId {
activeDownloadTaskId = nil
}
updateDockBadge()
objectWillChange.send()
Task {
do {
try await downloadUtils.clearExtendedAttributes(at: downloadTasks[taskIndex].destinationURL)
print("Successfully cleared extended attributes for \(downloadTasks[taskIndex].destinationURL.path)")
} catch {
print("Failed to clear extended attributes: \(error.localizedDescription)")
}
}
}
}
}
}
extension NetworkManager {
func getApplicationInfo(buildGuid: String) async throws -> ApplicationInfo {
guard let url = URL(string: NetworkConstants.applicationJsonURL) else {
throw NetworkError.invalidURL(NetworkConstants.applicationJsonURL)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
var headers = NetworkConstants.adobeRequestHeaders
headers["x-adobe-build-guid"] = buildGuid
headers["Accept"] = "application/json"
headers["Connection"] = "keep-alive"
headers["Cookie"] = generateCookie()
headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode, String(data: data, encoding: .utf8))
}
do {
let decoder = JSONDecoder()
let applicationInfo: ApplicationInfo = try decoder.decode(ApplicationInfo.self, from: data)
return applicationInfo
} catch {
throw NetworkError.parsingError(error, "Failed to parse application info")
}
}
func fetchProductsData() async throws -> ([String: Product], String) {
var components = URLComponents(string: NetworkConstants.productsXmlURL)
components?.queryItems = [
URLQueryItem(name: "_type", value: "xml"),
URLQueryItem(name: "channel", value: "ccm"),
URLQueryItem(name: "channel", value: "sti"),
URLQueryItem(name: "platform", value: "osx10-64,osx10,macarm64,macuniversal"),
URLQueryItem(name: "productType", value: "Desktop")
]
guard let url = components?.url else {
throw NetworkError.invalidURL(NetworkConstants.productsXmlURL)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
NetworkConstants.adobeRequestHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode, nil)
}
guard let xmlString = String(data: data, encoding: .utf8) else {
throw NetworkError.invalidData("无法解码XML数据")
}
let result: ([String: Product], String) = try await Task.detached(priority: .userInitiated) {
let parseResult = try XHXMLParser.parse(
xmlString: xmlString,
urlVersion: 6,
allowedPlatforms: Set(["osx10-64", "osx10", "macuniversal", "macarm64"])
)
return (parseResult.products, parseResult.cdn)
}.value
return result
}
func getDownloadPath(for fileName: String) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.main.async {
let panel = NSOpenPanel()
panel.title = "选择保存位置"
panel.canCreateDirectories = true
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
if panel.runModal() == .OK {
if let baseURL = panel.url {
continuation.resume(returning: baseURL)
} else {
continuation.resume(throwing: NetworkError.fileSystemError("未选择保存位置", nil))
}
} else {
continuation.resume(throwing: NetworkError.fileSystemError("用户取消了操作", nil))
}
}
}
}
func configureNetworkMonitor() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
@@ -202,18 +51,20 @@ extension NetworkManager {
self.isConnected = path.status == .satisfied
if !wasConnected && self.isConnected {
for task in self.downloadTasks where task.status.isPaused {
if case .paused(let info) = task.status,
for task in self.downloadTasks {
if case .paused(let info) = task.status,
info.reason == .networkIssue {
await self.resumeDownload(taskId: task.id)
}
}
} else if wasConnected && !self.isConnected {
for task in self.downloadTasks where task.status.isActive {
await self.downloadUtils.pauseDownloadTask(
taskId: task.id,
reason: DownloadTask.DownloadStatus.PauseInfo.PauseReason.networkIssue
)
for task in self.downloadTasks {
if case .downloading = task.status {
await self.downloadUtils.pauseDownloadTask(
taskId: task.id,
reason: .networkIssue
)
}
}
}
}
@@ -228,7 +79,13 @@ extension NetworkManager {
}
func updateDockBadge() {
let activeCount = downloadTasks.filter { $0.status.isActive }.count
let activeCount = downloadTasks.filter { task in
if case .downloading = task.status {
return true
}
return false
}.count
if activeCount > 0 {
NSApplication.shared.dockTile.badgeLabel = "\(activeCount)"
} else {

View File

@@ -5,6 +5,120 @@
//
import Foundation
class Package: Identifiable, ObservableObject {
let id = UUID()
var type: String
var fullPackageName: String
var downloadSize: Int64
var downloadURL: String
@Published var downloadedSize: Int64 = 0 {
didSet {
if downloadSize > 0 {
progress = Double(downloadedSize) / Double(downloadSize)
}
}
}
@Published var progress: Double = 0
@Published var speed: Double = 0 {
didSet {
objectWillChange.send()
}
}
@Published var status: PackageStatus = .waiting {
didSet {
objectWillChange.send()
}
}
@Published var downloaded: Bool = false
var lastUpdated: Date = Date()
var lastRecordedSize: Int64 = 0
init(type: String, fullPackageName: String, downloadSize: Int64, downloadURL: String) {
self.type = type
self.fullPackageName = fullPackageName
self.downloadSize = downloadSize
self.downloadURL = downloadURL
}
//
var formattedSize: String {
ByteCountFormatter.string(fromByteCount: downloadSize, countStyle: .file)
}
//
var hasValidSize: Bool {
downloadSize > 0
}
}
class ProductsToDownload {
var sapCode: String
var version: String
var buildGuid: String
var applicationJson: String?
var packages: [Package] = []
init(sapCode: String, version: String, buildGuid: String, applicationJson: String = "") {
self.sapCode = sapCode
self.version = version
self.buildGuid = buildGuid
self.applicationJson = applicationJson
}
}
struct SapCodes: Identifiable {
var id: String { sapCode }
var sapCode: String
var displayName: String
}
struct Sap: Identifiable {
var id: String { sapCode }
var hidden: Bool
var displayName: String
var sapCode: String
var versions: [String: Versions]
var icons: [ProductIcon]
var productsToDownload: [ProductsToDownload]? = nil
struct Versions {
var sapCode: String
var baseVersion: String
var productVersion: String
var apPlatform: String
var dependencies: [Dependencies]
var buildGuid: String
struct Dependencies {
var sapCode: String
var version: String
}
}
struct ProductIcon {
let size: String
let url: String
var dimension: Int {
let components = size.split(separator: "x")
if components.count == 2,
let dimension = Int(components[0]) {
return dimension
}
return 0
}
}
var isValid: Bool { !hidden }
func getBestIcon() -> ProductIcon? {
if let icon = icons.first(where: { $0.size == "192x192" }) {
return icon
}
return icons.max(by: { $0.dimension < $1.dimension })
}
}
struct NetworkConstants {
static let downloadTimeout: TimeInterval = 300
static let maxRetryAttempts = 3
@@ -30,394 +144,157 @@ struct NetworkConstants {
static let ADOBE_CC_MAC_ICON_PATH = "/Library/Application Support/Adobe/Adobe Desktop Common/HDBox/Install.app/Contents/Resources/CreativeCloudInstaller.icns"
static let MAC_VOLUME_ICON_PATH = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/CDAudioVolumeIcon.icns"
// script
static let INSTALL_APP_APPLE_SCRIPT = """
const app = Application.currentApplication()
app.includeStandardAdditions = true
const app = Application.currentApplication()
app.includeStandardAdditions = true
ObjC.import('Cocoa')
ObjC.import('stdio')
ObjC.import('stdlib')
ObjC.import('Cocoa')
ObjC.import('stdio')
ObjC.import('stdlib')
ObjC.registerSubclass({
name: 'HandleDataAction',
methods: {
'outData:': {
types: ['void', ['id']],
implementation: function(sender) {
const data = sender.object.availableData
if (data.length !== 0) {
const output = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js
const res = parseOutput(output)
if (res) {
switch (res.type) {
case 'progress':
Progress.additionalDescription = `Progress: ${res.data}%`
Progress.completedUnitCount = res.data
break
case 'exit':
if (res.data === 0) {
$.puts(JSON.stringify({ title: 'Installation succeeded' }))
} else {
$.puts(JSON.stringify({ title: `Failed with error code ${res.data}` }))
}
$.exit(0)
break
ObjC.registerSubclass({
name: 'HandleDataAction',
methods: {
'outData:': {
types: ['void', ['id']],
implementation: function(sender) {
const data = sender.object.availableData
if (data.length !== 0) {
const output = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js
const res = parseOutput(output)
if (res) {
switch (res.type) {
case 'progress':
Progress.additionalDescription = `Progress: ${res.data}%`
Progress.completedUnitCount = res.data
break
case 'exit':
if (res.data === 0) {
$.puts(JSON.stringify({ title: 'Installation succeeded' }))
} else {
$.puts(JSON.stringify({ title: `Failed with error code ${res.data}` }))
}
$.exit(0)
break
}
}
sender.object.waitForDataInBackgroundAndNotify
} else {
$.NSNotificationCenter.defaultCenter.removeObserver(this)
}
sender.object.waitForDataInBackgroundAndNotify
} else {
$.NSNotificationCenter.defaultCenter.removeObserver(this)
}
}
}
}
})
function parseOutput(output) {
let matches
matches = output.match(/Progress: ([0-9]{1,3})%/)
if (matches) {
return {
type: 'progress',
data: parseInt(matches[1], 10)
}
}
matches = output.match(/Exit Code: ([0-9]{1,3})/)
if (matches) {
return {
type: 'exit',
data: parseInt(matches[1], 10)
}
}
return false
}
function shellescape(a) {
var ret = []
a.forEach(function(s) {
if (/[^A-Za-z0-9_\\/:=-]/.test(s)) {
s = "'"+s.replace(/'/g,"'\\''")+"'"
s = s.replace(/^(?:'')+/g, '') // unduplicate single-quote at the beginning
.replace(/\\'''/g, "\\'") // remove non-escaped single-quote if there are enclosed between 2 escaped
}
ret.push(s)
})
return ret.join(' ')
}
function run() {
const appPath = app.pathTo(this).toString()
const driverPath = appPath + '/Contents/Resources/products/driver.xml'
const hyperDrivePath = '/Library/Application Support/Adobe/Adobe Desktop Common/HDBox/Setup'
if (!$.NSProcessInfo && parseFloat(app.doShellScript('sw_vers -productVersion')) >= 11.0) {
app.displayAlert('GUI unavailable in Big Sur', {
message: 'JXA is currently broken in Big Sur.\\nInstall in Terminal instead?',
buttons: ['Cancel', 'Install in Terminal'],
defaultButton: 'Install in Terminal',
cancelButton: 'Cancel'
})
const cmd = shellescape([ 'sudo', hyperDrivePath, '--install=1', '--driverXML=' + driverPath ])
app.displayDialog('Run this command in Terminal to install (press \\'OK\\' to copy to clipboard)', { defaultAnswer: cmd })
app.setTheClipboardTo(cmd)
return
}
const args = $.NSProcessInfo.processInfo.arguments
const argv = []
const argc = args.count
for (var i = 0; i < argc; i++) {
argv.push(ObjC.unwrap(args.objectAtIndex(i)))
}
delete args
const installFlag = argv.indexOf('-y') > -1
if (!installFlag) {
app.displayAlert('Adobe Package Installer', {
message: 'Start installation now?',
buttons: ['Cancel', 'Install'],
defaultButton: 'Install',
cancelButton: 'Cancel'
})
const output = app.doShellScript(`"${appPath}/Contents/MacOS/applet" -y`, { administratorPrivileges: true })
const alert = JSON.parse(output)
alert.params ? app.displayAlert(alert.title, alert.params) : app.displayAlert(alert.title)
return
}
function parseOutput(output) {
let matches
const stdout = $.NSPipe.pipe
const task = $.NSTask.alloc.init
task.executableURL = $.NSURL.alloc.initFileURLWithPath(hyperDrivePath)
task.arguments = $(['--install=1', '--driverXML=' + driverPath])
task.standardOutput = stdout
const dataAction = $.HandleDataAction.alloc.init
$.NSNotificationCenter.defaultCenter.addObserverSelectorNameObject(dataAction, 'outData:', $.NSFileHandleDataAvailableNotification, stdout.fileHandleForReading)
stdout.fileHandleForReading.waitForDataInBackgroundAndNotify
let err = $.NSError.alloc.initWithDomainCodeUserInfo('', 0, '')
const ret = task.launchAndReturnError(err)
if (!ret) {
$.puts(JSON.stringify({
title: 'Error',
params: {
message: 'Failed to launch task: ' + err.localizedDescription.UTF8String
}
}))
$.exit(0)
}
Progress.description = "Installing packages..."
Progress.additionalDescription = "Preparing"
Progress.totalUnitCount = 100
task.waitUntilExit
}
"""
}
struct ApplicationInfo: Codable {
let Name: String?
let SAPCode: String?
let CodexVersion: String?
let AssetGuid: String?
let ProductVersion: String?
let BaseVersion: String?
let Platform: String?
let LbsUrl: String?
let LanguageSet: String?
let Packages: PackagesContainer
let SupportedLanguages: SupportedLanguages?
let ConflictingProcesses: ConflictingProcesses?
let AMTConfig: AMTConfig?
let SystemRequirement: SystemRequirement?
let version: String?
let NglLicensingInfo: NglLicensingInfo?
let AppLineage: String?
let FamilyName: String?
let BuildGuid: String?
let selfServeBuild: Bool?
let HDBuilderVersion: String?
let IsSTI: Bool?
let AppsPanelFullAppUpdateConfig: AppsPanelFullAppUpdateConfig?
let Cdn: CdnInfo?
let WhatsNewUrl: UrlContainer?
let TutorialUrl: UrlContainer?
let AppLaunch: String?
let InstallDir: InstallDir?
let MoreInfoUrl: UrlContainer?
let AddRemoveInfo: AddRemoveInfo?
let AutoUpdate: String?
let AppsPanelPreviousVersionConfig: AppsPanelPreviousVersionConfig?
let ProductDescription: ProductDescription?
let IsNonCCProduct: Bool?
let CompressionType: String?
let MinimumSupportedClientVersion: String?
}
struct PackagesContainer: Codable {
let Package: [Package]
struct Package: Codable {
let PackageType: String?
let PackageName: String?
let PackageVersion: String?
let DownloadSize: Int64?
let ExtractSize: Int64?
let Path: String
let Format: String?
let ValidationURL: String?
let packageHashKey: String?
let DeltaPackages: [DeltaPackage]?
let ValidationURLs: ValidationURLs?
let Condition: String?
let InstallSequenceNumber: Int?
let fullPackageName: String?
let PackageValidation: String?
let AliasPackageName: String?
let PackageScheme: String?
let Features: Features?
var size: Int64 { DownloadSize ?? 0 }
}
}
struct DeltaPackage: Codable {
let SchemaVersion: String?
let PackageName: String?
let Path: String?
let BasePackageVersion: String?
let ValidationURL: String?
let DownloadSize: Int64?
let ExtractSize: Int64?
let packageHashKey: String?
}
struct ValidationURLs: Codable {
let TYPE1: String?
let TYPE2: String?
}
struct Features: Codable {
let Feature: [FeatureItem]
struct FeatureItem: Codable {
let name: String?
let value: String?
}
}
struct CdnInfo: Codable {
let Secure: String
let NonSecure: String
}
struct UrlContainer: Codable {
let Stage: LanguageContainer
let Prod: LanguageContainer
struct LanguageContainer: Codable {
let Language: [LanguageValue]
}
struct LanguageValue: Codable {
let value: String
let locale: String
}
}
struct InstallDir: Codable {
let value: String?
let maxPath: String?
}
struct AddRemoveInfo: Codable {
let DisplayName: LanguageContainer
let DisplayVersion: LanguageContainer?
let URLInfoAbout: LanguageContainer?
struct LanguageContainer: Codable {
let Language: [LanguageValue]
}
struct LanguageValue: Codable {
let value: String
let locale: String
}
}
struct AppsPanelPreviousVersionConfig: Codable {
let ListInPreviousVersion: Bool
let BrandingName: String
}
struct ProductDescription: Codable {
let Tagline: LanguageContainer?
let DetailedDescription: LanguageContainer?
struct LanguageContainer: Codable {
let Language: [LanguageValue]
struct LanguageValue: Codable {
let value: String
let locale: String
}
}
}
struct AppsPanelFullAppUpdateConfig: Codable {
let PreviousVersionRange: VersionRange
let ShowDialogBox: Bool
let ImportPreferenceCheckBox: PreferenceCheckBox
let RemovePreviousVersionCheckBox: PreferenceCheckBox
struct VersionRange: Codable {
let min: String
}
struct PreferenceCheckBox: Codable {
let DefaultValue: Bool
let Show: Bool
let AllowToggle: Bool
}
}
struct SupportedLanguages: Codable {
let Language: [LanguageInfo]
struct LanguageInfo: Codable {
let value: String
let locale: String
}
}
struct ConflictingProcesses: Codable {
let ConflictingProcess: [ConflictingProcess]
struct ConflictingProcess: Codable {
let RegularExpression: String
let ProcessDisplayName: String
let Reason: String
let RelativePath: String
let headless: Bool
let forceKillAllowed: Bool
let adobeOwned: Bool
}
}
struct AMTConfig: Codable {
let path: String
let LEID: String
let appID: String
}
struct SystemRequirement: Codable {
let OsVersion: OsVersion?
let SupportedOsVersionRange: [OsVersionRange]?
let ExternalUrl: ExternalUrl
let CheckCompatibility: CheckCompatibility
struct OsVersion: Codable {
let min: String
}
struct OsVersionRange: Codable {
let min: String
}
struct ExternalUrl: Codable {
let Stage: LanguageUrls
let Prod: LanguageUrls
struct LanguageUrls: Codable {
let Language: [LanguageUrl]
struct LanguageUrl: Codable {
let value: String
let locale: String
matches = output.match(/Progress: ([0-9]{1,3})%/)
if (matches) {
return {
type: 'progress',
data: parseInt(matches[1], 10)
}
}
}
struct CheckCompatibility: Codable {
let Content: String
}
}
struct NglLicensingInfo: Codable {
let AppId: String
let AppVersion: String
let LibVersion: String
let BuildId: String
let ImsClientId: String
}
matches = output.match(/Exit Code: ([0-9]{1,3})/)
if (matches) {
return {
type: 'exit',
data: parseInt(matches[1], 10)
}
}
return false
}
function shellescape(a) {
var ret = []
a.forEach(function(s) {
if (/[^A-Za-z0-9_\\/:=-]/.test(s)) {
s = "'"+s.replace(/'/g,"'\\''")+"'"
s = s.replace(/^(?:'')+/g, '') // unduplicate single-quote at the beginning
.replace(/\\'''/g, "\\'") // remove non-escaped single-quote if there are enclosed between 2 escaped
}
ret.push(s)
})
return ret.join(' ')
}
function run() {
const appPath = app.pathTo(this).toString()
const driverPath = appPath + '/Contents/Resources/products/driver.xml'
const hyperDrivePath = '/Library/Application Support/Adobe/Adobe Desktop Common/HDBox/Setup'
if (!$.NSProcessInfo && parseFloat(app.doShellScript('sw_vers -productVersion')) >= 11.0) {
app.displayAlert('GUI unavailable in Big Sur', {
message: 'JXA is currently broken in Big Sur.\\nInstall in Terminal instead?',
buttons: ['Cancel', 'Install in Terminal'],
defaultButton: 'Install in Terminal',
cancelButton: 'Cancel'
})
const cmd = shellescape([ 'sudo', hyperDrivePath, '--install=1', '--driverXML=' + driverPath ])
app.displayDialog('Run this command in Terminal to install (press \\'OK\\' to copy to clipboard)', { defaultAnswer: cmd })
app.setTheClipboardTo(cmd)
return
}
const args = $.NSProcessInfo.processInfo.arguments
const argv = []
const argc = args.count
for (var i = 0; i < argc; i++) {
argv.push(ObjC.unwrap(args.objectAtIndex(i)))
}
delete args
const installFlag = argv.indexOf('-y') > -1
if (!installFlag) {
app.displayAlert('Adobe Package Installer', {
message: 'Start installation now?',
buttons: ['Cancel', 'Install'],
defaultButton: 'Install',
cancelButton: 'Cancel'
})
const output = app.doShellScript(`"${appPath}/Contents/MacOS/applet" -y`, { administratorPrivileges: true })
const alert = JSON.parse(output)
alert.params ? app.displayAlert(alert.title, alert.params) : app.displayAlert(alert.title)
return
}
const stdout = $.NSPipe.pipe
const task = $.NSTask.alloc.init
task.executableURL = $.NSURL.alloc.initFileURLWithPath(hyperDrivePath)
task.arguments = $(['--install=1', '--driverXML=' + driverPath])
task.standardOutput = stdout
const dataAction = $.HandleDataAction.alloc.init
$.NSNotificationCenter.defaultCenter.addObserverSelectorNameObject(dataAction, 'outData:', $.NSFileHandleDataAvailableNotification, stdout.fileHandleForReading)
stdout.fileHandleForReading.waitForDataInBackgroundAndNotify
let err = $.NSError.alloc.initWithDomainCodeUserInfo('', 0, '')
const ret = task.launchAndReturnError(err)
if (!ret) {
$.puts(JSON.stringify({
title: 'Error',
params: {
message: 'Failed to launch task: ' + err.localizedDescription.UTF8String
}
}))
$.exit(0)
}
Progress.description = "Installing packages..."
Progress.additionalDescription = "Preparing"
Progress.totalUnitCount = 100
task.waitUntilExit
}
"""
}

View File

@@ -12,8 +12,8 @@ struct ContentView: View {
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
@State private var showLanguagePicker = false
private var filteredProducts: [Product] {
let products = networkManager.products.values
private var filteredProducts: [Sap] {
let products = networkManager.saps.values
.filter { !$0.hidden && !$0.versions.isEmpty }
.sorted { $0.displayName < $1.displayName }
@@ -133,8 +133,8 @@ struct ContentView: View {
columns: [GridItem(.adaptive(minimum: 250))],
spacing: 20
) {
ForEach(filteredProducts) { product in
AppCardView(product: product)
ForEach(filteredProducts, id: \.sapCode) { sap in
AppCardView(sap: sap)
}
}
.padding()
@@ -154,7 +154,8 @@ struct ContentView: View {
.environmentObject(networkManager)
}
.onAppear {
if networkManager.products.isEmpty {
if networkManager.saps.isEmpty {
refreshData()
}
}
@@ -215,80 +216,6 @@ struct SearchField: View {
#Preview {
let networkManager = NetworkManager()
let mockProducts: [String: Product] = [
"PHSP": Product(
id: "PHSP",
hidden: false,
displayName: "Photoshop",
sapCode: "PHSP",
versions: [
"25.0.0": Product.ProductVersion(
sapCode: "PHSP",
baseVersion: "25.0.0",
productVersion: "25.0.0",
apPlatform: "macuniversal",
dependencies: [],
buildGuid: ""
)
],
icons: [
Product.ProductIcon(
size: "192x192",
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/PHSP/25.0.0/192x192.png"
)
]
),
"ILST": Product(
id: "ILST",
hidden: false,
displayName: "Illustrator",
sapCode: "ILST",
versions: [
"28.0.0": Product.ProductVersion(
sapCode: "ILST",
baseVersion: "28.0.0",
productVersion: "28.0.0",
apPlatform: "macuniversal",
dependencies: [],
buildGuid: ""
)
],
icons: [
Product.ProductIcon(
size: "192x192",
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/ILST/28.0.0/192x192.png"
)
]
),
"AEFT": Product(
id: "AEFT",
hidden: false,
displayName: "After Effects",
sapCode: "AEFT",
versions: [
"24.0.0": Product.ProductVersion(
sapCode: "AEFT",
baseVersion: "24.0.0",
productVersion: "24.0.0",
apPlatform: "macuniversal",
dependencies: [],
buildGuid: ""
)
],
icons: [
Product.ProductIcon(
size: "192x192",
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/AEFT/24.0.0/192x192.png"
)
]
)
]
Task { @MainActor in
networkManager.products = mockProducts
networkManager.loadingState = .success
}
return ContentView()
.environmentObject(networkManager)

View File

@@ -5,264 +5,103 @@
//
import Foundation
class DownloadTask: Identifiable, ObservableObject, Equatable {
extension DownloadStatus {
var isCompleted: Bool {
if case .completed = self {
return true
}
return false
}
var isFailed: Bool {
if case .failed = self {
return true
}
return false
}
}
class NewDownloadTask: Identifiable, ObservableObject, Equatable {
let id = UUID()
let sapCode: String
var sapCode: String
let version: String
let language: String
let productName: String
@Published var status: DownloadStatus
@Published var progress: Double
@Published var downloadedSize: Int64
@Published var totalSize: Int64
@Published var speed: Double
@Published var currentFileName: String
let destinationURL: URL
var priority: Priority
let displayName: String
let directory: URL
var productsToDownload: [ProductsToDownload]
var retryCount: Int
let createdAt: Date
@Published var lastUpdated: Date
@Published var lastRecordedSize: Int64
@Published var packages: [Package]
@Published var detailedStatus: String = ""
enum Priority: Int {
case low = 0
case normal = 1
case high = 2
}
enum DownloadStatus {
case waiting
case preparing(PrepareInfo)
case downloading(DownloadInfo)
case paused(PauseInfo)
case completed(CompletionInfo)
case failed(FailureInfo)
case retrying(RetryInfo)
struct PrepareInfo: Equatable {
let message: String
let timestamp: Date
let stage: PrepareStage
enum PrepareStage: Equatable {
case initializing
case creatingInstaller
case signingApp
case fetchingInfo
case validatingSetup
}
let createAt: Date
@Published var totalStatus: DownloadStatus?
@Published var totalProgress: Double
@Published var totalDownloadedSize: Int64
@Published var totalSize: Int64
@Published var totalSpeed: Double
@Published var currentPackage: Package? {
didSet {
objectWillChange.send()
}
struct DownloadInfo: Equatable {
let fileName: String
let currentPackageIndex: Int
let totalPackages: Int
let startTime: Date
let estimatedTimeRemaining: TimeInterval?
}
struct PauseInfo: Equatable {
let reason: PauseReason
let timestamp: Date
let resumable: Bool
enum PauseReason: Equatable {
case userRequested
case networkIssue
case systemSleep
case other(String)
}
}
struct CompletionInfo: Equatable {
let timestamp: Date
let totalTime: TimeInterval
let totalSize: Int64
}
struct FailureInfo: Equatable {
let message: String
let error: Error?
let timestamp: Date
let recoverable: Bool
static func == (lhs: FailureInfo, rhs: FailureInfo) -> Bool {
lhs.message == rhs.message &&
lhs.timestamp == rhs.timestamp &&
lhs.recoverable == rhs.recoverable
}
}
struct RetryInfo: Equatable {
let attempt: Int
let maxAttempts: Int
let reason: String
let nextRetryDate: Date
}
var description: String {
switch self {
case .waiting:
return "等待中"
case .preparing(let info):
return "准备中: \(info.message)"
case .downloading(let info):
return "下载中: \(info.fileName) (\(info.currentPackageIndex + 1)/\(info.totalPackages))"
case .paused(let info):
switch info.reason {
case .userRequested: return "已暂停"
case .networkIssue: return "网络中断"
case .systemSleep: return "系统休眠"
case .other(let reason): return "已暂停: \(reason)"
}
case .completed(let info):
let duration = String(format: "%.1f", info.totalTime)
return "已完成 (用时: \(duration)秒)"
case .failed(let info):
return "失败: \(info.message)"
case .retrying(let info):
return "重试中 (\(info.attempt)/\(info.maxAttempts))"
}
}
var sortOrder: Int {
switch self {
case .downloading: return 0
case .preparing: return 1
case .waiting: return 2
case .paused: return 3
case .retrying: return 4
case .failed: return 5
case .completed: return 6
}
}
var isFinished: Bool {
switch self {
case .completed, .failed:
return true
default:
return false
}
}
var isPaused: Bool {
if case .paused = self {
return true
}
return false
}
var isActive: Bool {
switch self {
case .downloading, .preparing, .retrying:
return true
default:
return false
}
}
var isCompleted: Bool {
if case .completed = self {
return true
}
return false
}
var isFailed: Bool {
if case .failed = self {
return true
}
return false
}
}
enum PackageStatus {
case waiting
case downloading
case paused
case completed
case failed(String)
var description: String {
switch self {
case .waiting: return "等待中"
case .downloading: return "下载中"
case .paused: return "已暂停"
case .completed: return "已完成"
case .failed(let message): return "失败: \(message)"
}
}
}
struct Package: Identifiable {
let id = UUID()
var name: String
var Path: String
var size: Int64
var downloadedSize: Int64 = 0
var progress: Double = 0
var speed: Double = 0
var status: PackageStatus = .waiting
var type: String
var downloaded: Bool = false
var lastUpdated: Date = Date()
var lastRecordedSize: Int64 = 0
}
init(sapCode: String, version: String, language: String, productName: String,
status: DownloadStatus = .waiting, progress: Double = 0,
downloadedSize: Int64 = 0, totalSize: Int64 = 0, speed: Double = 0,
currentFileName: String = "", destinationURL: URL,
priority: Priority = .normal, retryCount: Int = 0,
packages: [Package] = [], detailedStatus: String = "") {
var status: DownloadStatus {
totalStatus ?? .waiting
}
var destinationURL: URL { directory }
var downloadedSize: Int64 {
get { totalDownloadedSize }
set { totalDownloadedSize = newValue }
}
var progress: Double {
get { totalProgress }
set { totalProgress = newValue }
}
var speed: Double {
get { totalSpeed }
set { totalSpeed = newValue }
}
var formattedTotalSize: String {
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
}
var formattedDownloadedSize: String {
ByteCountFormatter.string(fromByteCount: totalDownloadedSize, countStyle: .file)
}
func setStatus(_ newStatus: DownloadStatus) {
totalStatus = newStatus
objectWillChange.send()
}
func updateProgress(downloaded: Int64, total: Int64, speed: Double) {
totalDownloadedSize = downloaded
totalSize = total
totalSpeed = speed
totalProgress = total > 0 ? Double(downloaded) / Double(total) : 0
objectWillChange.send()
}
init(sapCode: String, version: String, language: String, displayName: String, directory: URL, productsToDownload: [ProductsToDownload] = [], retryCount: Int = 0, createAt: Date, totalStatus: DownloadStatus? = nil, totalProgress: Double, totalDownloadedSize: Int64 = 0, totalSize: Int64 = 0, totalSpeed: Double = 0, currentPackage: Package? = nil) {
self.sapCode = sapCode
self.version = version
self.language = language
self.productName = productName
self.status = status
self.progress = progress
self.downloadedSize = downloadedSize
self.totalSize = totalSize
self.speed = speed
self.currentFileName = currentFileName
self.destinationURL = destinationURL
self.priority = priority
self.displayName = displayName
self.directory = directory
self.productsToDownload = productsToDownload
self.retryCount = retryCount
self.createdAt = Date()
self.lastUpdated = Date()
self.lastRecordedSize = 0
self.packages = packages
self.detailedStatus = detailedStatus
self.createAt = createAt
self.totalStatus = totalStatus
self.totalProgress = totalProgress
self.totalDownloadedSize = totalDownloadedSize
self.totalSize = totalSize
self.totalSpeed = totalSpeed
self.currentPackage = currentPackage
}
private func updateProgress(_ newProgress: Double) {
objectWillChange.send()
progress = newProgress
}
private func updateSpeed(_ newSpeed: Double) {
objectWillChange.send()
speed = newSpeed
}
static func == (lhs: DownloadTask, rhs: DownloadTask) -> Bool {
lhs.id == rhs.id
}
}
extension DownloadTask.DownloadStatus: Equatable {
static func == (lhs: DownloadTask.DownloadStatus, rhs: DownloadTask.DownloadStatus) -> Bool {
switch (lhs, rhs) {
case (.waiting, .waiting): return true
case (.downloading, .downloading): return true
case (.paused, .paused): return true
case (.completed, .completed): return true
case (.failed(let lhsMessage), .failed(let rhsMessage)): return lhsMessage == rhsMessage
case (.retrying(let lhsCount), .retrying(let rhsCount)): return lhsCount == rhsCount
default: return false
}
static func == (lhs: NewDownloadTask, rhs: NewDownloadTask) -> Bool {
return lhs.id == rhs.id
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ class DownloadUtils {
var progressHandler: ((Int64, Int64, Int64) -> Void)?
var destinationDirectory: URL
var fileName: String
private var hasCompleted = false
private let completionLock = NSLock()
init(destinationDirectory: URL,
fileName: String,
@@ -37,6 +39,12 @@ class DownloadUtils {
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
completionLock.lock()
defer { completionLock.unlock() }
guard !hasCompleted else { return }
hasCompleted = true
do {
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
@@ -49,17 +57,19 @@ class DownloadUtils {
}
try FileManager.default.moveItem(at: location, to: destinationURL)
Thread.sleep(forTimeInterval: 0.5)
if FileManager.default.fileExists(atPath: destinationURL.path) {
let fileSize = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0
print("File size verification - Expected: \(downloadTask.countOfBytesExpectedToReceive), Actual: \(fileSize)")
completionHandler(destinationURL, downloadTask.response, nil)
} else {
completionHandler(nil, downloadTask.response, NetworkError.fileSystemError("文件移动后不存在", nil))
let expectedSize = downloadTask.countOfBytesExpectedToReceive
if expectedSize > 0,
let fileSize = try? FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 {
print("File size verification - Expected: \(expectedSize), Actual: \(fileSize)")
if fileSize != expectedSize {
print("Warning: File size mismatch - Expected: \(expectedSize), Actual: \(fileSize)")
}
}
completionHandler(destinationURL, downloadTask.response, nil)
} catch {
print("File operation error in delegate: \(error.localizedDescription)")
completionHandler(nil, downloadTask.response, error)
@@ -67,17 +77,23 @@ class DownloadUtils {
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error = error else { return }
completionLock.lock()
defer { completionLock.unlock() }
switch (error as NSError).code {
case NSURLErrorCancelled:
return
case NSURLErrorTimedOut:
completionHandler(nil, task.response, NetworkError.downloadError("下载超时", error))
case NSURLErrorNotConnectedToInternet:
completionHandler(nil, task.response, NetworkError.noConnection)
default:
completionHandler(nil, task.response, error)
guard !hasCompleted else { return }
hasCompleted = true
if let error = error {
switch (error as NSError).code {
case NSURLErrorCancelled:
return
case NSURLErrorTimedOut:
completionHandler(nil, task.response, NetworkError.downloadError("下载超时", error))
case NSURLErrorNotConnectedToInternet:
completionHandler(nil, task.response, NetworkError.noConnection)
default:
completionHandler(nil, task.response, error)
}
}
}
@@ -90,178 +106,51 @@ class DownloadUtils {
progressHandler?(bytesWritten, totalBytesWritten, totalBytesExpectedToWrite)
}
func cleanup() {
completionHandler = { _, _, _ in }
progressHandler = nil
}
}
func pauseDownloadTask(taskId: UUID, reason: DownloadTask.DownloadStatus.PauseInfo.PauseReason = .userRequested) async {
await cancelTracker.pause(taskId)
await networkManager?.setTaskStatus(taskId, .paused(DownloadTask.DownloadStatus.PauseInfo(
reason: reason,
timestamp: Date(),
resumable: true
)))
func pauseDownloadTask(taskId: UUID, reason: DownloadStatus.PauseInfo.PauseReason) async {
if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) {
task.setStatus(.paused(DownloadStatus.PauseInfo(
reason: reason,
timestamp: Date(),
resumable: true
)))
await cancelTracker.pause(taskId)
}
}
func resumeDownloadTask(taskId: UUID) async {
guard let networkManager = networkManager,
let task = await networkManager.getTasks().first(where: { $0.id == taskId }) else { return }
if let activeId = await networkManager.getActiveTaskId(), activeId != taskId {
await cancelTracker.cancel(activeId)
}
guard let packageIndex = task.packages.firstIndex(where: { !$0.downloaded }) else {
await networkManager.setTaskStatus(taskId, .completed(DownloadTask.DownloadStatus.CompletionInfo(
timestamp: Date(),
totalTime: Date().timeIntervalSince(task.startTime),
totalSize: task.totalSize
if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) {
task.setStatus(.downloading(DownloadStatus.DownloadInfo(
fileName: task.currentPackage?.fullPackageName ?? "",
currentPackageIndex: 0,
totalPackages: task.productsToDownload.reduce(0) { $0 + $1.packages.count },
startTime: Date(),
estimatedTimeRemaining: nil
)))
return
//
await startDownloadProcess(task: task)
}
let package = task.packages[packageIndex]
let delegate = DownloadDelegate(
destinationDirectory: task.destinationURL.appendingPathComponent("Contents/Resources/products/\(task.sapCode)"),
fileName: package.Path.components(separatedBy: "/").last ?? "",
completionHandler: { [weak networkManager] localURL, response, error in
guard let networkManager = networkManager else { return }
Task {
if let error = error {
await networkManager.handleError(taskId, error)
return
}
if let localURL = localURL {
do {
let fileSize = try FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64 ?? 0
guard fileSize >= package.size else {
throw NetworkError.dataValidationError("文件大小不正确")
}
await networkManager.handleDownloadCompletion(taskId: taskId, packageIndex: packageIndex)
} catch {
print("File validation error: \(error.localizedDescription)")
await networkManager.handleError(taskId, error)
}
}
}
},
progressHandler: { [weak networkManager] bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in
guard let networkManager = networkManager else { return }
Task { @MainActor in
networkManager.updateDownloadProgress(for: taskId, progress: (
bytesWritten: bytesWritten,
totalWritten: totalBytesWritten,
expectedToWrite: totalBytesExpectedToWrite
))
}
}
)
let config = URLSessionConfiguration.default
config.timeoutIntervalForResource = NetworkConstants.downloadTimeout
config.timeoutIntervalForRequest = NetworkConstants.downloadTimeout
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
var downloadTask: URLSessionDownloadTask
if let resumeData = await cancelTracker.getResumeData(taskId) {
downloadTask = session.downloadTask(withResumeData: resumeData)
} else {
let downloadURL: String
if task.sapCode == "APRO" {
downloadURL = await package.Path.hasPrefix("https://") ? package.Path : networkManager.cdn + package.Path
} else {
downloadURL = await networkManager.cdn + package.Path
}
guard let url = URL(string: downloadURL) else {
await networkManager.handleError(taskId, NetworkError.invalidURL(downloadURL))
return
}
var request = URLRequest(url: url)
NetworkConstants.downloadHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
downloadTask = session.downloadTask(with: request)
}
await cancelTracker.registerTask(taskId, task: downloadTask, session: session)
await networkManager.setTaskStatus(taskId, .downloading(DownloadTask.DownloadStatus.DownloadInfo(
fileName: package.name,
currentPackageIndex: packageIndex,
totalPackages: task.packages.count,
startTime: Date(),
estimatedTimeRemaining: nil
)))
downloadTask.resume()
}
func cancelDownloadTask(taskId: UUID, removeFiles: Bool = false) async {
await cancelTracker.cancel(taskId)
if removeFiles {
if let task = await networkManager?.getTasks().first(where: { $0.id == taskId }) {
try? FileManager.default.removeItem(at: task.destinationURL)
if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) {
if removeFiles {
try? FileManager.default.removeItem(at: task.directory)
}
task.setStatus(.failed(DownloadStatus.FailureInfo(
message: "下载已取消",
error: NetworkError.downloadCancelled,
timestamp: Date(),
recoverable: false
)))
}
await networkManager?.setTaskStatus(taskId, .failed(DownloadTask.DownloadStatus.FailureInfo(
message: "下载已取消",
error: NetworkError.downloadCancelled,
timestamp: Date(),
recoverable: false
)))
}
func downloadAPRO(task: DownloadTask, productInfo: Product.ProductVersion) async throws {
guard let networkManager = networkManager else { return }
let manifestURL = await networkManager.cdnUrl + productInfo.buildGuid
print("Manifest URL:", manifestURL)
guard let url = URL(string: manifestURL) else {
throw NetworkError.invalidURL(manifestURL)
}
var request = URLRequest(url: url)
NetworkConstants.adobeRequestHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
let (manifestData, _) = try await URLSession.shared.data(for: request)
let manifestXML = try XMLDocument(data: manifestData)
guard let downloadPath = try manifestXML.nodes(forXPath: "//asset_list/asset/asset_path").first?.stringValue,
let assetSizeStr = try manifestXML.nodes(forXPath: "//asset_list/asset/asset_size").first?.stringValue,
let assetSize = Int64(assetSizeStr) else {
throw NetworkError.invalidData("无法从manifest中获取下载信息")
}
await MainActor.run {
if let index = networkManager.downloadTasks.firstIndex(where: { $0.id == task.id }) {
networkManager.downloadTasks[index].packages = [
DownloadTask.Package(
name: "Acrobat_DC_Web_WWMUI.dmg",
Path: downloadPath,
size: assetSize,
downloadedSize: 0,
progress: 0,
speed: 0,
status: .waiting,
type: "core",
downloaded: false,
lastUpdated: Date(),
lastRecordedSize: 0
)
]
networkManager.downloadTasks[index].totalSize = assetSize
}
}
await networkManager.resumeDownload(taskId: task.id)
}
func signApp(at url: URL) async throws {
@@ -329,8 +218,7 @@ class DownloadUtils {
)
}
func generateDriverXML(sapCode: String, version: String, language: String,
productInfo: Product.ProductVersion, displayName: String) -> String {
func generateDriverXML(sapCode: String, version: String, language: String, productInfo: Sap.Versions, displayName: String) -> String {
let dependencies = productInfo.dependencies.map { dependency in
"""
<Dependency>
@@ -391,4 +279,189 @@ class DownloadUtils {
print("Error executing xattr command:", error.localizedDescription)
}
}
internal func startDownloadProcess(task: NewDownloadTask) async {
//
await MainActor.run {
let totalPackages = task.productsToDownload.reduce(0) { $0 + $1.packages.count }
task.setStatus(.downloading(DownloadStatus.DownloadInfo(
fileName: task.currentPackage?.fullPackageName ?? "",
currentPackageIndex: 0,
totalPackages: totalPackages,
startTime: Date(),
estimatedTimeRemaining: nil
)))
task.objectWillChange.send()
}
var currentPackageIndex = 0
let totalPackages = task.productsToDownload.reduce(0) { $0 + $1.packages.count }
for product in task.productsToDownload {
for package in product.packages where !package.downloaded {
await MainActor.run {
task.currentPackage = package
task.setStatus(.downloading(DownloadStatus.DownloadInfo(
fileName: package.fullPackageName,
currentPackageIndex: currentPackageIndex,
totalPackages: totalPackages,
startTime: Date(),
estimatedTimeRemaining: nil
)))
}
currentPackageIndex += 1
//
guard !package.fullPackageName.isEmpty,
!package.downloadURL.isEmpty,
package.downloadSize > 0 else {
print("Warning: Skipping invalid package in \(product.sapCode)")
continue
}
// URL
let cdn = await networkManager?.cdn ?? ""
let cleanCdn = cdn.hasSuffix("/") ? String(cdn.dropLast()) : cdn
let cleanPath = package.downloadURL.hasPrefix("/") ? package.downloadURL : "/\(package.downloadURL)"
let downloadURL = cleanCdn + cleanPath
guard let url = URL(string: downloadURL) else {
print("Error: Invalid download URL: \(downloadURL)")
continue
}
print("Starting download for \(package.fullPackageName) from \(downloadURL)")
// 使 async/await
do {
try await downloadPackage(package: package, task: task, product: product, url: url)
print("Completed download for \(package.fullPackageName)")
} catch {
print("Error downloading \(package.fullPackageName): \(error.localizedDescription)")
await networkManager?.handleError(task.id, error)
return
}
}
}
//
let allPackagesDownloaded = task.productsToDownload.allSatisfy { product in
product.packages.allSatisfy { $0.downloaded }
}
if allPackagesDownloaded {
task.setStatus(.completed(DownloadStatus.CompletionInfo(
timestamp: Date(),
totalTime: Date().timeIntervalSince(task.createAt),
totalSize: task.totalSize
)))
}
}
//
private func downloadPackage(package: Package, task: NewDownloadTask, product: ProductsToDownload, url: URL) async throws {
var lastUpdateTime = Date()
var lastBytes: Int64 = 0
return try await withCheckedThrowingContinuation { continuation in
let delegate = DownloadDelegate(
destinationDirectory: task.directory.appendingPathComponent("Contents/Resources/products/\(product.sapCode)"),
fileName: package.fullPackageName,
completionHandler: { [weak networkManager] localURL, response, error in
Task { @MainActor in
if let error = error {
continuation.resume(throwing: error)
return
}
package.downloaded = true
package.progress = 1.0
package.status = .completed
package.speed = 0
//
let totalDownloaded = task.productsToDownload.reduce(0) { sum, product in
sum + product.packages.reduce(0) { sum, pkg in
sum + (pkg.downloaded ? pkg.downloadSize : 0)
}
}
task.totalDownloadedSize = totalDownloaded
task.totalProgress = Double(totalDownloaded) / Double(task.totalSize)
task.totalSpeed = 0
task.objectWillChange.send()
networkManager?.objectWillChange.send()
continuation.resume()
}
},
progressHandler: { [weak networkManager] bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in
Task { @MainActor in
let now = Date()
let timeDiff = now.timeIntervalSince(lastUpdateTime)
//
if timeDiff >= 1.0 {
// (bytes/s)
let bytesDiff = totalBytesWritten - lastBytes
let speed = Double(bytesDiff) / timeDiff
//
package.downloadedSize = totalBytesWritten
package.progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
package.speed = speed
package.status = .downloading
//
let productTotalSize = product.packages.reduce(Int64(0)) { $0 + $1.downloadSize }
let productDownloaded = product.packages.reduce(Int64(0)) { sum, pkg in
if pkg.downloaded {
return sum + pkg.downloadSize
} else if pkg.id == package.id {
return sum + totalBytesWritten
}
return sum
}
//
let totalDownloaded = task.productsToDownload.reduce(Int64(0)) { sum, prod in
if prod.sapCode == product.sapCode {
return sum + productDownloaded
} else {
return sum + prod.packages.reduce(Int64(0)) { sum, pkg in
sum + (pkg.downloaded ? pkg.downloadSize : 0)
}
}
}
task.totalDownloadedSize = totalDownloaded
task.totalProgress = Double(totalDownloaded) / Double(task.totalSize)
task.totalSpeed = speed
//
lastUpdateTime = now
lastBytes = totalBytesWritten
// UI
package.objectWillChange.send()
task.objectWillChange.send()
networkManager?.objectWillChange.send()
}
}
}
)
var request = URLRequest(url: url)
NetworkConstants.downloadHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
let downloadTask = session.downloadTask(with: request)
Task {
await cancelTracker.registerTask(task.id, task: downloadTask, session: session)
}
downloadTask.resume()
}
}
}

View File

@@ -92,6 +92,9 @@ actor InstallManager {
let installProcess = Process()
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
installProcess.arguments = ["-S", setupPath, "--install=1", "--driverXML=\(driverPath)"]
print("执行安装命令: \(installProcess.executableURL!.path) \(installProcess.arguments!.joined(separator: " "))")
let inputPipe = Pipe()
let outputPipe = Pipe()
installProcess.standardInput = inputPipe
@@ -217,6 +220,9 @@ actor InstallManager {
let installProcess = Process()
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
installProcess.arguments = [setupPath, "--install=1", "--driverXML=\(driverPath)"]
print("执行重试命令: \(installProcess.executableURL!.path) \(installProcess.arguments!.joined(separator: " "))")
let outputPipe = Pipe()
installProcess.standardOutput = outputPipe
installProcess.standardError = outputPipe

View File

@@ -5,79 +5,94 @@
//
import Foundation
struct Product: Identifiable {
let id: String
var hidden: Bool
var displayName: String
var sapCode: String
var versions: [String: ProductVersion]
var icons: [ProductIcon]
// struct Product: Identifiable {
// let id: String
// var hidden: Bool
// var displayName: String
// var sapCode: String
// var versions: [String: ProductVersion]
// var icons: [ProductIcon]
// var dependencyType: String?
// var family: String?
// var familyName: String?
// var appLineage: String?
// var type: String?
// var categories: [String]?
struct ProductVersion {
var sapCode: String
var baseVersion: String
var productVersion: String
var apPlatform: String
var dependencies: [Dependency]
var buildGuid: String
}
// struct ProductVersion {
// var sapCode: String
// var baseVersion: String
// var productVersion: String
// var apPlatform: String
// var dependencies: [Dependency]
// var buildGuid: String
// var packageCode: String?
// var productCode: String?
// var installSize: Int?
// var esdData: EsdData?
// }
struct Dependency {
var sapCode: String
var version: String
}
// struct EsdData {
// var name: String
// var size: Int64
// var assetGuid: String
// }
struct ProductIcon {
let size: String
let url: String
// struct Dependency {
// var sapCode: String
// var version: String
// var esdDirectory: String?
// }
var dimension: Int {
let components = size.split(separator: "x")
if components.count == 2,
let dimension = Int(components[0]) {
return dimension
}
return 0
}
}
// struct ProductIcon {
// let size: String
// let url: String
var isValid: Bool {
return !sapCode.isEmpty &&
!displayName.isEmpty &&
!versions.isEmpty
}
// var dimension: Int {
// let components = size.split(separator: "x")
// if components.count == 2,
// let dimension = Int(components[0]) {
// return dimension
// }
// return 0
// }
// }
func getBestIcon() -> ProductIcon? {
if let icon = icons.first(where: { $0.size == "192x192" }) {
return icon
}
// var isValid: Bool {
// return !sapCode.isEmpty &&
// !displayName.isEmpty &&
// !versions.isEmpty
// }
return icons.max(by: { $0.dimension < $1.dimension })
}
}
// func getBestIcon() -> ProductIcon? {
// if let icon = icons.first(where: { $0.size == "192x192" }) {
// return icon
// }
// return icons.max(by: { $0.dimension < $1.dimension })
// }
// }
struct ParseResult {
var products: [String: Product]
var products: [String: Sap]
var cdn: String
}
class XHXMLParser {
static func parseProductsXML(xmlData: Data, urlVersion: Int, allowedPlatforms: Set<String>) throws -> ParseResult {
static func parseProductsXML(xmlData: Data) throws -> ParseResult {
let xml = try XMLDocument(data: xmlData)
let prefix = urlVersion == 6 ? "channels/" : ""
guard let cdn = try xml.nodes(forXPath: "//" + prefix + "channel/cdn/secure").first?.stringValue else {
let allowedPlatforms = Set(["osx10-64", "osx10", "macuniversal", "macarm64"])
guard let cdn = try xml.nodes(forXPath: "//channels/channel/cdn/secure").first?.stringValue else {
throw ParserError.missingCDN
}
var products: [String: Product] = [:]
print("parseProductsXML - cdn: \(cdn)")
let productNodes = try xml.nodes(forXPath: "//" + prefix + "channel/products/product")
var products: [String: Sap] = [:]
let productNodes = try xml.nodes(forXPath: "//channels/channel/products/product")
let parentMap = createParentMap(xml.rootElement())
for productNode in productNodes {
guard let element = productNode as? XMLElement else { continue }
@@ -85,113 +100,81 @@ class XHXMLParser {
let parentElement = parentMap[parentMap[element] ?? element]
let hidden = (parentElement as? XMLElement)?.attribute(forName: "name")?.stringValue != "ccm"
let displayName = try element.nodes(forXPath: "displayName").first?.stringValue ?? ""
let productVersion = element.attribute(forName: "version")?.stringValue ?? ""
var productVersion = element.attribute(forName: "version")?.stringValue ?? ""
if products[sap] == nil {
let productIcons = try element.nodes(forXPath: "productIcons/icon").compactMap { iconNode -> Product.ProductIcon? in
guard let iconElement = iconNode as? XMLElement,
let size = iconElement.attribute(forName: "size")?.stringValue,
let url = iconElement.stringValue
else { return nil }
return Product.ProductIcon(size: size, url: url)
let icons = try element.nodes(forXPath: "productIcons/icon").compactMap { node -> Sap.ProductIcon? in
guard let element = node as? XMLElement,
let size = element.attribute(forName: "size")?.stringValue,
let url = element.stringValue else {
return nil
}
return Sap.ProductIcon(size: size, url: url)
}
products[sap] = Product(
id: sap,
products[sap] = Sap(
hidden: hidden,
displayName: displayName,
sapCode: sap,
versions: [:],
icons: productIcons
icons: icons
)
}
let platforms = try element.nodes(forXPath: "platforms/platform")
for platformNode in platforms {
guard let platform = platformNode as? XMLElement else { continue }
guard let platform = platformNode as? XMLElement,
let languageSet = try platform.nodes(forXPath: "languageSet").first as? XMLElement else { continue }
let appPlatform = platform.attribute(forName: "id")?.stringValue ?? ""
guard let languageSet = try platform.nodes(forXPath: "languageSet").first as? XMLElement else { continue }
let baseVersion = languageSet.attribute(forName: "baseVersion")?.stringValue ?? ""
var baseVersion = languageSet.attribute(forName: "baseVersion")?.stringValue ?? ""
var buildGuid = languageSet.attribute(forName: "buildGuid")?.stringValue ?? ""
let currentProductVersion = productVersion
let appPlatform = platform.attribute(forName: "id")?.stringValue ?? ""
let dependencies = try languageSet.nodes(forXPath: "dependencies/dependency").compactMap { node -> Sap.Versions.Dependencies? in
guard let element = node as? XMLElement,
let sapCode = try element.nodes(forXPath: "sapCode").first?.stringValue,
let version = try element.nodes(forXPath: "baseVersion").first?.stringValue else {
return nil
}
return Sap.Versions.Dependencies(sapCode: sapCode, version: version)
}
if let existingVersion = products[sap]?.versions[productVersion],
allowedPlatforms.contains(existingVersion.apPlatform) {
continue
break
}
if sap == "APRO" {
let baseVersion = productVersion
var currentProductVersion = productVersion
if urlVersion == 4 || urlVersion == 5 {
if let appVersion = try languageSet.nodes(forXPath: "nglLicensingInfo/appVersion").first?.stringValue {
currentProductVersion = appVersion
baseVersion = productVersion
let buildNodes = try xml.nodes(forXPath: "//builds/build")
for buildNode in buildNodes {
guard let buildElement = buildNode as? XMLElement,
buildElement.attribute(forName: "id")?.stringValue == sap,
buildElement.attribute(forName: "version")?.stringValue == baseVersion else {
continue
}
} else if urlVersion == 6 {
currentProductVersion = productVersion
let builds = try xml.nodes(forXPath: "//builds/build")
for build in builds {
guard let buildElement = build as? XMLElement,
buildElement.attribute(forName: "id")?.stringValue == sap,
buildElement.attribute(forName: "version")?.stringValue == baseVersion else {
continue
}
if let appVersion = try buildElement.nodes(forXPath: "nglLicensingInfo/appVersion").first?.stringValue {
productVersion = appVersion
break
}
}
buildGuid = try languageSet.nodes(forXPath: "urls/manifestURL").first?.stringValue ?? buildGuid
if !buildGuid.isEmpty && allowedPlatforms.contains(appPlatform) {
let version = Product.ProductVersion(
sapCode: sap,
baseVersion: baseVersion,
productVersion: currentProductVersion,
apPlatform: appPlatform,
dependencies: [],
buildGuid: buildGuid
)
products[sap]?.versions[currentProductVersion] = version
}
continue
buildGuid = try languageSet.nodes(forXPath: "urls/manifestURL").first?.stringValue ?? ""
}
let dependencies = try languageSet.nodes(forXPath: "dependencies/dependency").compactMap { node -> Product.Dependency? in
guard let element = node as? XMLElement,
let sapCode = try element.nodes(forXPath: "sapCode").first?.stringValue,
let version = try element.nodes(forXPath: "baseVersion").first?.stringValue
else { return nil }
return Product.Dependency(sapCode: sapCode, version: version)
}
if !buildGuid.isEmpty && allowedPlatforms.contains(appPlatform) {
let version = Product.ProductVersion(
let version = Sap.Versions(
sapCode: sap,
baseVersion: baseVersion,
productVersion: currentProductVersion,
productVersion: productVersion,
apPlatform: appPlatform,
dependencies: dependencies,
buildGuid: buildGuid
)
products[sap]?.versions[currentProductVersion] = version
products[sap]?.versions[productVersion] = version
}
}
}
let validProducts = products.filter { product in
!product.value.hidden &&
product.value.isValid &&
!product.value.versions.isEmpty
}
return ParseResult(products: validProducts, cdn: cdn)
return ParseResult(products: products, cdn: cdn)
}
private static func createParentMap(_ root: XMLNode?) -> [XMLNode: XMLNode] {
@@ -219,10 +202,10 @@ enum ParserError: Error {
}
extension XHXMLParser {
static func parse(xmlString: String, urlVersion: Int, allowedPlatforms: Set<String>) throws -> ParseResult {
static func parse(xmlString: String) throws -> ParseResult {
guard let data = xmlString.data(using: .utf8) else {
throw ParserError.invalidXML
}
return try parseProductsXML(xmlData: data, urlVersion: urlVersion, allowedPlatforms: allowedPlatforms)
return try parseProductsXML(xmlData: data)
}
}

View File

@@ -25,79 +25,54 @@ class IconCache {
}
struct AppCardView: View {
let product: Product
let sap: Sap
@EnvironmentObject private var networkManager: NetworkManager
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
@AppStorage("useDefaultDirectory") private var useDefaultDirectory: Bool = true
@AppStorage("useDefaultLanguage") private var useDefaultLanguage: Bool = true
@AppStorage("defaultLanguage") private var defaultLanguage: String = "zh_CN"
@State private var showError: Bool = false
@State private var errorMessage: String = ""
@State private var showVersionPicker = false
@State private var selectedVersion: String = ""
@State private var iconImage: NSImage? = nil
@State private var showLanguagePicker = false
@State private var selectedLanguage = ""
private var isDownloading: Bool {
networkManager.downloadTasks.contains { task in
if task.sapCode == product.sapCode {
if case .downloading = task.status {
return true
}
if case .preparing = task.status {
return true
}
if case .waiting = task.status {
return true
}
if case .retrying = task.status {
return true
}
}
networkManager.downloadTasks.contains(where: isTaskDownloading)
}
private func isTaskDownloading(_ task: NewDownloadTask) -> Bool {
guard task.sapCode == sap.sapCode else { return false }
switch task.totalStatus {
case .downloading, .preparing, .waiting, .retrying:
return true
default:
return false
}
}
private var dependenciesCount: Int {
if let firstVersion = sap.versions.first?.value {
return firstVersion.dependencies.count
}
return 0
}
var body: some View {
VStack {
Group {
if let iconImage = iconImage {
Image(nsImage: iconImage)
.resizable()
.interpolation(.high)
.scaledToFit()
} else {
Image(systemName: "app.fill")
.resizable()
.scaledToFit()
.foregroundColor(.secondary)
}
}
.frame(width: 64, height: 64)
.onAppear {
loadIcon()
}
Text(product.displayName)
.font(.system(size: 16))
.fontWeight(.bold)
.lineLimit(2)
.multilineTextAlignment(.center)
Text("可用版本: \(product.versions.count)")
.font(.caption)
.foregroundColor(.secondary)
.frame(height: 20)
IconView(iconImage: iconImage, loadIcon: loadIcon)
ProductInfoView(sap: sap, dependenciesCount: dependenciesCount)
Spacer()
Button(action: { showVersionPicker = true }) {
Label(isDownloading ? "下载中" : "下载",
systemImage: isDownloading ? "hourglass.circle.fill" : "arrow.down.circle")
.font(.system(size: 14))
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 32)
}
.buttonStyle(.borderedProminent)
.tint(isDownloading ? .gray : .blue)
.disabled(isDownloading)
DownloadButton(
isDownloading: isDownloading,
showVersionPicker: $showVersionPicker
)
}
.padding()
.frame(width: 250, height: 200)
@@ -107,9 +82,22 @@ struct AppCardView: View {
.stroke(Color.gray.opacity(0.1), lineWidth: 2)
)
.sheet(isPresented: $showVersionPicker) {
VersionPickerView(product: product) { version in
selectedVersion = version
startDownload(version)
VersionPickerView(sap: sap) { version in
if useDefaultLanguage {
startDownload(version)
} else {
selectedVersion = version
showLanguagePicker = true
}
}
}
.sheet(isPresented: $showLanguagePicker) {
LanguagePickerView(languages: AppStatics.supportedLanguages) { language in
selectedLanguage = language
showLanguagePicker = false
if !selectedVersion.isEmpty {
startDownloadWithLanguage(selectedVersion, language)
}
}
}
.alert("下载错误", isPresented: $showError) {
@@ -125,51 +113,61 @@ struct AppCardView: View {
}
private func loadIcon() {
guard let bestIcon = product.getBestIcon(),
let iconURL = URL(string: bestIcon.url) else {
return
}
if let cachedImage = IconCache.shared.getIcon(for: bestIcon.url) {
self.iconImage = cachedImage
return
}
Task {
do {
var request = URLRequest(url: iconURL)
request.timeoutInterval = 10
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = NSImage(data: data) else {
throw URLError(.badServerResponse)
}
IconCache.shared.setIcon(image, for: bestIcon.url)
await MainActor.run {
self.iconImage = image
}
} catch {
if let localImage = NSImage(named: product.displayName) {
if let bestIcon = sap.getBestIcon(),
let iconURL = URL(string: bestIcon.url) {
if let cachedImage = IconCache.shared.getIcon(for: bestIcon.url) {
self.iconImage = cachedImage
return
}
Task {
do {
var request = URLRequest(url: iconURL)
request.timeoutInterval = 10
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = NSImage(data: data) else {
throw URLError(.badServerResponse)
}
IconCache.shared.setIcon(image, for: bestIcon.url)
await MainActor.run {
self.iconImage = localImage
self.iconImage = image
}
} catch {
if let localImage = NSImage(named: sap.sapCode) {
await MainActor.run {
self.iconImage = localImage
}
}
}
}
} else if let localImage = NSImage(named: sap.sapCode) {
self.iconImage = localImage
}
}
private func startDownload(_ version: String) {
if useDefaultLanguage {
startDownloadWithLanguage(version, defaultLanguage)
} else {
selectedVersion = version
showLanguagePicker = true
}
}
private func startDownloadWithLanguage(_ version: String, _ language: String) {
Task {
do {
let destinationURL: URL
if useDefaultDirectory && !defaultDirectory.isEmpty {
destinationURL = URL(fileURLWithPath: defaultDirectory)
.appendingPathComponent("Install \(product.displayName)_\(version)-zh_CN.app")
.appendingPathComponent("Install \(sap.displayName)_\(version)-\(language).app")
} else {
let panel = NSOpenPanel()
panel.title = "选择保存位置"
@@ -183,15 +181,14 @@ struct AppCardView: View {
return
}
destinationURL = selectedURL
.appendingPathComponent("Install \(product.displayName)_\(version)-zh_CN.app")
.appendingPathComponent("Install \(sap.displayName)_\(version)-\(language).app")
}
try await networkManager.startDownload(
sapCode: product.sapCode,
version: version,
language: "zh_CN",
sap: sap,
selectedVersion: version,
language: language,
destinationURL: destinationURL
)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
@@ -202,24 +199,93 @@ struct AppCardView: View {
}
}
//
private struct IconView: View {
let iconImage: NSImage?
let loadIcon: () -> Void
var body: some View {
Group {
if let iconImage = iconImage {
Image(nsImage: iconImage)
.resizable()
.interpolation(.high)
.scaledToFit()
} else {
Image(systemName: "app.fill")
.resizable()
.scaledToFit()
.foregroundColor(.secondary)
}
}
.frame(width: 64, height: 64)
.onAppear(perform: loadIcon)
}
}
private struct ProductInfoView: View {
let sap: Sap
let dependenciesCount: Int
var body: some View {
VStack {
Text(sap.displayName)
.font(.system(size: 16))
.fontWeight(.bold)
.lineLimit(2)
.multilineTextAlignment(.center)
HStack(spacing: 4) {
Text("可用版本: \(sap.versions.count)")
Text("|")
Text("依赖包: \(dependenciesCount)")
}
.font(.caption)
.foregroundColor(.secondary)
.frame(height: 20)
}
}
}
private struct DownloadButton: View {
let isDownloading: Bool
@Binding var showVersionPicker: Bool
var body: some View {
Button(action: { showVersionPicker = true }) {
Label(isDownloading ? "下载中" : "下载",
systemImage: isDownloading ? "hourglass.circle.fill" : "arrow.down.circle")
.font(.system(size: 14))
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 32)
}
.buttonStyle(.borderedProminent)
.tint(isDownloading ? .gray : .blue)
.disabled(isDownloading)
}
}
#Preview {
AppCardView(product: Product(
id: "PHSP",
AppCardView(sap: Sap(
hidden: false,
displayName: "Photoshop",
sapCode: "PHSP",
versions: [
"25.0.0": Product.ProductVersion(
"25.0.0": Sap.Versions(
sapCode: "PHSP",
baseVersion: "25.0.0",
productVersion: "25.0.0",
apPlatform: "macuniversal",
dependencies: [],
dependencies: [
Sap.Versions.Dependencies(sapCode: "ACR", version: "9.6"),
Sap.Versions.Dependencies(sapCode: "COCM", version: "1.0"),
Sap.Versions.Dependencies(sapCode: "COSY", version: "2.4.1")
],
buildGuid: ""
)
],
icons: [
Product.ProductIcon(
Sap.ProductIcon(
size: "192x192",
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/PHSP/25.0.0/192x192.png"
)

View File

@@ -26,18 +26,23 @@ struct DownloadManagerView: View {
}
}
private func removeTask(_ task: DownloadTask) {
networkManager.removeTask(taskId: task.id)
private func removeTask(_ task: NewDownloadTask) {
networkManager.downloadTasks.removeAll { $0.id == task.id }
networkManager.updateDockBadge()
}
private func sortTasks(_ tasks: [DownloadTask]) -> [DownloadTask] {
private func sortTasks(_ tasks: [NewDownloadTask]) -> [NewDownloadTask] {
switch sortOrder {
case .addTime:
return tasks
case .name:
return tasks.sorted { $0.productName < $1.productName }
return tasks.sorted { task1, task2 in
task1.displayName < task2.displayName
}
case .status:
return tasks.sorted { $0.status.sortOrder < $1.status.sortOrder }
return tasks.sorted { task1, task2 in
task1.status.sortOrder < task2.status.sortOrder
}
}
}
@@ -71,9 +76,13 @@ struct DownloadManagerView: View {
Button("全部暂停", action: {})
Button("全部继续", action: {})
Button("清理已完成", action: {
Task {
networkManager.clearCompletedTasks()
networkManager.downloadTasks.removeAll { task in
if case .completed = task.status {
return true
}
return false
}
networkManager.updateDockBadge()
})
Button("关闭") {
@@ -112,7 +121,7 @@ struct DownloadManagerView: View {
.padding(.horizontal)
}
}
.frame(width: 600, height: 400)
.frame(width: 600, height: 500)
}
}

View File

@@ -7,7 +7,7 @@ import SwiftUI
struct DownloadProgressView: View {
@EnvironmentObject private var networkManager: NetworkManager
let task: DownloadTask
let task: NewDownloadTask
let onCancel: () -> Void
let onPause: () -> Void
let onResume: () -> Void
@@ -118,9 +118,7 @@ struct DownloadProgressView: View {
.buttonStyle(.borderedProminent)
.tint(.green)
Button(action: {
networkManager.removeTask(taskId: task.id, removeFiles: true)
}) {
Button(action: onRemove) {
Label("删除", systemImage: "trash")
}
.buttonStyle(.borderedProminent)
@@ -138,7 +136,7 @@ struct DownloadProgressView: View {
.controlSize(.small)
.sheet(isPresented: $showInstallPrompt) {
VStack(spacing: 20) {
Text("是否要安装 \(task.productName)?")
Text("是否要安装 \(task.displayName)?")
.font(.headline)
HStack(spacing: 16) {
@@ -151,7 +149,7 @@ struct DownloadProgressView: View {
showInstallPrompt = false
isInstalling = true
Task {
await networkManager.installProduct(at: task.destinationURL)
await networkManager.installProduct(at: task.directory)
}
}
.buttonStyle(.borderedProminent)
@@ -164,7 +162,7 @@ struct DownloadProgressView: View {
Group {
if case .installing(let progress, let status) = networkManager.installationState {
InstallProgressView(
productName: task.productName,
productName: task.displayName,
progress: progress,
status: status,
onCancel: {
@@ -175,7 +173,7 @@ struct DownloadProgressView: View {
)
} else if case .completed = networkManager.installationState {
InstallProgressView(
productName: task.productName,
productName: task.displayName,
progress: 1.0,
status: "安装完成",
onCancel: {
@@ -185,7 +183,7 @@ struct DownloadProgressView: View {
)
} else if case .failed(let error) = networkManager.installationState {
InstallProgressView(
productName: task.productName,
productName: task.displayName,
progress: 0,
status: "安装失败: \(error.localizedDescription)",
onCancel: {
@@ -193,13 +191,13 @@ struct DownloadProgressView: View {
},
onRetry: {
Task {
await networkManager.retryInstallation(at: task.destinationURL)
await networkManager.retryInstallation(at: task.directory)
}
}
)
} else {
InstallProgressView(
productName: task.productName,
productName: task.displayName,
progress: 0,
status: "准备安装...",
onCancel: {
@@ -233,69 +231,78 @@ struct DownloadProgressView: View {
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)
}
private func formatRemainingTime(totalSize: Int64, downloadedSize: Int64, speed: Double) -> String {
guard speed > 0 else { return "" }
let remainingBytes = Double(totalSize - downloadedSize)
let remainingSeconds = Int(remainingBytes / speed)
let minutes = remainingSeconds / 60
let seconds = remainingSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(task.productName)
.font(.headline)
Text(task.destinationURL.path)
.font(.caption)
.foregroundColor(.blue)
.lineLimit(1)
.truncationMode(.middle)
.onTapGesture {
openInFinder(task.destinationURL)
}
.onHover { hovering in
if hovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
}
Text(task.displayName)
.font(.headline)
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(task.version)
.foregroundColor(.secondary)
statusLabel
.padding(.vertical, 2)
.padding(.horizontal, 6)
.cornerRadius(4)
}
Text(task.version)
.foregroundColor(.secondary)
}
//
Text(task.directory.path)
.font(.caption)
.foregroundColor(.blue)
.lineLimit(1)
.truncationMode(.middle)
.onTapGesture {
openInFinder(task.directory)
}
//
statusLabel
.padding(.vertical, 2)
//
VStack(alignment: .leading, spacing: 4) {
HStack {
HStack(spacing: 4) {
Text(formatFileSize(task.downloadedSize))
Text(formatFileSize(task.totalDownloadedSize))
Text("/")
Text(formatFileSize(task.totalSize))
}
Spacer()
HStack(spacing: 8) {
Text("\(Int(task.progress * 100))%")
.foregroundColor(.primary)
if task.speed > 0 {
Text(formatSpeed(task.speed))
.foregroundColor(.secondary)
}
if task.totalSpeed > 0 {
Text(formatRemainingTime(
totalSize: task.totalSize,
downloadedSize: task.totalDownloadedSize,
speed: task.totalSpeed
))
.foregroundColor(.secondary)
}
Text("\(Int(task.totalProgress * 100))%")
if task.totalSpeed > 0 {
Text(formatSpeed(task.totalSpeed))
.foregroundColor(.secondary)
}
}
.font(.caption)
.foregroundColor(.secondary)
ProgressView(value: task.progress)
ProgressView(value: task.totalProgress)
.progressViewStyle(.linear)
}
if task.packages.count > 0 {
if !task.productsToDownload.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 6) {
@@ -307,7 +314,7 @@ struct DownloadProgressView: View {
HStack {
Image(systemName: isPackageListExpanded ? "chevron.down" : "chevron.right")
.foregroundColor(.secondary)
Text("包列表 (\(task.packages.count))")
Text("产品和包列表")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -317,14 +324,55 @@ struct DownloadProgressView: View {
if isPackageListExpanded {
ScrollView {
VStack(alignment: .leading, spacing: 6) {
ForEach(task.packages.indices, id: \.self) { index in
let package = task.packages[index]
PackageProgressView(package: package, index: index, total: task.packages.count)
VStack(alignment: .leading, spacing: 8) {
ForEach(task.productsToDownload.indices, id: \.self) { productIndex in
let product = task.productsToDownload[productIndex]
VStack(alignment: .leading, spacing: 4) {
//
HStack {
Image(systemName: "cube.box")
.foregroundColor(.blue)
Text("\(product.sapCode) (\(product.version))")
.font(.caption)
.fontWeight(.medium)
Spacer()
//
let productProgress = product.packages.reduce(0.0) { sum, pkg in
sum + (pkg.downloaded ? 1.0 : pkg.progress)
} / Double(product.packages.count)
Text("\(Int(productProgress * 100))%")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 8)
//
ForEach(product.packages.indices, id: \.self) { packageIndex in
let package = product.packages[packageIndex]
PackageProgressView(
package: package,
index: packageIndex + 1,
total: product.packages.count,
isCurrentPackage: task.currentPackage?.id == package.id
)
.padding(.leading, 24)
}
}
.padding(.vertical, 4)
.background(Color.primary.opacity(0.03))
.cornerRadius(6)
if productIndex < task.productsToDownload.count - 1 {
Divider()
.padding(.vertical, 4)
}
}
}
}
.frame(maxHeight: 120)
.frame(maxHeight: 200)
}
}
}
@@ -346,23 +394,32 @@ struct DownloadProgressView: View {
}
struct PackageProgressView: View {
let package: DownloadTask.Package
let package: Package
let index: Int
let total: Int
let isCurrentPackage: Bool
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text("\(package.name)")
.font(.caption)
.foregroundColor(package.downloaded ? .secondary : .primary)
Text("(\(index + 1)/\(total))")
.font(.caption2)
.foregroundColor(.secondary)
//
HStack(spacing: 4) {
Text("\(package.fullPackageName)")
.font(.caption)
.foregroundColor(package.downloaded ? .secondary : (isCurrentPackage ? .blue : .primary))
Text(package.type)
.font(.caption2)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(package.type == "core" ? Color.blue.opacity(0.1) : Color.secondary.opacity(0.1))
.cornerRadius(4)
.foregroundColor(package.type == "core" ? .blue : .secondary)
}
Spacer()
//
if package.downloaded {
Text("已完成")
.font(.caption)
@@ -373,7 +430,7 @@ struct PackageProgressView: View {
Text(formatSpeed(package.speed))
}
.font(.caption)
.foregroundColor(.blue)
.foregroundColor(isCurrentPackage ? .blue : .secondary)
} else {
Text("等待中")
.font(.caption)
@@ -384,17 +441,21 @@ struct PackageProgressView: View {
if !package.downloaded && package.downloadedSize > 0 {
ProgressView(value: package.progress)
.scaleEffect(x: 1, y: 0.5, anchor: .center)
.tint(isCurrentPackage ? .blue : .gray)
HStack {
Text(formatFileSize(package.downloadedSize))
Text("/")
Text(formatFileSize(package.size))
Text(formatFileSize(package.downloadSize))
}
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 2)
.padding(.horizontal, 4)
.background(isCurrentPackage ? Color.blue.opacity(0.05) : Color.clear)
.cornerRadius(4)
}
private func formatFileSize(_ size: Int64) -> String {
@@ -412,115 +473,310 @@ struct PackageProgressView: View {
}
}
//
#Preview("下载中") {
DownloadProgressView(
task: DownloadTask(
sapCode: "PHSP",
version: "25.0.0",
language: "zh_CN",
productName: "Photoshop",
status: .downloading(DownloadTask.DownloadStatus.DownloadInfo(
fileName: "package1.zip",
currentPackageIndex: 0,
totalPackages: 3,
startTime: Date(),
estimatedTimeRemaining: nil
)),
progress: 0.3,
downloadedSize: 100_000_000,
totalSize: 300_000_000,
speed: 1_000_000,
currentFileName: "package1.zip",
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
let task = NewDownloadTask(
sapCode: "PHSP",
version: "26.0.0",
language: "zh_CN",
displayName: "Adobe Photoshop",
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
productsToDownload: [
ProductsToDownload(
sapCode: "PHSP",
version: "26.0.0",
buildGuid: "123",
applicationJson: ""
),
ProductsToDownload(
sapCode: "ACR",
version: "9.6.0",
buildGuid: "456",
applicationJson: ""
)
],
retryCount: 0,
createAt: Date(),
totalStatus: .downloading(DownloadStatus.DownloadInfo(
fileName: "AdobePhotoshop26-Core.zip",
currentPackageIndex: 0,
totalPackages: 8,
startTime: Date(),
estimatedTimeRemaining: nil
)),
totalProgress: 0.35,
totalDownloadedSize: 738_197_504,
totalSize: 2_147_483_648,
totalSpeed: 1_048_576
)
//
task.productsToDownload[0].packages = [
Package(
type: "core",
fullPackageName: "AdobePhotoshop26-Core.zip",
downloadSize: 1_073_741_824,
downloadURL: "/products/PHSP/AdobePhotoshop26-Core.zip"
),
Package(
type: "non-core",
fullPackageName: "AdobePhotoshop26-Support.zip",
downloadSize: 536_870_912,
downloadURL: "/products/PHSP/AdobePhotoshop26-Support.zip"
)
]
task.productsToDownload[1].packages = [
Package(
type: "core",
fullPackageName: "ACR-Core.zip",
downloadSize: 268_435_456,
downloadURL: "/products/ACR/ACR-Core.zip"
)
]
//
task.currentPackage = task.productsToDownload[0].packages[0]
task.currentPackage?.downloadedSize = 738_197_504
task.currentPackage?.progress = 0.35
task.currentPackage?.speed = 1_048_576
task.currentPackage?.status = .downloading
return DownloadProgressView(
task: task,
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
)
.environmentObject(NetworkManager())
.padding()
.frame(width: 600)
//
.onAppear {
// @State
//
}
}
#Preview("准备下载") {
DownloadProgressView(
task: DownloadTask(
sapCode: "PHSP",
version: "25.0.0",
language: "zh_CN",
productName: "Photoshop",
status: .preparing(DownloadTask.DownloadStatus.PrepareInfo(
message: "正在准备下载...",
timestamp: Date(),
stage: .initializing
)),
progress: 0.0,
downloadedSize: 0,
totalSize: 300_000_000,
speed: 0,
currentFileName: "",
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
),
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
//
#Preview("下载中(展开包列表)") {
struct PreviewWrapper: View {
@State private var isExpanded = true
let task: NewDownloadTask
var body: some View {
DownloadProgressView(
task: task,
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
)
.environmentObject(NetworkManager())
.padding()
.frame(width: 600)
}
}
let task = NewDownloadTask(
sapCode: "PHSP",
version: "26.0.0",
language: "zh_CN",
displayName: "Adobe Photoshop",
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
productsToDownload: [
ProductsToDownload(
sapCode: "PHSP",
version: "26.0.0",
buildGuid: "123",
applicationJson: ""
),
ProductsToDownload(
sapCode: "ACR",
version: "9.6.0",
buildGuid: "456",
applicationJson: ""
)
],
retryCount: 0,
createAt: Date(),
totalStatus: .downloading(DownloadStatus.DownloadInfo(
fileName: "AdobePhotoshop26-Core.zip",
currentPackageIndex: 0,
totalPackages: 8,
startTime: Date(),
estimatedTimeRemaining: nil
)),
totalProgress: 0.35,
totalDownloadedSize: 738_197_504,
totalSize: 2_147_483_648,
totalSpeed: 1_048_576
)
//
task.productsToDownload[0].packages = [
Package(
type: "core",
fullPackageName: "AdobePhotoshop26-Core.zip",
downloadSize: 1_073_741_824,
downloadURL: "/products/PHSP/AdobePhotoshop26-Core.zip"
),
Package(
type: "non-core",
fullPackageName: "AdobePhotoshop26-Support.zip",
downloadSize: 536_870_912,
downloadURL: "/products/PHSP/AdobePhotoshop26-Support.zip"
)
]
task.productsToDownload[1].packages = [
Package(
type: "core",
fullPackageName: "ACR-Core.zip",
downloadSize: 268_435_456,
downloadURL: "/products/ACR/ACR-Core.zip"
)
]
//
task.currentPackage = task.productsToDownload[0].packages[0]
task.currentPackage?.downloadedSize = 738_197_504
task.currentPackage?.progress = 0.35
task.currentPackage?.speed = 1_048_576
task.currentPackage?.status = .downloading
return PreviewWrapper(task: task)
}
#Preview("下载完成") {
DownloadProgressView(
task: DownloadTask(
sapCode: "PHSP",
version: "25.0.0",
language: "zh_CN",
productName: "Photoshop",
status: .completed(DownloadTask.DownloadStatus.CompletionInfo(
timestamp: Date(),
totalTime: 120,
totalSize: 300_000_000
)),
progress: 1.0,
downloadedSize: 300_000_000,
totalSize: 300_000_000,
speed: 0,
currentFileName: "",
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
),
#Preview("准备中") {
let task = NewDownloadTask(
sapCode: "PHSP",
version: "26.0.0",
language: "zh_CN",
displayName: "Adobe Photoshop",
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
productsToDownload: [],
retryCount: 0,
createAt: Date(),
totalStatus: .preparing(DownloadStatus.PrepareInfo(
message: "正在准备下载...",
timestamp: Date(),
stage: .initializing
)),
totalProgress: 0,
totalDownloadedSize: 0,
totalSize: 2_147_483_648,
totalSpeed: 0
)
return DownloadProgressView(
task: task,
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
)
.environmentObject(NetworkManager())
.padding()
.frame(width: 600)
}
#Preview("深色模式") {
DownloadProgressView(
task: DownloadTask(
sapCode: "PHSP",
version: "25.0.0",
language: "zh_CN",
productName: "Photoshop",
status: .downloading(DownloadTask.DownloadStatus.DownloadInfo(
fileName: "package1.zip",
currentPackageIndex: 0,
totalPackages: 3,
startTime: Date(),
estimatedTimeRemaining: nil
)),
progress: 0.3,
downloadedSize: 100_000_000,
totalSize: 300_000_000,
speed: 1_000_000,
currentFileName: "package1.zip",
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
),
#Preview("已完成") {
let task = NewDownloadTask(
sapCode: "PHSP",
version: "26.0.0",
language: "zh_CN",
displayName: "Adobe Photoshop",
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
productsToDownload: [
ProductsToDownload(
sapCode: "PHSP",
version: "26.0.0",
buildGuid: "123",
applicationJson: ""
)
],
retryCount: 0,
createAt: Date().addingTimeInterval(-3600),
totalStatus: .completed(DownloadStatus.CompletionInfo(
timestamp: Date(),
totalTime: 3600,
totalSize: 2_147_483_648
)),
totalProgress: 1.0,
totalDownloadedSize: 2_147_483_648,
totalSize: 2_147_483_648,
totalSpeed: 0
)
//
task.productsToDownload[0].packages = [
Package(
type: "core",
fullPackageName: "AdobePhotoshop26-Core.zip",
downloadSize: 1_073_741_824,
downloadURL: "/products/PHSP/AdobePhotoshop26-Core.zip"
)
]
task.productsToDownload[0].packages[0].downloaded = true
task.productsToDownload[0].packages[0].progress = 1.0
task.productsToDownload[0].packages[0].status = .completed
return DownloadProgressView(
task: task,
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
)
.preferredColorScheme(.dark)
.environmentObject(NetworkManager())
.padding()
.frame(width: 600)
}
#Preview("失败") {
let task = NewDownloadTask(
sapCode: "PHSP",
version: "26.0.0",
language: "zh_CN",
displayName: "Adobe Photoshop",
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
productsToDownload: [
ProductsToDownload(
sapCode: "PHSP",
version: "26.0.0",
buildGuid: "123",
applicationJson: ""
)
],
retryCount: 3,
createAt: Date(),
totalStatus: .failed(DownloadStatus.FailureInfo(
message: "网络连接已断开",
error: NetworkError.noConnection,
timestamp: Date(),
recoverable: true
)),
totalProgress: 0.5,
totalDownloadedSize: 1_073_741_824,
totalSize: 2_147_483_648,
totalSpeed: 0
)
return DownloadProgressView(
task: task,
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
)
.environmentObject(NetworkManager())
.padding()
.frame(width: 600)
}

View File

@@ -6,174 +6,315 @@
import SwiftUI
struct VersionPickerView: View {
let product: Product
let sap: Sap
let onVersionSelected: (String) -> Void
@Environment(\.dismiss) private var dismiss
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
@AppStorage("useDefaultDirectory") private var useDefaultDirectory: Bool = true
@AppStorage("defaultLanguage") private var defaultLanguage: String = "zh_CN"
@State private var expandedVersions: Set<String> = []
private var sortedVersions: [(version: String, platform: String, exists: Bool)] {
product.versions
.map { version -> (version: String, platform: String, exists: Bool) in
let installerPath: String
let appName = "Install \(product.sapCode)_\(version.key)-\(defaultLanguage)-\(version.value.apPlatform).app"
if useDefaultDirectory && !defaultDirectory.isEmpty {
installerPath = (defaultDirectory as NSString).appendingPathComponent(appName)
} else {
let downloadsPath = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.path ?? ""
installerPath = (downloadsPath as NSString).appendingPathComponent(appName)
}
return (
version: version.key,
platform: version.value.apPlatform,
exists: FileManager.default.fileExists(atPath: installerPath)
)
}
private func getInstallerPath(version: String, platform: String) -> String {
let appName = "Install \(sap.sapCode)_\(version)-\(defaultLanguage)-\(platform).app"
if useDefaultDirectory && !defaultDirectory.isEmpty {
return (defaultDirectory as NSString).appendingPathComponent(appName)
} else {
let downloadsPath = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.path ?? ""
return (downloadsPath as NSString).appendingPathComponent(appName)
}
}
private func mapVersion(_ version: (key: String, value: Sap.Versions)) -> (version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies]) {
let installerPath = getInstallerPath(version: version.key, platform: version.value.apPlatform)
return (
version: version.key,
platform: version.value.apPlatform,
exists: FileManager.default.fileExists(atPath: installerPath),
dependencies: version.value.dependencies
)
}
private var sortedVersions: [(version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies])] {
sap.versions
.map(mapVersion)
.sorted { $0.version.compare($1.version, options: .numeric) == .orderedDescending }
}
var body: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(product.displayName)
.font(.headline)
Text("选择版本")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Button("取消") {
dismiss()
}
.buttonStyle(.plain)
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
HeaderView(
displayName: sap.displayName,
onDismiss: { dismiss() }
)
Divider()
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(sortedVersions, id: \.version) { version in
Button(action: {
onVersionSelected(version.version)
dismiss()
}) {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text(version.version)
.font(.system(.body, design: .monospaced))
.fontWeight(.medium)
.lineLimit(1)
HStack(spacing: 6) {
Image(systemName: getPlatformIcon(version.platform))
.foregroundColor(.secondary)
Text(getPlatformDisplayName(version.platform))
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if version.exists {
Text("已下载")
.font(.caption)
.foregroundColor(.green)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.green.opacity(0.1))
.cornerRadius(4)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(Color(NSColor.controlBackgroundColor).opacity(0.01))
.cornerRadius(8)
.padding(.horizontal, 8)
.padding(.vertical, 2)
Divider()
}
}
.padding(.vertical, 8)
}
VersionListView(
versions: sortedVersions,
expandedVersions: $expandedVersions,
onVersionSelected: onVersionSelected,
onDismiss: { dismiss() }
)
}
.frame(width: 360, height: 400)
.background(Color(NSColor.windowBackgroundColor))
}
}
// MARK: -
private struct HeaderView: View {
let displayName: String
let onDismiss: () -> Void
private func getPlatformDisplayName(_ platform: String) -> String {
switch platform {
case "macuniversal":
return "Universal (Intel/Apple Silicon)"
case "macarm64":
return "Apple Silicon"
case "osx10-64", "osx10":
return "Intel"
default:
return platform
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(displayName)
.font(.headline)
Text("选择版本")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Button("取消", action: onDismiss)
.buttonStyle(.plain)
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
}
}
private struct VersionListView: View {
let versions: [(version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies])]
@Binding var expandedVersions: Set<String>
let onVersionSelected: (String) -> Void
let onDismiss: () -> Void
var body: some View {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(versions, id: \.version) { version in
VersionRowView(
version: version,
isExpanded: expandedVersions.contains(version.version),
onToggleExpand: {
withAnimation {
if expandedVersions.contains(version.version) {
expandedVersions.remove(version.version)
} else {
expandedVersions.insert(version.version)
}
}
},
onSelect: {
onVersionSelected(version.version)
onDismiss()
}
)
}
}
.padding(.vertical, 8)
}
}
}
private struct VersionRowView: View {
let version: (version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies])
let isExpanded: Bool
let onToggleExpand: () -> Void
let onSelect: () -> Void
private func getPlatformIcon(_ platform: String) -> String {
switch platform {
case "macuniversal":
return "cpu"
case "macarm64":
return "memorychip"
case "osx10-64", "osx10":
return "desktopcomputer"
default:
return "questionmark.circle"
var body: some View {
VStack(spacing: 0) {
Button(action: {
if version.dependencies.isEmpty {
onSelect()
} else {
onToggleExpand()
}
}) {
VersionRowContent(
version: version.version,
platform: version.platform,
exists: version.exists,
hasDependencies: !version.dependencies.isEmpty,
isExpanded: isExpanded
)
}
.buttonStyle(.plain)
.background(Color(NSColor.controlBackgroundColor).opacity(0.01))
if isExpanded {
DependenciesView(
dependencies: version.dependencies,
onSelect: onSelect
)
}
Divider()
}
}
}
private struct VersionRowContent: View {
let version: String
let platform: String
let exists: Bool
let hasDependencies: Bool
let isExpanded: Bool
var body: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text(version)
.font(.system(.body, design: .monospaced))
.fontWeight(.medium)
.lineLimit(1)
HStack(spacing: 6) {
Image(systemName: getPlatformIcon(platform))
.foregroundColor(.secondary)
Text(getPlatformDisplayName(platform))
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if exists {
Text("已下载")
.font(.caption)
.foregroundColor(.green)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.green.opacity(0.1))
.cornerRadius(4)
}
if hasDependencies {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
}
private struct DependenciesView: View {
let dependencies: [Sap.Versions.Dependencies]
let onSelect: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("依赖包:")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
ForEach(dependencies, id: \.sapCode) { dependency in
HStack(spacing: 8) {
Image(systemName: "cube.box")
.foregroundColor(.blue)
.frame(width: 16)
Text("\(dependency.sapCode) (\(dependency.version))")
.font(.caption)
Spacer()
}
.padding(.horizontal)
}
HStack {
Spacer()
Button("下载此版本", action: onSelect)
.buttonStyle(.borderedProminent)
Spacer()
}
.padding()
}
.padding(.vertical, 8)
.background(Color(NSColor.controlBackgroundColor).opacity(0.05))
}
}
//
private func getPlatformDisplayName(_ platform: String) -> String {
switch platform {
case "macuniversal":
return "Universal (Intel/Apple Silicon)"
case "macarm64":
return "Apple Silicon"
case "osx10-64", "osx10":
return "Intel"
default:
return platform
}
}
private func getPlatformIcon(_ platform: String) -> String {
switch platform {
case "macuniversal":
return "cpu"
case "macarm64":
return "memorychip"
case "osx10-64", "osx10":
return "desktopcomputer"
default:
return "questionmark.circle"
}
}
#Preview {
VersionPickerView(
product: Product(
id: "PHSP",
sap: Sap(
hidden: false,
displayName: "Photoshop",
sapCode: "PHSP",
versions: [
"25.0.0": Product.ProductVersion(
"26.0.0": Sap.Versions(
sapCode: "PHSP",
baseVersion: "26.0.0",
productVersion: "26.0.0",
apPlatform: "macuniversal",
dependencies: [
Sap.Versions.Dependencies(sapCode: "ACR", version: "9.6"),
Sap.Versions.Dependencies(sapCode: "COCM", version: "1.0"),
Sap.Versions.Dependencies(sapCode: "COSY", version: "2.4.1")
],
buildGuid: "b382ef03-c44a-4fd4-a9a1-3119ab0474b4"
),
"25.0.0": Sap.Versions(
sapCode: "PHSP",
baseVersion: "25.0.0",
productVersion: "25.0.0",
apPlatform: "macuniversal",
dependencies: [],
buildGuid: ""
dependencies: [
Sap.Versions.Dependencies(sapCode: "ACR", version: "9.5"),
Sap.Versions.Dependencies(sapCode: "COCM", version: "1.0")
],
buildGuid: "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6"
),
"24.6.0": Product.ProductVersion(
"24.0.0": Sap.Versions(
sapCode: "PHSP",
baseVersion: "24.6.0",
productVersion: "24.6.0",
baseVersion: "24.0.0",
productVersion: "24.0.0",
apPlatform: "macuniversal",
dependencies: [],
buildGuid: ""
),
"24.5.0": Product.ProductVersion(
sapCode: "PHSP",
baseVersion: "24.5.0",
productVersion: "24.5.0",
apPlatform: "macuniversal",
dependencies: [],
buildGuid: ""
buildGuid: "q1w2e3r4-t5y6-u7i8-o9p0-a1s2d3f4g5h6"
)
],
icons: []
icons: [
Sap.ProductIcon(
size: "192x192",
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/PHSP/26.0.0/192x192.png"
)
]
),
onVersionSelected: { _ in }
onVersionSelected: { version in
print("Selected version: \(version)")
}
)
}

View File

@@ -1,16 +1,6 @@
{
"sourceLanguage" : "en",
"strings" : {
"(%lld/%lld)" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "(%1$lld/%2$lld)"
}
}
}
},
"/" : {
},
@@ -24,6 +14,16 @@
}
}
},
"%@ (%@)" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@ (%2$@)"
}
}
}
},
"%@ 安装失败" : {
},
@@ -42,6 +42,9 @@
}
}
}
},
"|" : {
},
"About" : {
"localizations" : {
@@ -89,6 +92,9 @@
},
"下载已取消" : {
"comment" : "Download cancelled"
},
"下载此版本" : {
},
"下载管理" : {
@@ -98,12 +104,21 @@
},
"下载错误" : {
},
"产品和包列表" : {
},
"使用默认目录" : {
},
"使用默认语言" : {
},
"依赖包:" : {
},
"依赖包: %lld" : {
},
"全部暂停" : {
@@ -119,9 +134,6 @@
},
"加载失败" : {
},
"包列表 (%lld)" : {
},
"取消" : {