mirror of
https://github.com/X1a0He/Adobe-Downloader.git
synced 2025-11-25 11:18:53 +08:00
Rewrite: Rewrite the dependency acquisition logic and product dependency logic, but there is a problem that the package download progress and download status cannot be updated.
This commit is contained in:
@@ -3,4 +3,22 @@
|
|||||||
uuid = "05600D7B-4F3A-44C5-8A39-5E4971936E92"
|
uuid = "05600D7B-4F3A-44C5-8A39-5E4971936E92"
|
||||||
type = "1"
|
type = "1"
|
||||||
version = "2.0">
|
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>
|
</Bucket>
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
// Created by X1a0He on 2024/10/30.
|
// Created by X1a0He on 2024/10/30.
|
||||||
//
|
//
|
||||||
import Foundation
|
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 {
|
enum NetworkError: Error, LocalizedError {
|
||||||
case noConnection
|
case noConnection
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ extension FileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Product.ProductVersion {
|
extension Sap.Versions {
|
||||||
var size: Int64 {
|
var size: Int64 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DownloadTask {
|
extension NewDownloadTask {
|
||||||
var startTime: Date {
|
var startTime: Date {
|
||||||
switch status {
|
switch totalStatus {
|
||||||
case .downloading(let info):
|
case .downloading(let info):
|
||||||
return info.startTime
|
return info.startTime
|
||||||
case .completed(let info):
|
case .completed(let info):
|
||||||
@@ -36,164 +36,13 @@ extension DownloadTask {
|
|||||||
return info.timestamp
|
return info.timestamp
|
||||||
case .waiting:
|
case .waiting:
|
||||||
return Date()
|
return Date()
|
||||||
|
case .none:
|
||||||
|
return createAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NetworkManager {
|
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() {
|
func configureNetworkMonitor() {
|
||||||
monitor.pathUpdateHandler = { [weak self] path in
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -202,18 +51,20 @@ extension NetworkManager {
|
|||||||
self.isConnected = path.status == .satisfied
|
self.isConnected = path.status == .satisfied
|
||||||
|
|
||||||
if !wasConnected && self.isConnected {
|
if !wasConnected && self.isConnected {
|
||||||
for task in self.downloadTasks where task.status.isPaused {
|
for task in self.downloadTasks {
|
||||||
if case .paused(let info) = task.status,
|
if case .paused(let info) = task.status,
|
||||||
info.reason == .networkIssue {
|
info.reason == .networkIssue {
|
||||||
await self.resumeDownload(taskId: task.id)
|
await self.resumeDownload(taskId: task.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if wasConnected && !self.isConnected {
|
} else if wasConnected && !self.isConnected {
|
||||||
for task in self.downloadTasks where task.status.isActive {
|
for task in self.downloadTasks {
|
||||||
await self.downloadUtils.pauseDownloadTask(
|
if case .downloading = task.status {
|
||||||
taskId: task.id,
|
await self.downloadUtils.pauseDownloadTask(
|
||||||
reason: DownloadTask.DownloadStatus.PauseInfo.PauseReason.networkIssue
|
taskId: task.id,
|
||||||
)
|
reason: .networkIssue
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +79,13 @@ extension NetworkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateDockBadge() {
|
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 {
|
if activeCount > 0 {
|
||||||
NSApplication.shared.dockTile.badgeLabel = "\(activeCount)"
|
NSApplication.shared.dockTile.badgeLabel = "\(activeCount)"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,6 +5,120 @@
|
|||||||
//
|
//
|
||||||
import Foundation
|
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 {
|
struct NetworkConstants {
|
||||||
static let downloadTimeout: TimeInterval = 300
|
static let downloadTimeout: TimeInterval = 300
|
||||||
static let maxRetryAttempts = 3
|
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 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"
|
static let MAC_VOLUME_ICON_PATH = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/CDAudioVolumeIcon.icns"
|
||||||
|
|
||||||
// 这里好像不怎么需要这个script了
|
|
||||||
static let INSTALL_APP_APPLE_SCRIPT = """
|
static let INSTALL_APP_APPLE_SCRIPT = """
|
||||||
const app = Application.currentApplication()
|
const app = Application.currentApplication()
|
||||||
app.includeStandardAdditions = true
|
app.includeStandardAdditions = true
|
||||||
|
|
||||||
ObjC.import('Cocoa')
|
ObjC.import('Cocoa')
|
||||||
ObjC.import('stdio')
|
ObjC.import('stdio')
|
||||||
ObjC.import('stdlib')
|
ObjC.import('stdlib')
|
||||||
|
|
||||||
ObjC.registerSubclass({
|
ObjC.registerSubclass({
|
||||||
name: 'HandleDataAction',
|
name: 'HandleDataAction',
|
||||||
methods: {
|
methods: {
|
||||||
'outData:': {
|
'outData:': {
|
||||||
types: ['void', ['id']],
|
types: ['void', ['id']],
|
||||||
implementation: function(sender) {
|
implementation: function(sender) {
|
||||||
const data = sender.object.availableData
|
const data = sender.object.availableData
|
||||||
if (data.length !== 0) {
|
if (data.length !== 0) {
|
||||||
const output = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js
|
const output = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js
|
||||||
const res = parseOutput(output)
|
const res = parseOutput(output)
|
||||||
if (res) {
|
if (res) {
|
||||||
switch (res.type) {
|
switch (res.type) {
|
||||||
case 'progress':
|
case 'progress':
|
||||||
Progress.additionalDescription = `Progress: ${res.data}%`
|
Progress.additionalDescription = `Progress: ${res.data}%`
|
||||||
Progress.completedUnitCount = res.data
|
Progress.completedUnitCount = res.data
|
||||||
break
|
break
|
||||||
case 'exit':
|
case 'exit':
|
||||||
if (res.data === 0) {
|
if (res.data === 0) {
|
||||||
$.puts(JSON.stringify({ title: 'Installation succeeded' }))
|
$.puts(JSON.stringify({ title: 'Installation succeeded' }))
|
||||||
} else {
|
} else {
|
||||||
$.puts(JSON.stringify({ title: `Failed with error code ${res.data}` }))
|
$.puts(JSON.stringify({ title: `Failed with error code ${res.data}` }))
|
||||||
}
|
}
|
||||||
$.exit(0)
|
$.exit(0)
|
||||||
break
|
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 })
|
function parseOutput(output) {
|
||||||
const alert = JSON.parse(output)
|
let matches
|
||||||
alert.params ? app.displayAlert(alert.title, alert.params) : app.displayAlert(alert.title)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = $.NSPipe.pipe
|
matches = output.match(/Progress: ([0-9]{1,3})%/)
|
||||||
const task = $.NSTask.alloc.init
|
if (matches) {
|
||||||
|
return {
|
||||||
task.executableURL = $.NSURL.alloc.initFileURLWithPath(hyperDrivePath)
|
type: 'progress',
|
||||||
task.arguments = $(['--install=1', '--driverXML=' + driverPath])
|
data: parseInt(matches[1], 10)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
struct CheckCompatibility: Codable {
|
matches = output.match(/Exit Code: ([0-9]{1,3})/)
|
||||||
let Content: String
|
if (matches) {
|
||||||
}
|
return {
|
||||||
}
|
type: 'exit',
|
||||||
|
data: parseInt(matches[1], 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct NglLicensingInfo: Codable {
|
return false
|
||||||
let AppId: String
|
}
|
||||||
let AppVersion: String
|
|
||||||
let LibVersion: String
|
function shellescape(a) {
|
||||||
let BuildId: String
|
var ret = []
|
||||||
let ImsClientId: String
|
|
||||||
|
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 = ""
|
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
||||||
@State private var showLanguagePicker = false
|
@State private var showLanguagePicker = false
|
||||||
|
|
||||||
private var filteredProducts: [Product] {
|
private var filteredProducts: [Sap] {
|
||||||
let products = networkManager.products.values
|
let products = networkManager.saps.values
|
||||||
.filter { !$0.hidden && !$0.versions.isEmpty }
|
.filter { !$0.hidden && !$0.versions.isEmpty }
|
||||||
.sorted { $0.displayName < $1.displayName }
|
.sorted { $0.displayName < $1.displayName }
|
||||||
|
|
||||||
@@ -133,8 +133,8 @@ struct ContentView: View {
|
|||||||
columns: [GridItem(.adaptive(minimum: 250))],
|
columns: [GridItem(.adaptive(minimum: 250))],
|
||||||
spacing: 20
|
spacing: 20
|
||||||
) {
|
) {
|
||||||
ForEach(filteredProducts) { product in
|
ForEach(filteredProducts, id: \.sapCode) { sap in
|
||||||
AppCardView(product: product)
|
AppCardView(sap: sap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -154,7 +154,8 @@ struct ContentView: View {
|
|||||||
.environmentObject(networkManager)
|
.environmentObject(networkManager)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if networkManager.products.isEmpty {
|
|
||||||
|
if networkManager.saps.isEmpty {
|
||||||
refreshData()
|
refreshData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,80 +217,6 @@ struct SearchField: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
let networkManager = NetworkManager()
|
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()
|
return ContentView()
|
||||||
.environmentObject(networkManager)
|
.environmentObject(networkManager)
|
||||||
.frame(width: 850, height: 700)
|
.frame(width: 850, height: 700)
|
||||||
|
|||||||
@@ -5,264 +5,103 @@
|
|||||||
//
|
//
|
||||||
import Foundation
|
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 id = UUID()
|
||||||
let sapCode: String
|
var sapCode: String
|
||||||
let version: String
|
let version: String
|
||||||
let language: String
|
let language: String
|
||||||
let productName: String
|
let displayName: String
|
||||||
@Published var status: DownloadStatus
|
let directory: URL
|
||||||
@Published var progress: Double
|
var productsToDownload: [ProductsToDownload]
|
||||||
@Published var downloadedSize: Int64
|
|
||||||
@Published var totalSize: Int64
|
|
||||||
@Published var speed: Double
|
|
||||||
@Published var currentFileName: String
|
|
||||||
let destinationURL: URL
|
|
||||||
var priority: Priority
|
|
||||||
var retryCount: Int
|
var retryCount: Int
|
||||||
let createdAt: Date
|
let createAt: Date
|
||||||
@Published var lastUpdated: Date
|
@Published var totalStatus: DownloadStatus?
|
||||||
@Published var lastRecordedSize: Int64
|
@Published var totalProgress: Double
|
||||||
@Published var packages: [Package]
|
@Published var totalDownloadedSize: Int64
|
||||||
@Published var detailedStatus: String = ""
|
@Published var totalSize: Int64
|
||||||
|
@Published var totalSpeed: Double
|
||||||
enum Priority: Int {
|
@Published var currentPackage: Package? {
|
||||||
case low = 0
|
didSet {
|
||||||
case normal = 1
|
objectWillChange.send()
|
||||||
case high = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
enum DownloadStatus {
|
|
||||||
case waiting
|
|
||||||
case preparing(PrepareInfo)
|
|
||||||
case downloading(DownloadInfo)
|
|
||||||
case paused(PauseInfo)
|
|
||||||
case completed(CompletionInfo)
|
|
||||||
case failed(FailureInfo)
|
|
||||||
case retrying(RetryInfo)
|
|
||||||
|
|
||||||
struct PrepareInfo: Equatable {
|
|
||||||
let message: String
|
|
||||||
let timestamp: Date
|
|
||||||
let stage: PrepareStage
|
|
||||||
|
|
||||||
enum PrepareStage: Equatable {
|
|
||||||
case initializing
|
|
||||||
case creatingInstaller
|
|
||||||
case signingApp
|
|
||||||
case fetchingInfo
|
|
||||||
case validatingSetup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DownloadInfo: Equatable {
|
|
||||||
let fileName: String
|
|
||||||
let currentPackageIndex: Int
|
|
||||||
let totalPackages: Int
|
|
||||||
let startTime: Date
|
|
||||||
let estimatedTimeRemaining: TimeInterval?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PauseInfo: Equatable {
|
|
||||||
let reason: PauseReason
|
|
||||||
let timestamp: Date
|
|
||||||
let resumable: Bool
|
|
||||||
|
|
||||||
enum PauseReason: Equatable {
|
|
||||||
case userRequested
|
|
||||||
case networkIssue
|
|
||||||
case systemSleep
|
|
||||||
case other(String)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CompletionInfo: Equatable {
|
|
||||||
let timestamp: Date
|
|
||||||
let totalTime: TimeInterval
|
|
||||||
let totalSize: Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FailureInfo: Equatable {
|
|
||||||
let message: String
|
|
||||||
let error: Error?
|
|
||||||
let timestamp: Date
|
|
||||||
let recoverable: Bool
|
|
||||||
|
|
||||||
static func == (lhs: FailureInfo, rhs: FailureInfo) -> Bool {
|
|
||||||
lhs.message == rhs.message &&
|
|
||||||
lhs.timestamp == rhs.timestamp &&
|
|
||||||
lhs.recoverable == rhs.recoverable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RetryInfo: Equatable {
|
|
||||||
let attempt: Int
|
|
||||||
let maxAttempts: Int
|
|
||||||
let reason: String
|
|
||||||
let nextRetryDate: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .waiting:
|
|
||||||
return "等待中"
|
|
||||||
case .preparing(let info):
|
|
||||||
return "准备中: \(info.message)"
|
|
||||||
case .downloading(let info):
|
|
||||||
return "下载中: \(info.fileName) (\(info.currentPackageIndex + 1)/\(info.totalPackages))"
|
|
||||||
case .paused(let info):
|
|
||||||
switch info.reason {
|
|
||||||
case .userRequested: return "已暂停"
|
|
||||||
case .networkIssue: return "网络中断"
|
|
||||||
case .systemSleep: return "系统休眠"
|
|
||||||
case .other(let reason): return "已暂停: \(reason)"
|
|
||||||
}
|
|
||||||
case .completed(let info):
|
|
||||||
let duration = String(format: "%.1f", info.totalTime)
|
|
||||||
return "已完成 (用时: \(duration)秒)"
|
|
||||||
case .failed(let info):
|
|
||||||
return "失败: \(info.message)"
|
|
||||||
case .retrying(let info):
|
|
||||||
return "重试中 (\(info.attempt)/\(info.maxAttempts))"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sortOrder: Int {
|
|
||||||
switch self {
|
|
||||||
case .downloading: return 0
|
|
||||||
case .preparing: return 1
|
|
||||||
case .waiting: return 2
|
|
||||||
case .paused: return 3
|
|
||||||
case .retrying: return 4
|
|
||||||
case .failed: return 5
|
|
||||||
case .completed: return 6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isFinished: Bool {
|
|
||||||
switch self {
|
|
||||||
case .completed, .failed:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isPaused: Bool {
|
|
||||||
if case .paused = self {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var isActive: Bool {
|
|
||||||
switch self {
|
|
||||||
case .downloading, .preparing, .retrying:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isCompleted: Bool {
|
|
||||||
if case .completed = self {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var isFailed: Bool {
|
|
||||||
if case .failed = self {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PackageStatus {
|
var status: DownloadStatus {
|
||||||
case waiting
|
totalStatus ?? .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 {
|
var destinationURL: URL { directory }
|
||||||
let id = UUID()
|
|
||||||
var name: String
|
var downloadedSize: Int64 {
|
||||||
var Path: String
|
get { totalDownloadedSize }
|
||||||
var size: Int64
|
set { totalDownloadedSize = newValue }
|
||||||
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,
|
var progress: Double {
|
||||||
status: DownloadStatus = .waiting, progress: Double = 0,
|
get { totalProgress }
|
||||||
downloadedSize: Int64 = 0, totalSize: Int64 = 0, speed: Double = 0,
|
set { totalProgress = newValue }
|
||||||
currentFileName: String = "", destinationURL: URL,
|
}
|
||||||
priority: Priority = .normal, retryCount: Int = 0,
|
|
||||||
packages: [Package] = [], detailedStatus: String = "") {
|
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.sapCode = sapCode
|
||||||
self.version = version
|
self.version = version
|
||||||
self.language = language
|
self.language = language
|
||||||
self.productName = productName
|
self.displayName = displayName
|
||||||
self.status = status
|
self.directory = directory
|
||||||
self.progress = progress
|
self.productsToDownload = productsToDownload
|
||||||
self.downloadedSize = downloadedSize
|
|
||||||
self.totalSize = totalSize
|
|
||||||
self.speed = speed
|
|
||||||
self.currentFileName = currentFileName
|
|
||||||
self.destinationURL = destinationURL
|
|
||||||
self.priority = priority
|
|
||||||
self.retryCount = retryCount
|
self.retryCount = retryCount
|
||||||
self.createdAt = Date()
|
self.createAt = createAt
|
||||||
self.lastUpdated = Date()
|
self.totalStatus = totalStatus
|
||||||
self.lastRecordedSize = 0
|
self.totalProgress = totalProgress
|
||||||
self.packages = packages
|
self.totalDownloadedSize = totalDownloadedSize
|
||||||
self.detailedStatus = detailedStatus
|
self.totalSize = totalSize
|
||||||
|
self.totalSpeed = totalSpeed
|
||||||
|
self.currentPackage = currentPackage
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateProgress(_ newProgress: Double) {
|
static func == (lhs: NewDownloadTask, rhs: NewDownloadTask) -> Bool {
|
||||||
objectWillChange.send()
|
return lhs.id == rhs.id
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,8 @@ class DownloadUtils {
|
|||||||
var progressHandler: ((Int64, Int64, Int64) -> Void)?
|
var progressHandler: ((Int64, Int64, Int64) -> Void)?
|
||||||
var destinationDirectory: URL
|
var destinationDirectory: URL
|
||||||
var fileName: String
|
var fileName: String
|
||||||
|
private var hasCompleted = false
|
||||||
|
private let completionLock = NSLock()
|
||||||
|
|
||||||
init(destinationDirectory: URL,
|
init(destinationDirectory: URL,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
@@ -37,6 +39,12 @@ class DownloadUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||||
|
completionLock.lock()
|
||||||
|
defer { completionLock.unlock() }
|
||||||
|
|
||||||
|
guard !hasCompleted else { return }
|
||||||
|
hasCompleted = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
|
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
|
||||||
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
|
||||||
@@ -50,16 +58,18 @@ class DownloadUtils {
|
|||||||
|
|
||||||
try FileManager.default.moveItem(at: location, to: destinationURL)
|
try FileManager.default.moveItem(at: location, to: destinationURL)
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
let expectedSize = downloadTask.countOfBytesExpectedToReceive
|
||||||
|
if expectedSize > 0,
|
||||||
|
let fileSize = try? FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 {
|
||||||
|
print("File size verification - Expected: \(expectedSize), Actual: \(fileSize)")
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
if fileSize != expectedSize {
|
||||||
let fileSize = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0
|
print("Warning: File size mismatch - Expected: \(expectedSize), Actual: \(fileSize)")
|
||||||
print("File size verification - Expected: \(downloadTask.countOfBytesExpectedToReceive), Actual: \(fileSize)")
|
}
|
||||||
|
|
||||||
completionHandler(destinationURL, downloadTask.response, nil)
|
|
||||||
} else {
|
|
||||||
completionHandler(nil, downloadTask.response, NetworkError.fileSystemError("文件移动后不存在", nil))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completionHandler(destinationURL, downloadTask.response, nil)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
print("File operation error in delegate: \(error.localizedDescription)")
|
print("File operation error in delegate: \(error.localizedDescription)")
|
||||||
completionHandler(nil, downloadTask.response, error)
|
completionHandler(nil, downloadTask.response, error)
|
||||||
@@ -67,17 +77,23 @@ class DownloadUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
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 {
|
guard !hasCompleted else { return }
|
||||||
case NSURLErrorCancelled:
|
hasCompleted = true
|
||||||
return
|
|
||||||
case NSURLErrorTimedOut:
|
if let error = error {
|
||||||
completionHandler(nil, task.response, NetworkError.downloadError("下载超时", error))
|
switch (error as NSError).code {
|
||||||
case NSURLErrorNotConnectedToInternet:
|
case NSURLErrorCancelled:
|
||||||
completionHandler(nil, task.response, NetworkError.noConnection)
|
return
|
||||||
default:
|
case NSURLErrorTimedOut:
|
||||||
completionHandler(nil, task.response, error)
|
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)
|
progressHandler?(bytesWritten, totalBytesWritten, totalBytesExpectedToWrite)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanup() {
|
||||||
|
completionHandler = { _, _, _ in }
|
||||||
|
progressHandler = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pauseDownloadTask(taskId: UUID, reason: DownloadTask.DownloadStatus.PauseInfo.PauseReason = .userRequested) async {
|
func pauseDownloadTask(taskId: UUID, reason: DownloadStatus.PauseInfo.PauseReason) async {
|
||||||
await cancelTracker.pause(taskId)
|
if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) {
|
||||||
await networkManager?.setTaskStatus(taskId, .paused(DownloadTask.DownloadStatus.PauseInfo(
|
task.setStatus(.paused(DownloadStatus.PauseInfo(
|
||||||
reason: reason,
|
reason: reason,
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
resumable: true
|
resumable: true
|
||||||
)))
|
)))
|
||||||
|
await cancelTracker.pause(taskId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resumeDownloadTask(taskId: UUID) async {
|
func resumeDownloadTask(taskId: UUID) async {
|
||||||
guard let networkManager = networkManager,
|
if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) {
|
||||||
let task = await networkManager.getTasks().first(where: { $0.id == taskId }) else { return }
|
task.setStatus(.downloading(DownloadStatus.DownloadInfo(
|
||||||
|
fileName: task.currentPackage?.fullPackageName ?? "",
|
||||||
if let activeId = await networkManager.getActiveTaskId(), activeId != taskId {
|
currentPackageIndex: 0,
|
||||||
await cancelTracker.cancel(activeId)
|
totalPackages: task.productsToDownload.reduce(0) { $0 + $1.packages.count },
|
||||||
}
|
startTime: Date(),
|
||||||
|
estimatedTimeRemaining: nil
|
||||||
guard let packageIndex = task.packages.firstIndex(where: { !$0.downloaded }) else {
|
|
||||||
await networkManager.setTaskStatus(taskId, .completed(DownloadTask.DownloadStatus.CompletionInfo(
|
|
||||||
timestamp: Date(),
|
|
||||||
totalTime: Date().timeIntervalSince(task.startTime),
|
|
||||||
totalSize: task.totalSize
|
|
||||||
)))
|
)))
|
||||||
return
|
// 实现下载逻辑
|
||||||
|
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 {
|
func cancelDownloadTask(taskId: UUID, removeFiles: Bool = false) async {
|
||||||
await cancelTracker.cancel(taskId)
|
await cancelTracker.cancel(taskId)
|
||||||
|
if let task = await networkManager?.downloadTasks.first(where: { $0.id == taskId }) {
|
||||||
if removeFiles {
|
if removeFiles {
|
||||||
if let task = await networkManager?.getTasks().first(where: { $0.id == taskId }) {
|
try? FileManager.default.removeItem(at: task.directory)
|
||||||
try? FileManager.default.removeItem(at: task.destinationURL)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
func signApp(at url: URL) async throws {
|
||||||
@@ -329,8 +218,7 @@ class DownloadUtils {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateDriverXML(sapCode: String, version: String, language: String,
|
func generateDriverXML(sapCode: String, version: String, language: String, productInfo: Sap.Versions, displayName: String) -> String {
|
||||||
productInfo: Product.ProductVersion, displayName: String) -> String {
|
|
||||||
let dependencies = productInfo.dependencies.map { dependency in
|
let dependencies = productInfo.dependencies.map { dependency in
|
||||||
"""
|
"""
|
||||||
<Dependency>
|
<Dependency>
|
||||||
@@ -391,4 +279,189 @@ class DownloadUtils {
|
|||||||
print("Error executing xattr command:", error.localizedDescription)
|
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()
|
let installProcess = Process()
|
||||||
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||||
installProcess.arguments = ["-S", setupPath, "--install=1", "--driverXML=\(driverPath)"]
|
installProcess.arguments = ["-S", setupPath, "--install=1", "--driverXML=\(driverPath)"]
|
||||||
|
|
||||||
|
print("执行安装命令: \(installProcess.executableURL!.path) \(installProcess.arguments!.joined(separator: " "))")
|
||||||
|
|
||||||
let inputPipe = Pipe()
|
let inputPipe = Pipe()
|
||||||
let outputPipe = Pipe()
|
let outputPipe = Pipe()
|
||||||
installProcess.standardInput = inputPipe
|
installProcess.standardInput = inputPipe
|
||||||
@@ -217,6 +220,9 @@ actor InstallManager {
|
|||||||
let installProcess = Process()
|
let installProcess = Process()
|
||||||
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
installProcess.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||||
installProcess.arguments = [setupPath, "--install=1", "--driverXML=\(driverPath)"]
|
installProcess.arguments = [setupPath, "--install=1", "--driverXML=\(driverPath)"]
|
||||||
|
|
||||||
|
print("执行重试命令: \(installProcess.executableURL!.path) \(installProcess.arguments!.joined(separator: " "))")
|
||||||
|
|
||||||
let outputPipe = Pipe()
|
let outputPipe = Pipe()
|
||||||
installProcess.standardOutput = outputPipe
|
installProcess.standardOutput = outputPipe
|
||||||
installProcess.standardError = outputPipe
|
installProcess.standardError = outputPipe
|
||||||
|
|||||||
@@ -5,79 +5,94 @@
|
|||||||
//
|
//
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Product: Identifiable {
|
// struct Product: Identifiable {
|
||||||
let id: String
|
// let id: String
|
||||||
var hidden: Bool
|
// var hidden: Bool
|
||||||
var displayName: String
|
// var displayName: String
|
||||||
var sapCode: String
|
// var sapCode: String
|
||||||
var versions: [String: ProductVersion]
|
// var versions: [String: ProductVersion]
|
||||||
var icons: [ProductIcon]
|
// var icons: [ProductIcon]
|
||||||
|
// var dependencyType: String?
|
||||||
|
// var family: String?
|
||||||
|
// var familyName: String?
|
||||||
|
// var appLineage: String?
|
||||||
|
// var type: String?
|
||||||
|
// var categories: [String]?
|
||||||
|
|
||||||
struct ProductVersion {
|
// struct ProductVersion {
|
||||||
var sapCode: String
|
// var sapCode: String
|
||||||
var baseVersion: String
|
// var baseVersion: String
|
||||||
var productVersion: String
|
// var productVersion: String
|
||||||
var apPlatform: String
|
// var apPlatform: String
|
||||||
var dependencies: [Dependency]
|
// var dependencies: [Dependency]
|
||||||
var buildGuid: String
|
// var buildGuid: String
|
||||||
}
|
// var packageCode: String?
|
||||||
|
// var productCode: String?
|
||||||
|
// var installSize: Int?
|
||||||
|
// var esdData: EsdData?
|
||||||
|
// }
|
||||||
|
|
||||||
struct Dependency {
|
// struct EsdData {
|
||||||
var sapCode: String
|
// var name: String
|
||||||
var version: String
|
// var size: Int64
|
||||||
}
|
// var assetGuid: String
|
||||||
|
// }
|
||||||
|
|
||||||
struct ProductIcon {
|
// struct Dependency {
|
||||||
let size: String
|
// var sapCode: String
|
||||||
let url: String
|
// var version: String
|
||||||
|
// var esdDirectory: String?
|
||||||
|
// }
|
||||||
|
|
||||||
var dimension: Int {
|
// struct ProductIcon {
|
||||||
let components = size.split(separator: "x")
|
// let size: String
|
||||||
if components.count == 2,
|
// let url: String
|
||||||
let dimension = Int(components[0]) {
|
|
||||||
return dimension
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isValid: Bool {
|
// var dimension: Int {
|
||||||
return !sapCode.isEmpty &&
|
// let components = size.split(separator: "x")
|
||||||
!displayName.isEmpty &&
|
// if components.count == 2,
|
||||||
!versions.isEmpty
|
// let dimension = Int(components[0]) {
|
||||||
}
|
// return dimension
|
||||||
|
// }
|
||||||
|
// return 0
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
func getBestIcon() -> ProductIcon? {
|
// var isValid: Bool {
|
||||||
if let icon = icons.first(where: { $0.size == "192x192" }) {
|
// return !sapCode.isEmpty &&
|
||||||
return icon
|
// !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 {
|
struct ParseResult {
|
||||||
var products: [String: Product]
|
var products: [String: Sap]
|
||||||
var cdn: String
|
var cdn: String
|
||||||
}
|
}
|
||||||
|
|
||||||
class XHXMLParser {
|
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 xml = try XMLDocument(data: xmlData)
|
||||||
|
|
||||||
let prefix = urlVersion == 6 ? "channels/" : ""
|
let allowedPlatforms = Set(["osx10-64", "osx10", "macuniversal", "macarm64"])
|
||||||
|
guard let cdn = try xml.nodes(forXPath: "//channels/channel/cdn/secure").first?.stringValue else {
|
||||||
guard let cdn = try xml.nodes(forXPath: "//" + prefix + "channel/cdn/secure").first?.stringValue else {
|
|
||||||
throw ParserError.missingCDN
|
throw ParserError.missingCDN
|
||||||
}
|
}
|
||||||
|
print("parseProductsXML - cdn: \(cdn)")
|
||||||
|
|
||||||
var products: [String: Product] = [:]
|
var products: [String: Sap] = [:]
|
||||||
|
|
||||||
let productNodes = try xml.nodes(forXPath: "//" + prefix + "channel/products/product")
|
|
||||||
|
|
||||||
|
let productNodes = try xml.nodes(forXPath: "//channels/channel/products/product")
|
||||||
let parentMap = createParentMap(xml.rootElement())
|
let parentMap = createParentMap(xml.rootElement())
|
||||||
|
|
||||||
for productNode in productNodes {
|
for productNode in productNodes {
|
||||||
guard let element = productNode as? XMLElement else { continue }
|
guard let element = productNode as? XMLElement else { continue }
|
||||||
|
|
||||||
@@ -85,113 +100,81 @@ class XHXMLParser {
|
|||||||
let parentElement = parentMap[parentMap[element] ?? element]
|
let parentElement = parentMap[parentMap[element] ?? element]
|
||||||
let hidden = (parentElement as? XMLElement)?.attribute(forName: "name")?.stringValue != "ccm"
|
let hidden = (parentElement as? XMLElement)?.attribute(forName: "name")?.stringValue != "ccm"
|
||||||
let displayName = try element.nodes(forXPath: "displayName").first?.stringValue ?? ""
|
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 {
|
if products[sap] == nil {
|
||||||
let productIcons = try element.nodes(forXPath: "productIcons/icon").compactMap { iconNode -> Product.ProductIcon? in
|
let icons = try element.nodes(forXPath: "productIcons/icon").compactMap { node -> Sap.ProductIcon? in
|
||||||
guard let iconElement = iconNode as? XMLElement,
|
guard let element = node as? XMLElement,
|
||||||
let size = iconElement.attribute(forName: "size")?.stringValue,
|
let size = element.attribute(forName: "size")?.stringValue,
|
||||||
let url = iconElement.stringValue
|
let url = element.stringValue else {
|
||||||
else { return nil }
|
return nil
|
||||||
return Product.ProductIcon(size: size, url: url)
|
}
|
||||||
|
return Sap.ProductIcon(size: size, url: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
products[sap] = Product(
|
products[sap] = Sap(
|
||||||
id: sap,
|
|
||||||
hidden: hidden,
|
hidden: hidden,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
sapCode: sap,
|
sapCode: sap,
|
||||||
versions: [:],
|
versions: [:],
|
||||||
icons: productIcons
|
icons: icons
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let platforms = try element.nodes(forXPath: "platforms/platform")
|
let platforms = try element.nodes(forXPath: "platforms/platform")
|
||||||
for platformNode in platforms {
|
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 ?? ""
|
var baseVersion = languageSet.attribute(forName: "baseVersion")?.stringValue ?? ""
|
||||||
|
|
||||||
guard let languageSet = try platform.nodes(forXPath: "languageSet").first as? XMLElement else { continue }
|
|
||||||
|
|
||||||
let baseVersion = languageSet.attribute(forName: "baseVersion")?.stringValue ?? ""
|
|
||||||
var buildGuid = languageSet.attribute(forName: "buildGuid")?.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],
|
if let existingVersion = products[sap]?.versions[productVersion],
|
||||||
allowedPlatforms.contains(existingVersion.apPlatform) {
|
allowedPlatforms.contains(existingVersion.apPlatform) {
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if sap == "APRO" {
|
if sap == "APRO" {
|
||||||
let baseVersion = productVersion
|
baseVersion = productVersion
|
||||||
var currentProductVersion = productVersion
|
let buildNodes = try xml.nodes(forXPath: "//builds/build")
|
||||||
|
for buildNode in buildNodes {
|
||||||
if urlVersion == 4 || urlVersion == 5 {
|
guard let buildElement = buildNode as? XMLElement,
|
||||||
if let appVersion = try languageSet.nodes(forXPath: "nglLicensingInfo/appVersion").first?.stringValue {
|
buildElement.attribute(forName: "id")?.stringValue == sap,
|
||||||
currentProductVersion = appVersion
|
buildElement.attribute(forName: "version")?.stringValue == baseVersion else {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
} else if urlVersion == 6 {
|
if let appVersion = try buildElement.nodes(forXPath: "nglLicensingInfo/appVersion").first?.stringValue {
|
||||||
currentProductVersion = productVersion
|
productVersion = appVersion
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
buildGuid = try languageSet.nodes(forXPath: "urls/manifestURL").first?.stringValue ?? ""
|
||||||
buildGuid = try languageSet.nodes(forXPath: "urls/manifestURL").first?.stringValue ?? buildGuid
|
|
||||||
|
|
||||||
if !buildGuid.isEmpty && allowedPlatforms.contains(appPlatform) {
|
|
||||||
let version = Product.ProductVersion(
|
|
||||||
sapCode: sap,
|
|
||||||
baseVersion: baseVersion,
|
|
||||||
productVersion: currentProductVersion,
|
|
||||||
apPlatform: appPlatform,
|
|
||||||
dependencies: [],
|
|
||||||
buildGuid: buildGuid
|
|
||||||
)
|
|
||||||
|
|
||||||
products[sap]?.versions[currentProductVersion] = version
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let dependencies = try languageSet.nodes(forXPath: "dependencies/dependency").compactMap { node -> Product.Dependency? in
|
|
||||||
guard let element = node as? XMLElement,
|
|
||||||
let sapCode = try element.nodes(forXPath: "sapCode").first?.stringValue,
|
|
||||||
let version = try element.nodes(forXPath: "baseVersion").first?.stringValue
|
|
||||||
else { return nil }
|
|
||||||
return Product.Dependency(sapCode: sapCode, version: version)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !buildGuid.isEmpty && allowedPlatforms.contains(appPlatform) {
|
if !buildGuid.isEmpty && allowedPlatforms.contains(appPlatform) {
|
||||||
let version = Product.ProductVersion(
|
let version = Sap.Versions(
|
||||||
sapCode: sap,
|
sapCode: sap,
|
||||||
baseVersion: baseVersion,
|
baseVersion: baseVersion,
|
||||||
productVersion: currentProductVersion,
|
productVersion: productVersion,
|
||||||
apPlatform: appPlatform,
|
apPlatform: appPlatform,
|
||||||
dependencies: dependencies,
|
dependencies: dependencies,
|
||||||
buildGuid: buildGuid
|
buildGuid: buildGuid
|
||||||
)
|
)
|
||||||
|
products[sap]?.versions[productVersion] = version
|
||||||
products[sap]?.versions[currentProductVersion] = version
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let validProducts = products.filter { product in
|
return ParseResult(products: products, cdn: cdn)
|
||||||
!product.value.hidden &&
|
|
||||||
product.value.isValid &&
|
|
||||||
!product.value.versions.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
return ParseResult(products: validProducts, cdn: cdn)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createParentMap(_ root: XMLNode?) -> [XMLNode: XMLNode] {
|
private static func createParentMap(_ root: XMLNode?) -> [XMLNode: XMLNode] {
|
||||||
@@ -219,10 +202,10 @@ enum ParserError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension XHXMLParser {
|
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 {
|
guard let data = xmlString.data(using: .utf8) else {
|
||||||
throw ParserError.invalidXML
|
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 {
|
struct AppCardView: View {
|
||||||
let product: Product
|
let sap: Sap
|
||||||
@EnvironmentObject private var networkManager: NetworkManager
|
@EnvironmentObject private var networkManager: NetworkManager
|
||||||
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
||||||
@AppStorage("useDefaultDirectory") private var useDefaultDirectory: Bool = true
|
@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 showError: Bool = false
|
||||||
@State private var errorMessage: String = ""
|
@State private var errorMessage: String = ""
|
||||||
@State private var showVersionPicker = false
|
@State private var showVersionPicker = false
|
||||||
@State private var selectedVersion: String = ""
|
@State private var selectedVersion: String = ""
|
||||||
@State private var iconImage: NSImage? = nil
|
@State private var iconImage: NSImage? = nil
|
||||||
|
@State private var showLanguagePicker = false
|
||||||
|
@State private var selectedLanguage = ""
|
||||||
|
|
||||||
private var isDownloading: Bool {
|
private var isDownloading: Bool {
|
||||||
networkManager.downloadTasks.contains { task in
|
networkManager.downloadTasks.contains(where: isTaskDownloading)
|
||||||
if task.sapCode == product.sapCode {
|
}
|
||||||
if case .downloading = task.status {
|
|
||||||
return true
|
private func isTaskDownloading(_ task: NewDownloadTask) -> Bool {
|
||||||
}
|
guard task.sapCode == sap.sapCode else { return false }
|
||||||
if case .preparing = task.status {
|
|
||||||
return true
|
switch task.totalStatus {
|
||||||
}
|
case .downloading, .preparing, .waiting, .retrying:
|
||||||
if case .waiting = task.status {
|
return true
|
||||||
return true
|
default:
|
||||||
}
|
|
||||||
if case .retrying = task.status {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var dependenciesCount: Int {
|
||||||
|
if let firstVersion = sap.versions.first?.value {
|
||||||
|
return firstVersion.dependencies.count
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Group {
|
IconView(iconImage: iconImage, loadIcon: loadIcon)
|
||||||
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)
|
ProductInfoView(sap: sap, dependenciesCount: dependenciesCount)
|
||||||
.font(.system(size: 16))
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.lineLimit(2)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Text("可用版本: \(product.versions.count)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(height: 20)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: { showVersionPicker = true }) {
|
DownloadButton(
|
||||||
Label(isDownloading ? "下载中" : "下载",
|
isDownloading: isDownloading,
|
||||||
systemImage: isDownloading ? "hourglass.circle.fill" : "arrow.down.circle")
|
showVersionPicker: $showVersionPicker
|
||||||
.font(.system(size: 14))
|
)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
|
||||||
.frame(height: 32)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(isDownloading ? .gray : .blue)
|
|
||||||
.disabled(isDownloading)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(width: 250, height: 200)
|
.frame(width: 250, height: 200)
|
||||||
@@ -107,9 +82,22 @@ struct AppCardView: View {
|
|||||||
.stroke(Color.gray.opacity(0.1), lineWidth: 2)
|
.stroke(Color.gray.opacity(0.1), lineWidth: 2)
|
||||||
)
|
)
|
||||||
.sheet(isPresented: $showVersionPicker) {
|
.sheet(isPresented: $showVersionPicker) {
|
||||||
VersionPickerView(product: product) { version in
|
VersionPickerView(sap: sap) { version in
|
||||||
selectedVersion = version
|
if useDefaultLanguage {
|
||||||
startDownload(version)
|
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) {
|
.alert("下载错误", isPresented: $showError) {
|
||||||
@@ -125,51 +113,61 @@ struct AppCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadIcon() {
|
private func loadIcon() {
|
||||||
guard let bestIcon = product.getBestIcon(),
|
if let bestIcon = sap.getBestIcon(),
|
||||||
let iconURL = URL(string: bestIcon.url) else {
|
let iconURL = URL(string: bestIcon.url) {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let cachedImage = IconCache.shared.getIcon(for: bestIcon.url) {
|
if let cachedImage = IconCache.shared.getIcon(for: bestIcon.url) {
|
||||||
self.iconImage = cachedImage
|
self.iconImage = cachedImage
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
var request = URLRequest(url: iconURL)
|
var request = URLRequest(url: iconURL)
|
||||||
request.timeoutInterval = 10
|
request.timeoutInterval = 10
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
(200...299).contains(httpResponse.statusCode),
|
(200...299).contains(httpResponse.statusCode),
|
||||||
let image = NSImage(data: data) else {
|
let image = NSImage(data: data) else {
|
||||||
throw URLError(.badServerResponse)
|
throw URLError(.badServerResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
IconCache.shared.setIcon(image, for: bestIcon.url)
|
IconCache.shared.setIcon(image, for: bestIcon.url)
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
self.iconImage = image
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if let localImage = NSImage(named: product.displayName) {
|
|
||||||
await MainActor.run {
|
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) {
|
private func startDownload(_ version: String) {
|
||||||
|
if useDefaultLanguage {
|
||||||
|
startDownloadWithLanguage(version, defaultLanguage)
|
||||||
|
} else {
|
||||||
|
selectedVersion = version
|
||||||
|
showLanguagePicker = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startDownloadWithLanguage(_ version: String, _ language: String) {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let destinationURL: URL
|
let destinationURL: URL
|
||||||
if useDefaultDirectory && !defaultDirectory.isEmpty {
|
if useDefaultDirectory && !defaultDirectory.isEmpty {
|
||||||
destinationURL = URL(fileURLWithPath: defaultDirectory)
|
destinationURL = URL(fileURLWithPath: defaultDirectory)
|
||||||
.appendingPathComponent("Install \(product.displayName)_\(version)-zh_CN.app")
|
.appendingPathComponent("Install \(sap.displayName)_\(version)-\(language).app")
|
||||||
} else {
|
} else {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.title = "选择保存位置"
|
panel.title = "选择保存位置"
|
||||||
@@ -183,15 +181,14 @@ struct AppCardView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
destinationURL = selectedURL
|
destinationURL = selectedURL
|
||||||
.appendingPathComponent("Install \(product.displayName)_\(version)-zh_CN.app")
|
.appendingPathComponent("Install \(sap.displayName)_\(version)-\(language).app")
|
||||||
}
|
}
|
||||||
try await networkManager.startDownload(
|
try await networkManager.startDownload(
|
||||||
sapCode: product.sapCode,
|
sap: sap,
|
||||||
version: version,
|
selectedVersion: version,
|
||||||
language: "zh_CN",
|
language: language,
|
||||||
destinationURL: destinationURL
|
destinationURL: destinationURL
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
errorMessage = error.localizedDescription
|
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 {
|
#Preview {
|
||||||
AppCardView(product: Product(
|
AppCardView(sap: Sap(
|
||||||
id: "PHSP",
|
|
||||||
hidden: false,
|
hidden: false,
|
||||||
displayName: "Photoshop",
|
displayName: "Photoshop",
|
||||||
sapCode: "PHSP",
|
sapCode: "PHSP",
|
||||||
versions: [
|
versions: [
|
||||||
"25.0.0": Product.ProductVersion(
|
"25.0.0": Sap.Versions(
|
||||||
sapCode: "PHSP",
|
sapCode: "PHSP",
|
||||||
baseVersion: "25.0.0",
|
baseVersion: "25.0.0",
|
||||||
productVersion: "25.0.0",
|
productVersion: "25.0.0",
|
||||||
apPlatform: "macuniversal",
|
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: ""
|
buildGuid: ""
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
icons: [
|
icons: [
|
||||||
Product.ProductIcon(
|
Sap.ProductIcon(
|
||||||
size: "192x192",
|
size: "192x192",
|
||||||
url: "https://ffc-static-cdn.oobesaas.adobe.com/icons/PHSP/25.0.0/192x192.png"
|
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) {
|
private func removeTask(_ task: NewDownloadTask) {
|
||||||
networkManager.removeTask(taskId: task.id)
|
networkManager.downloadTasks.removeAll { $0.id == task.id }
|
||||||
|
networkManager.updateDockBadge()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sortTasks(_ tasks: [DownloadTask]) -> [DownloadTask] {
|
private func sortTasks(_ tasks: [NewDownloadTask]) -> [NewDownloadTask] {
|
||||||
switch sortOrder {
|
switch sortOrder {
|
||||||
case .addTime:
|
case .addTime:
|
||||||
return tasks
|
return tasks
|
||||||
case .name:
|
case .name:
|
||||||
return tasks.sorted { $0.productName < $1.productName }
|
return tasks.sorted { task1, task2 in
|
||||||
|
task1.displayName < task2.displayName
|
||||||
|
}
|
||||||
case .status:
|
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: {})
|
Button("全部继续", action: {})
|
||||||
Button("清理已完成", action: {
|
Button("清理已完成", action: {
|
||||||
Task {
|
networkManager.downloadTasks.removeAll { task in
|
||||||
networkManager.clearCompletedTasks()
|
if case .completed = task.status {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
networkManager.updateDockBadge()
|
||||||
})
|
})
|
||||||
|
|
||||||
Button("关闭") {
|
Button("关闭") {
|
||||||
@@ -112,7 +121,7 @@ struct DownloadManagerView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 400)
|
.frame(width: 600, height: 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct DownloadProgressView: View {
|
struct DownloadProgressView: View {
|
||||||
@EnvironmentObject private var networkManager: NetworkManager
|
@EnvironmentObject private var networkManager: NetworkManager
|
||||||
let task: DownloadTask
|
let task: NewDownloadTask
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
let onPause: () -> Void
|
let onPause: () -> Void
|
||||||
let onResume: () -> Void
|
let onResume: () -> Void
|
||||||
@@ -118,9 +118,7 @@ struct DownloadProgressView: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: onRemove) {
|
||||||
networkManager.removeTask(taskId: task.id, removeFiles: true)
|
|
||||||
}) {
|
|
||||||
Label("删除", systemImage: "trash")
|
Label("删除", systemImage: "trash")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -138,7 +136,7 @@ struct DownloadProgressView: View {
|
|||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
.sheet(isPresented: $showInstallPrompt) {
|
.sheet(isPresented: $showInstallPrompt) {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Text("是否要安装 \(task.productName)?")
|
Text("是否要安装 \(task.displayName)?")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
@@ -151,7 +149,7 @@ struct DownloadProgressView: View {
|
|||||||
showInstallPrompt = false
|
showInstallPrompt = false
|
||||||
isInstalling = true
|
isInstalling = true
|
||||||
Task {
|
Task {
|
||||||
await networkManager.installProduct(at: task.destinationURL)
|
await networkManager.installProduct(at: task.directory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -164,7 +162,7 @@ struct DownloadProgressView: View {
|
|||||||
Group {
|
Group {
|
||||||
if case .installing(let progress, let status) = networkManager.installationState {
|
if case .installing(let progress, let status) = networkManager.installationState {
|
||||||
InstallProgressView(
|
InstallProgressView(
|
||||||
productName: task.productName,
|
productName: task.displayName,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
status: status,
|
status: status,
|
||||||
onCancel: {
|
onCancel: {
|
||||||
@@ -175,7 +173,7 @@ struct DownloadProgressView: View {
|
|||||||
)
|
)
|
||||||
} else if case .completed = networkManager.installationState {
|
} else if case .completed = networkManager.installationState {
|
||||||
InstallProgressView(
|
InstallProgressView(
|
||||||
productName: task.productName,
|
productName: task.displayName,
|
||||||
progress: 1.0,
|
progress: 1.0,
|
||||||
status: "安装完成",
|
status: "安装完成",
|
||||||
onCancel: {
|
onCancel: {
|
||||||
@@ -185,7 +183,7 @@ struct DownloadProgressView: View {
|
|||||||
)
|
)
|
||||||
} else if case .failed(let error) = networkManager.installationState {
|
} else if case .failed(let error) = networkManager.installationState {
|
||||||
InstallProgressView(
|
InstallProgressView(
|
||||||
productName: task.productName,
|
productName: task.displayName,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: "安装失败: \(error.localizedDescription)",
|
status: "安装失败: \(error.localizedDescription)",
|
||||||
onCancel: {
|
onCancel: {
|
||||||
@@ -193,13 +191,13 @@ struct DownloadProgressView: View {
|
|||||||
},
|
},
|
||||||
onRetry: {
|
onRetry: {
|
||||||
Task {
|
Task {
|
||||||
await networkManager.retryInstallation(at: task.destinationURL)
|
await networkManager.retryInstallation(at: task.directory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
InstallProgressView(
|
InstallProgressView(
|
||||||
productName: task.productName,
|
productName: task.displayName,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: "准备安装...",
|
status: "准备安装...",
|
||||||
onCancel: {
|
onCancel: {
|
||||||
@@ -233,69 +231,78 @@ struct DownloadProgressView: View {
|
|||||||
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Text(task.displayName)
|
||||||
Text(task.productName)
|
.font(.headline)
|
||||||
.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
Text(task.version)
|
||||||
Text(task.version)
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
statusLabel
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 下载目录
|
||||||
|
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) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(formatFileSize(task.downloadedSize))
|
Text(formatFileSize(task.totalDownloadedSize))
|
||||||
Text("/")
|
Text("/")
|
||||||
Text(formatFileSize(task.totalSize))
|
Text(formatFileSize(task.totalSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
if task.totalSpeed > 0 {
|
||||||
Text("\(Int(task.progress * 100))%")
|
Text(formatRemainingTime(
|
||||||
.foregroundColor(.primary)
|
totalSize: task.totalSize,
|
||||||
|
downloadedSize: task.totalDownloadedSize,
|
||||||
|
speed: task.totalSpeed
|
||||||
|
))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
if task.speed > 0 {
|
Text("\(Int(task.totalProgress * 100))%")
|
||||||
Text(formatSpeed(task.speed))
|
|
||||||
.foregroundColor(.secondary)
|
if task.totalSpeed > 0 {
|
||||||
}
|
Text(formatSpeed(task.totalSpeed))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
ProgressView(value: task.progress)
|
ProgressView(value: task.totalProgress)
|
||||||
.progressViewStyle(.linear)
|
.progressViewStyle(.linear)
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.packages.count > 0 {
|
if !task.productsToDownload.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -307,7 +314,7 @@ struct DownloadProgressView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Image(systemName: isPackageListExpanded ? "chevron.down" : "chevron.right")
|
Image(systemName: isPackageListExpanded ? "chevron.down" : "chevron.right")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("包列表 (\(task.packages.count))")
|
Text("产品和包列表")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -317,14 +324,55 @@ struct DownloadProgressView: View {
|
|||||||
|
|
||||||
if isPackageListExpanded {
|
if isPackageListExpanded {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(task.packages.indices, id: \.self) { index in
|
ForEach(task.productsToDownload.indices, id: \.self) { productIndex in
|
||||||
let package = task.packages[index]
|
let product = task.productsToDownload[productIndex]
|
||||||
PackageProgressView(package: package, index: index, total: task.packages.count)
|
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 {
|
struct PackageProgressView: View {
|
||||||
let package: DownloadTask.Package
|
let package: Package
|
||||||
let index: Int
|
let index: Int
|
||||||
let total: Int
|
let total: Int
|
||||||
|
let isCurrentPackage: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(package.name)")
|
// 包名和类型标签
|
||||||
.font(.caption)
|
HStack(spacing: 4) {
|
||||||
.foregroundColor(package.downloaded ? .secondary : .primary)
|
Text("\(package.fullPackageName)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(package.downloaded ? .secondary : (isCurrentPackage ? .blue : .primary))
|
||||||
|
|
||||||
Text("(\(index + 1)/\(total))")
|
Text(package.type)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(package.type == "core" ? Color.blue.opacity(0.1) : Color.secondary.opacity(0.1))
|
||||||
|
.cornerRadius(4)
|
||||||
|
.foregroundColor(package.type == "core" ? .blue : .secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// 状态和进度
|
||||||
if package.downloaded {
|
if package.downloaded {
|
||||||
Text("已完成")
|
Text("已完成")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -373,7 +430,7 @@ struct PackageProgressView: View {
|
|||||||
Text(formatSpeed(package.speed))
|
Text(formatSpeed(package.speed))
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(isCurrentPackage ? .blue : .secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("等待中")
|
Text("等待中")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -384,17 +441,21 @@ struct PackageProgressView: View {
|
|||||||
if !package.downloaded && package.downloadedSize > 0 {
|
if !package.downloaded && package.downloadedSize > 0 {
|
||||||
ProgressView(value: package.progress)
|
ProgressView(value: package.progress)
|
||||||
.scaleEffect(x: 1, y: 0.5, anchor: .center)
|
.scaleEffect(x: 1, y: 0.5, anchor: .center)
|
||||||
|
.tint(isCurrentPackage ? .blue : .gray)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(formatFileSize(package.downloadedSize))
|
Text(formatFileSize(package.downloadedSize))
|
||||||
Text("/")
|
Text("/")
|
||||||
Text(formatFileSize(package.size))
|
Text(formatFileSize(package.downloadSize))
|
||||||
}
|
}
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.background(isCurrentPackage ? Color.blue.opacity(0.05) : Color.clear)
|
||||||
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatFileSize(_ size: Int64) -> String {
|
private func formatFileSize(_ size: Int64) -> String {
|
||||||
@@ -412,115 +473,310 @@ struct PackageProgressView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在文件末尾添加预览
|
||||||
#Preview("下载中") {
|
#Preview("下载中") {
|
||||||
DownloadProgressView(
|
let task = NewDownloadTask(
|
||||||
task: DownloadTask(
|
sapCode: "PHSP",
|
||||||
sapCode: "PHSP",
|
version: "26.0.0",
|
||||||
version: "25.0.0",
|
language: "zh_CN",
|
||||||
language: "zh_CN",
|
displayName: "Adobe Photoshop",
|
||||||
productName: "Photoshop",
|
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
|
||||||
status: .downloading(DownloadTask.DownloadStatus.DownloadInfo(
|
productsToDownload: [
|
||||||
fileName: "package1.zip",
|
ProductsToDownload(
|
||||||
currentPackageIndex: 0,
|
sapCode: "PHSP",
|
||||||
totalPackages: 3,
|
version: "26.0.0",
|
||||||
startTime: Date(),
|
buildGuid: "123",
|
||||||
estimatedTimeRemaining: nil
|
applicationJson: ""
|
||||||
)),
|
),
|
||||||
progress: 0.3,
|
ProductsToDownload(
|
||||||
downloadedSize: 100_000_000,
|
sapCode: "ACR",
|
||||||
totalSize: 300_000_000,
|
version: "9.6.0",
|
||||||
speed: 1_000_000,
|
buildGuid: "456",
|
||||||
currentFileName: "package1.zip",
|
applicationJson: ""
|
||||||
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
|
)
|
||||||
|
],
|
||||||
|
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: {},
|
onCancel: {},
|
||||||
onPause: {},
|
onPause: {},
|
||||||
onResume: {},
|
onResume: {},
|
||||||
onRetry: {},
|
onRetry: {},
|
||||||
onRemove: {}
|
onRemove: {}
|
||||||
)
|
)
|
||||||
|
.environmentObject(NetworkManager())
|
||||||
|
.padding()
|
||||||
|
.frame(width: 600)
|
||||||
|
// 添加一个修饰器来模拟用户点击展开包列表
|
||||||
|
.onAppear {
|
||||||
|
// 注意:这种方式在预览中可能不会立即生效,因为 @State 属性在预览中的行为可能不太一致
|
||||||
|
// 作为替代方案,我们可以创建一个新的初始化方法来设置初始状态
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("准备下载") {
|
// 添加一个新的预览,默认展开包列表
|
||||||
DownloadProgressView(
|
#Preview("下载中(展开包列表)") {
|
||||||
task: DownloadTask(
|
struct PreviewWrapper: View {
|
||||||
sapCode: "PHSP",
|
@State private var isExpanded = true
|
||||||
version: "25.0.0",
|
let task: NewDownloadTask
|
||||||
language: "zh_CN",
|
|
||||||
productName: "Photoshop",
|
var body: some View {
|
||||||
status: .preparing(DownloadTask.DownloadStatus.PrepareInfo(
|
DownloadProgressView(
|
||||||
message: "正在准备下载...",
|
task: task,
|
||||||
timestamp: Date(),
|
onCancel: {},
|
||||||
stage: .initializing
|
onPause: {},
|
||||||
)),
|
onResume: {},
|
||||||
progress: 0.0,
|
onRetry: {},
|
||||||
downloadedSize: 0,
|
onRemove: {}
|
||||||
totalSize: 300_000_000,
|
)
|
||||||
speed: 0,
|
.environmentObject(NetworkManager())
|
||||||
currentFileName: "",
|
.padding()
|
||||||
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
|
.frame(width: 600)
|
||||||
),
|
}
|
||||||
onCancel: {},
|
}
|
||||||
onPause: {},
|
|
||||||
onResume: {},
|
let task = NewDownloadTask(
|
||||||
onRetry: {},
|
sapCode: "PHSP",
|
||||||
onRemove: {}
|
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("下载完成") {
|
#Preview("准备中") {
|
||||||
DownloadProgressView(
|
let task = NewDownloadTask(
|
||||||
task: DownloadTask(
|
sapCode: "PHSP",
|
||||||
sapCode: "PHSP",
|
version: "26.0.0",
|
||||||
version: "25.0.0",
|
language: "zh_CN",
|
||||||
language: "zh_CN",
|
displayName: "Adobe Photoshop",
|
||||||
productName: "Photoshop",
|
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
|
||||||
status: .completed(DownloadTask.DownloadStatus.CompletionInfo(
|
productsToDownload: [],
|
||||||
timestamp: Date(),
|
retryCount: 0,
|
||||||
totalTime: 120,
|
createAt: Date(),
|
||||||
totalSize: 300_000_000
|
totalStatus: .preparing(DownloadStatus.PrepareInfo(
|
||||||
)),
|
message: "正在准备下载...",
|
||||||
progress: 1.0,
|
timestamp: Date(),
|
||||||
downloadedSize: 300_000_000,
|
stage: .initializing
|
||||||
totalSize: 300_000_000,
|
)),
|
||||||
speed: 0,
|
totalProgress: 0,
|
||||||
currentFileName: "",
|
totalDownloadedSize: 0,
|
||||||
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
|
totalSize: 2_147_483_648,
|
||||||
),
|
totalSpeed: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return DownloadProgressView(
|
||||||
|
task: task,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onPause: {},
|
onPause: {},
|
||||||
onResume: {},
|
onResume: {},
|
||||||
onRetry: {},
|
onRetry: {},
|
||||||
onRemove: {}
|
onRemove: {}
|
||||||
)
|
)
|
||||||
|
.environmentObject(NetworkManager())
|
||||||
|
.padding()
|
||||||
|
.frame(width: 600)
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("深色模式") {
|
#Preview("已完成") {
|
||||||
DownloadProgressView(
|
let task = NewDownloadTask(
|
||||||
task: DownloadTask(
|
sapCode: "PHSP",
|
||||||
sapCode: "PHSP",
|
version: "26.0.0",
|
||||||
version: "25.0.0",
|
language: "zh_CN",
|
||||||
language: "zh_CN",
|
displayName: "Adobe Photoshop",
|
||||||
productName: "Photoshop",
|
directory: URL(fileURLWithPath: "/Users/Downloads/Install Photoshop_26.0-zh_CN.app"),
|
||||||
status: .downloading(DownloadTask.DownloadStatus.DownloadInfo(
|
productsToDownload: [
|
||||||
fileName: "package1.zip",
|
ProductsToDownload(
|
||||||
currentPackageIndex: 0,
|
sapCode: "PHSP",
|
||||||
totalPackages: 3,
|
version: "26.0.0",
|
||||||
startTime: Date(),
|
buildGuid: "123",
|
||||||
estimatedTimeRemaining: nil
|
applicationJson: ""
|
||||||
)),
|
)
|
||||||
progress: 0.3,
|
],
|
||||||
downloadedSize: 100_000_000,
|
retryCount: 0,
|
||||||
totalSize: 300_000_000,
|
createAt: Date().addingTimeInterval(-3600),
|
||||||
speed: 1_000_000,
|
totalStatus: .completed(DownloadStatus.CompletionInfo(
|
||||||
currentFileName: "package1.zip",
|
timestamp: Date(),
|
||||||
destinationURL: URL(fileURLWithPath: "/Downloads/Adobe/Photoshop")
|
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: {},
|
onCancel: {},
|
||||||
onPause: {},
|
onPause: {},
|
||||||
onResume: {},
|
onResume: {},
|
||||||
onRetry: {},
|
onRetry: {},
|
||||||
onRemove: {}
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct VersionPickerView: View {
|
struct VersionPickerView: View {
|
||||||
let product: Product
|
let sap: Sap
|
||||||
let onVersionSelected: (String) -> Void
|
let onVersionSelected: (String) -> Void
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
@AppStorage("defaultDirectory") private var defaultDirectory: String = ""
|
||||||
@AppStorage("useDefaultDirectory") private var useDefaultDirectory: Bool = true
|
@AppStorage("useDefaultDirectory") private var useDefaultDirectory: Bool = true
|
||||||
@AppStorage("defaultLanguage") private var defaultLanguage: String = "zh_CN"
|
@AppStorage("defaultLanguage") private var defaultLanguage: String = "zh_CN"
|
||||||
|
@State private var expandedVersions: Set<String> = []
|
||||||
|
|
||||||
private var sortedVersions: [(version: String, platform: String, exists: Bool)] {
|
private func getInstallerPath(version: String, platform: String) -> String {
|
||||||
product.versions
|
let appName = "Install \(sap.sapCode)_\(version)-\(defaultLanguage)-\(platform).app"
|
||||||
.map { version -> (version: String, platform: String, exists: Bool) in
|
if useDefaultDirectory && !defaultDirectory.isEmpty {
|
||||||
let installerPath: String
|
return (defaultDirectory as NSString).appendingPathComponent(appName)
|
||||||
let appName = "Install \(product.sapCode)_\(version.key)-\(defaultLanguage)-\(version.value.apPlatform).app"
|
} else {
|
||||||
if useDefaultDirectory && !defaultDirectory.isEmpty {
|
let downloadsPath = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.path ?? ""
|
||||||
installerPath = (defaultDirectory as NSString).appendingPathComponent(appName)
|
return (downloadsPath as NSString).appendingPathComponent(appName)
|
||||||
} else {
|
}
|
||||||
let downloadsPath = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.path ?? ""
|
}
|
||||||
installerPath = (downloadsPath as NSString).appendingPathComponent(appName)
|
|
||||||
}
|
private func mapVersion(_ version: (key: String, value: Sap.Versions)) -> (version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies]) {
|
||||||
return (
|
let installerPath = getInstallerPath(version: version.key, platform: version.value.apPlatform)
|
||||||
version: version.key,
|
return (
|
||||||
platform: version.value.apPlatform,
|
version: version.key,
|
||||||
exists: FileManager.default.fileExists(atPath: installerPath)
|
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 }
|
.sorted { $0.version.compare($1.version, options: .numeric) == .orderedDescending }
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HeaderView(
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
displayName: sap.displayName,
|
||||||
Text(product.displayName)
|
onDismiss: { dismiss() }
|
||||||
.font(.headline)
|
)
|
||||||
Text("选择版本")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button("取消") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color(NSColor.controlBackgroundColor))
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
VersionListView(
|
||||||
LazyVStack(spacing: 0) {
|
versions: sortedVersions,
|
||||||
ForEach(sortedVersions, id: \.version) { version in
|
expandedVersions: $expandedVersions,
|
||||||
Button(action: {
|
onVersionSelected: onVersionSelected,
|
||||||
onVersionSelected(version.version)
|
onDismiss: { dismiss() }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(width: 360, height: 400)
|
.frame(width: 360, height: 400)
|
||||||
.background(Color(NSColor.windowBackgroundColor))
|
.background(Color(NSColor.windowBackgroundColor))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func getPlatformDisplayName(_ platform: String) -> String {
|
// MARK: - 子视图
|
||||||
switch platform {
|
private struct HeaderView: View {
|
||||||
case "macuniversal":
|
let displayName: String
|
||||||
return "Universal (Intel/Apple Silicon)"
|
let onDismiss: () -> Void
|
||||||
case "macarm64":
|
|
||||||
return "Apple Silicon"
|
var body: some View {
|
||||||
case "osx10-64", "osx10":
|
HStack {
|
||||||
return "Intel"
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
default:
|
Text(displayName)
|
||||||
return platform
|
.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 func getPlatformIcon(_ platform: String) -> String {
|
private struct VersionRowView: View {
|
||||||
switch platform {
|
let version: (version: String, platform: String, exists: Bool, dependencies: [Sap.Versions.Dependencies])
|
||||||
case "macuniversal":
|
let isExpanded: Bool
|
||||||
return "cpu"
|
let onToggleExpand: () -> Void
|
||||||
case "macarm64":
|
let onSelect: () -> Void
|
||||||
return "memorychip"
|
|
||||||
case "osx10-64", "osx10":
|
var body: some View {
|
||||||
return "desktopcomputer"
|
VStack(spacing: 0) {
|
||||||
default:
|
Button(action: {
|
||||||
return "questionmark.circle"
|
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 {
|
#Preview {
|
||||||
VersionPickerView(
|
VersionPickerView(
|
||||||
product: Product(
|
sap: Sap(
|
||||||
id: "PHSP",
|
|
||||||
hidden: false,
|
hidden: false,
|
||||||
displayName: "Photoshop",
|
displayName: "Photoshop",
|
||||||
sapCode: "PHSP",
|
sapCode: "PHSP",
|
||||||
versions: [
|
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",
|
sapCode: "PHSP",
|
||||||
baseVersion: "25.0.0",
|
baseVersion: "25.0.0",
|
||||||
productVersion: "25.0.0",
|
productVersion: "25.0.0",
|
||||||
apPlatform: "macuniversal",
|
apPlatform: "macuniversal",
|
||||||
dependencies: [],
|
dependencies: [
|
||||||
buildGuid: ""
|
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",
|
sapCode: "PHSP",
|
||||||
baseVersion: "24.6.0",
|
baseVersion: "24.0.0",
|
||||||
productVersion: "24.6.0",
|
productVersion: "24.0.0",
|
||||||
apPlatform: "macuniversal",
|
apPlatform: "macuniversal",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
buildGuid: ""
|
buildGuid: "q1w2e3r4-t5y6-u7i8-o9p0-a1s2d3f4g5h6"
|
||||||
),
|
|
||||||
"24.5.0": Product.ProductVersion(
|
|
||||||
sapCode: "PHSP",
|
|
||||||
baseVersion: "24.5.0",
|
|
||||||
productVersion: "24.5.0",
|
|
||||||
apPlatform: "macuniversal",
|
|
||||||
dependencies: [],
|
|
||||||
buildGuid: ""
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
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",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"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" : {
|
"About" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -89,6 +92,9 @@
|
|||||||
},
|
},
|
||||||
"下载已取消" : {
|
"下载已取消" : {
|
||||||
"comment" : "Download cancelled"
|
"comment" : "Download cancelled"
|
||||||
|
},
|
||||||
|
"下载此版本" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"下载管理" : {
|
"下载管理" : {
|
||||||
|
|
||||||
@@ -98,12 +104,21 @@
|
|||||||
},
|
},
|
||||||
"下载错误" : {
|
"下载错误" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"产品和包列表" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"使用默认目录" : {
|
"使用默认目录" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"使用默认语言" : {
|
"使用默认语言" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"依赖包:" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"依赖包: %lld" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"全部暂停" : {
|
"全部暂停" : {
|
||||||
|
|
||||||
@@ -119,9 +134,6 @@
|
|||||||
},
|
},
|
||||||
"加载失败" : {
|
"加载失败" : {
|
||||||
|
|
||||||
},
|
|
||||||
"包列表 (%lld)" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"取消" : {
|
"取消" : {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user