mirror of
https://github.com/X1a0He/Adobe-Downloader.git
synced 2025-11-25 19:27:35 +08:00
Rewrite: Rewrite the dependency acquisition logic and product dependency logic, but there is a problem that the package download progress and download status cannot be updated.
This commit is contained in:
@@ -3,4 +3,22 @@
|
||||
uuid = "05600D7B-4F3A-44C5-8A39-5E4971936E92"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
<Breakpoints>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "DEA1D888-6EDC-4454-A704-4F163A408945"
|
||||
shouldBeEnabled = "No"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "Adobe Downloader/Utils/XHXMLParser.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "46"
|
||||
endingLineNumber = "46"
|
||||
landmarkName = "unknown"
|
||||
landmarkType = "0">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
</Breakpoints>
|
||||
</Bucket>
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
// Created by X1a0He on 2024/10/30.
|
||||
//
|
||||
import Foundation
|
||||
enum PackageStatus {
|
||||
case waiting
|
||||
case downloading
|
||||
case paused
|
||||
case completed
|
||||
case failed(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .waiting: return "等待中"
|
||||
case .downloading: return "下载中"
|
||||
case .paused: return "已暂停"
|
||||
case .completed: return "已完成"
|
||||
case .failed(let message): return "失败: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: Error, LocalizedError {
|
||||
case noConnection
|
||||
|
||||
@@ -13,15 +13,15 @@ extension FileManager {
|
||||
}
|
||||
}
|
||||
|
||||
extension Product.ProductVersion {
|
||||
extension Sap.Versions {
|
||||
var size: Int64 {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadTask {
|
||||
extension NewDownloadTask {
|
||||
var startTime: Date {
|
||||
switch status {
|
||||
switch totalStatus {
|
||||
case .downloading(let info):
|
||||
return info.startTime
|
||||
case .completed(let info):
|
||||
@@ -36,164 +36,13 @@ extension DownloadTask {
|
||||
return info.timestamp
|
||||
case .waiting:
|
||||
return Date()
|
||||
case .none:
|
||||
return createAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NetworkManager {
|
||||
func handleDownloadCompletion(taskId: UUID, packageIndex: Int) async {
|
||||
await MainActor.run {
|
||||
guard let taskIndex = downloadTasks.firstIndex(where: { $0.id == taskId }) else { return }
|
||||
|
||||
downloadTasks[taskIndex].packages[packageIndex].downloaded = true
|
||||
downloadTasks[taskIndex].packages[packageIndex].progress = 1.0
|
||||
downloadTasks[taskIndex].packages[packageIndex].status = .completed
|
||||
|
||||
if let nextPackageIndex = downloadTasks[taskIndex].packages.firstIndex(where: { !$0.downloaded }) {
|
||||
downloadTasks[taskIndex].status = .downloading(DownloadTask.DownloadStatus.DownloadInfo(
|
||||
fileName: downloadTasks[taskIndex].packages[nextPackageIndex].name,
|
||||
currentPackageIndex: nextPackageIndex,
|
||||
totalPackages: downloadTasks[taskIndex].packages.count,
|
||||
startTime: Date(),
|
||||
estimatedTimeRemaining: nil
|
||||
))
|
||||
Task {
|
||||
await resumeDownload(taskId: taskId)
|
||||
}
|
||||
} else {
|
||||
let startTime = downloadTasks[taskIndex].startTime
|
||||
let totalTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
downloadTasks[taskIndex].status = .completed(DownloadTask.DownloadStatus.CompletionInfo(
|
||||
timestamp: Date(),
|
||||
totalTime: totalTime,
|
||||
totalSize: downloadTasks[taskIndex].totalSize
|
||||
))
|
||||
downloadTasks[taskIndex].progress = 1.0
|
||||
progressObservers[taskId]?.invalidate()
|
||||
progressObservers.removeValue(forKey: taskId)
|
||||
|
||||
if activeDownloadTaskId == taskId {
|
||||
activeDownloadTaskId = nil
|
||||
}
|
||||
|
||||
updateDockBadge()
|
||||
objectWillChange.send()
|
||||
Task {
|
||||
do {
|
||||
try await downloadUtils.clearExtendedAttributes(at: downloadTasks[taskIndex].destinationURL)
|
||||
print("Successfully cleared extended attributes for \(downloadTasks[taskIndex].destinationURL.path)")
|
||||
} catch {
|
||||
print("Failed to clear extended attributes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NetworkManager {
|
||||
func getApplicationInfo(buildGuid: String) async throws -> ApplicationInfo {
|
||||
guard let url = URL(string: NetworkConstants.applicationJsonURL) else {
|
||||
throw NetworkError.invalidURL(NetworkConstants.applicationJsonURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
var headers = NetworkConstants.adobeRequestHeaders
|
||||
headers["x-adobe-build-guid"] = buildGuid
|
||||
headers["Accept"] = "application/json"
|
||||
headers["Connection"] = "keep-alive"
|
||||
headers["Cookie"] = generateCookie()
|
||||
|
||||
headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
throw NetworkError.httpError(httpResponse.statusCode, String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let applicationInfo: ApplicationInfo = try decoder.decode(ApplicationInfo.self, from: data)
|
||||
return applicationInfo
|
||||
} catch {
|
||||
throw NetworkError.parsingError(error, "Failed to parse application info")
|
||||
}
|
||||
}
|
||||
|
||||
func fetchProductsData() async throws -> ([String: Product], String) {
|
||||
var components = URLComponents(string: NetworkConstants.productsXmlURL)
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "_type", value: "xml"),
|
||||
URLQueryItem(name: "channel", value: "ccm"),
|
||||
URLQueryItem(name: "channel", value: "sti"),
|
||||
URLQueryItem(name: "platform", value: "osx10-64,osx10,macarm64,macuniversal"),
|
||||
URLQueryItem(name: "productType", value: "Desktop")
|
||||
]
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw NetworkError.invalidURL(NetworkConstants.productsXmlURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
NetworkConstants.adobeRequestHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
throw NetworkError.httpError(httpResponse.statusCode, nil)
|
||||
}
|
||||
|
||||
guard let xmlString = String(data: data, encoding: .utf8) else {
|
||||
throw NetworkError.invalidData("无法解码XML数据")
|
||||
}
|
||||
|
||||
let result: ([String: Product], String) = try await Task.detached(priority: .userInitiated) {
|
||||
let parseResult = try XHXMLParser.parse(
|
||||
xmlString: xmlString,
|
||||
urlVersion: 6,
|
||||
allowedPlatforms: Set(["osx10-64", "osx10", "macuniversal", "macarm64"])
|
||||
)
|
||||
return (parseResult.products, parseResult.cdn)
|
||||
}.value
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getDownloadPath(for fileName: String) async throws -> URL {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "选择保存位置"
|
||||
panel.canCreateDirectories = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
if let baseURL = panel.url {
|
||||
continuation.resume(returning: baseURL)
|
||||
} else {
|
||||
continuation.resume(throwing: NetworkError.fileSystemError("未选择保存位置", nil))
|
||||
}
|
||||
} else {
|
||||
continuation.resume(throwing: NetworkError.fileSystemError("用户取消了操作", nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func configureNetworkMonitor() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor in
|
||||
@@ -202,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ actor InstallManager {
|
||||
let installProcess = Process()
|
||||
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||
installProcess.arguments = ["-S", setupPath, "--install=1", "--driverXML=\(driverPath)"]
|
||||
|
||||
print("执行安装命令: \(installProcess.executableURL!.path) \(installProcess.arguments!.joined(separator: " "))")
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
installProcess.standardInput = inputPipe
|
||||
@@ -217,6 +220,9 @@ actor InstallManager {
|
||||
let installProcess = Process()
|
||||
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||
installProcess.arguments = [setupPath, "--install=1", "--driverXML=\(driverPath)"]
|
||||
|
||||
print("执行重试命令: \(installProcess.executableURL!.path) \(installProcess.arguments!.joined(separator: " "))")
|
||||
|
||||
let outputPipe = Pipe()
|
||||
installProcess.standardOutput = outputPipe
|
||||
installProcess.standardError = outputPipe
|
||||
|
||||
@@ -5,79 +5,94 @@
|
||||
//
|
||||
import Foundation
|
||||
|
||||
struct Product: Identifiable {
|
||||
let id: String
|
||||
var hidden: Bool
|
||||
var displayName: String
|
||||
var sapCode: String
|
||||
var versions: [String: ProductVersion]
|
||||
var icons: [ProductIcon]
|
||||
// struct Product: Identifiable {
|
||||
// let id: String
|
||||
// var hidden: Bool
|
||||
// var displayName: String
|
||||
// var sapCode: String
|
||||
// var versions: [String: ProductVersion]
|
||||
// var icons: [ProductIcon]
|
||||
// var dependencyType: String?
|
||||
// var family: String?
|
||||
// var familyName: String?
|
||||
// var appLineage: String?
|
||||
// var type: String?
|
||||
// var categories: [String]?
|
||||
|
||||
struct ProductVersion {
|
||||
var sapCode: String
|
||||
var baseVersion: String
|
||||
var productVersion: String
|
||||
var apPlatform: String
|
||||
var dependencies: [Dependency]
|
||||
var buildGuid: String
|
||||
}
|
||||
// struct ProductVersion {
|
||||
// var sapCode: String
|
||||
// var baseVersion: String
|
||||
// var productVersion: String
|
||||
// var apPlatform: String
|
||||
// var dependencies: [Dependency]
|
||||
// var buildGuid: String
|
||||
// var packageCode: String?
|
||||
// var productCode: String?
|
||||
// var installSize: Int?
|
||||
// var esdData: EsdData?
|
||||
// }
|
||||
|
||||
struct Dependency {
|
||||
var sapCode: String
|
||||
var version: String
|
||||
}
|
||||
// struct EsdData {
|
||||
// var name: String
|
||||
// var size: Int64
|
||||
// var assetGuid: String
|
||||
// }
|
||||
|
||||
struct ProductIcon {
|
||||
let size: String
|
||||
let url: String
|
||||
// struct Dependency {
|
||||
// var sapCode: String
|
||||
// var version: String
|
||||
// var esdDirectory: String?
|
||||
// }
|
||||
|
||||
var dimension: Int {
|
||||
let components = size.split(separator: "x")
|
||||
if components.count == 2,
|
||||
let dimension = Int(components[0]) {
|
||||
return dimension
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
// struct ProductIcon {
|
||||
// let size: String
|
||||
// let url: String
|
||||
|
||||
var isValid: Bool {
|
||||
return !sapCode.isEmpty &&
|
||||
!displayName.isEmpty &&
|
||||
!versions.isEmpty
|
||||
}
|
||||
// var dimension: Int {
|
||||
// let components = size.split(separator: "x")
|
||||
// if components.count == 2,
|
||||
// let dimension = Int(components[0]) {
|
||||
// return dimension
|
||||
// }
|
||||
// return 0
|
||||
// }
|
||||
// }
|
||||
|
||||
func getBestIcon() -> ProductIcon? {
|
||||
if let icon = icons.first(where: { $0.size == "192x192" }) {
|
||||
return icon
|
||||
}
|
||||
// var isValid: Bool {
|
||||
// return !sapCode.isEmpty &&
|
||||
// !displayName.isEmpty &&
|
||||
// !versions.isEmpty
|
||||
// }
|
||||
|
||||
return icons.max(by: { $0.dimension < $1.dimension })
|
||||
}
|
||||
}
|
||||
// func getBestIcon() -> ProductIcon? {
|
||||
// if let icon = icons.first(where: { $0.size == "192x192" }) {
|
||||
// return icon
|
||||
// }
|
||||
|
||||
// return icons.max(by: { $0.dimension < $1.dimension })
|
||||
// }
|
||||
// }
|
||||
|
||||
struct ParseResult {
|
||||
var products: [String: Product]
|
||||
var products: [String: Sap]
|
||||
var cdn: String
|
||||
}
|
||||
|
||||
class XHXMLParser {
|
||||
|
||||
static func parseProductsXML(xmlData: Data, urlVersion: Int, allowedPlatforms: Set<String>) throws -> ParseResult {
|
||||
static func parseProductsXML(xmlData: Data) throws -> ParseResult {
|
||||
let xml = try XMLDocument(data: xmlData)
|
||||
|
||||
let prefix = urlVersion == 6 ? "channels/" : ""
|
||||
|
||||
guard let cdn = try xml.nodes(forXPath: "//" + prefix + "channel/cdn/secure").first?.stringValue else {
|
||||
let allowedPlatforms = Set(["osx10-64", "osx10", "macuniversal", "macarm64"])
|
||||
guard let cdn = try xml.nodes(forXPath: "//channels/channel/cdn/secure").first?.stringValue else {
|
||||
throw ParserError.missingCDN
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -26,18 +26,23 @@ struct DownloadManagerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTask(_ task: DownloadTask) {
|
||||
networkManager.removeTask(taskId: task.id)
|
||||
private func removeTask(_ task: NewDownloadTask) {
|
||||
networkManager.downloadTasks.removeAll { $0.id == task.id }
|
||||
networkManager.updateDockBadge()
|
||||
}
|
||||
|
||||
private func sortTasks(_ tasks: [DownloadTask]) -> [DownloadTask] {
|
||||
private func sortTasks(_ tasks: [NewDownloadTask]) -> [NewDownloadTask] {
|
||||
switch sortOrder {
|
||||
case .addTime:
|
||||
return tasks
|
||||
case .name:
|
||||
return tasks.sorted { $0.productName < $1.productName }
|
||||
return tasks.sorted { task1, task2 in
|
||||
task1.displayName < task2.displayName
|
||||
}
|
||||
case .status:
|
||||
return tasks.sorted { $0.status.sortOrder < $1.status.sortOrder }
|
||||
return tasks.sorted { task1, task2 in
|
||||
task1.status.sortOrder < task2.status.sortOrder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +76,13 @@ struct DownloadManagerView: View {
|
||||
Button("全部暂停", action: {})
|
||||
Button("全部继续", action: {})
|
||||
Button("清理已完成", action: {
|
||||
Task {
|
||||
networkManager.clearCompletedTasks()
|
||||
networkManager.downloadTasks.removeAll { task in
|
||||
if case .completed = task.status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
networkManager.updateDockBadge()
|
||||
})
|
||||
|
||||
Button("关闭") {
|
||||
@@ -112,7 +121,7 @@ struct DownloadManagerView: View {
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.frame(width: 600, height: 400)
|
||||
.frame(width: 600, height: 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import SwiftUI
|
||||
|
||||
struct DownloadProgressView: View {
|
||||
@EnvironmentObject private var networkManager: NetworkManager
|
||||
let task: DownloadTask
|
||||
let task: NewDownloadTask
|
||||
let onCancel: () -> Void
|
||||
let onPause: () -> Void
|
||||
let onResume: () -> Void
|
||||
@@ -118,9 +118,7 @@ struct DownloadProgressView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
|
||||
Button(action: {
|
||||
networkManager.removeTask(taskId: task.id, removeFiles: true)
|
||||
}) {
|
||||
Button(action: onRemove) {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -138,7 +136,7 @@ struct DownloadProgressView: View {
|
||||
.controlSize(.small)
|
||||
.sheet(isPresented: $showInstallPrompt) {
|
||||
VStack(spacing: 20) {
|
||||
Text("是否要安装 \(task.productName)?")
|
||||
Text("是否要安装 \(task.displayName)?")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
@@ -151,7 +149,7 @@ struct DownloadProgressView: View {
|
||||
showInstallPrompt = false
|
||||
isInstalling = true
|
||||
Task {
|
||||
await networkManager.installProduct(at: task.destinationURL)
|
||||
await networkManager.installProduct(at: task.directory)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -164,7 +162,7 @@ struct DownloadProgressView: View {
|
||||
Group {
|
||||
if case .installing(let progress, let status) = networkManager.installationState {
|
||||
InstallProgressView(
|
||||
productName: task.productName,
|
||||
productName: task.displayName,
|
||||
progress: progress,
|
||||
status: status,
|
||||
onCancel: {
|
||||
@@ -175,7 +173,7 @@ struct DownloadProgressView: View {
|
||||
)
|
||||
} else if case .completed = networkManager.installationState {
|
||||
InstallProgressView(
|
||||
productName: task.productName,
|
||||
productName: task.displayName,
|
||||
progress: 1.0,
|
||||
status: "安装完成",
|
||||
onCancel: {
|
||||
@@ -185,7 +183,7 @@ struct DownloadProgressView: View {
|
||||
)
|
||||
} else if case .failed(let error) = networkManager.installationState {
|
||||
InstallProgressView(
|
||||
productName: task.productName,
|
||||
productName: task.displayName,
|
||||
progress: 0,
|
||||
status: "安装失败: \(error.localizedDescription)",
|
||||
onCancel: {
|
||||
@@ -193,13 +191,13 @@ struct DownloadProgressView: View {
|
||||
},
|
||||
onRetry: {
|
||||
Task {
|
||||
await networkManager.retryInstallation(at: task.destinationURL)
|
||||
await networkManager.retryInstallation(at: task.directory)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
InstallProgressView(
|
||||
productName: task.productName,
|
||||
productName: task.displayName,
|
||||
progress: 0,
|
||||
status: "准备安装...",
|
||||
onCancel: {
|
||||
@@ -233,69 +231,78 @@ struct DownloadProgressView: View {
|
||||
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)
|
||||
}
|
||||
|
||||
private func formatRemainingTime(totalSize: Int64, downloadedSize: Int64, speed: Double) -> String {
|
||||
guard speed > 0 else { return "" }
|
||||
|
||||
let remainingBytes = Double(totalSize - downloadedSize)
|
||||
let remainingSeconds = Int(remainingBytes / speed)
|
||||
|
||||
let minutes = remainingSeconds / 60
|
||||
let seconds = remainingSeconds % 60
|
||||
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(task.productName)
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"(%lld/%lld)" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "(%1$lld/%2$lld)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/" : {
|
||||
|
||||
},
|
||||
@@ -24,6 +14,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ (%@)" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@ (%2$@)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ 安装失败" : {
|
||||
|
||||
},
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"|" : {
|
||||
|
||||
},
|
||||
"About" : {
|
||||
"localizations" : {
|
||||
@@ -89,6 +92,9 @@
|
||||
},
|
||||
"下载已取消" : {
|
||||
"comment" : "Download cancelled"
|
||||
},
|
||||
"下载此版本" : {
|
||||
|
||||
},
|
||||
"下载管理" : {
|
||||
|
||||
@@ -98,12 +104,21 @@
|
||||
},
|
||||
"下载错误" : {
|
||||
|
||||
},
|
||||
"产品和包列表" : {
|
||||
|
||||
},
|
||||
"使用默认目录" : {
|
||||
|
||||
},
|
||||
"使用默认语言" : {
|
||||
|
||||
},
|
||||
"依赖包:" : {
|
||||
|
||||
},
|
||||
"依赖包: %lld" : {
|
||||
|
||||
},
|
||||
"全部暂停" : {
|
||||
|
||||
@@ -119,9 +134,6 @@
|
||||
},
|
||||
"加载失败" : {
|
||||
|
||||
},
|
||||
"包列表 (%lld)" : {
|
||||
|
||||
},
|
||||
"取消" : {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user