Files
Adobe-Downloader/Adobe Downloader/Views/DownloadProgressView.swift
X1a0He 2cb51c7076 feat: Added "Cleanup Tool" and "Common Issues" functions in the program settings page
1. Fixed the issue of Helper not being able to reconnect in some cases
2. Fixed the issue of not being able to reconnect after reinstalling the program and reinstalling Helper
3. Adjusted the content translation of X1a0He CC, version 1.5.0 can choose "Download and Process" and "Only Download"
4. Adjusted the translation of some Setup component content
5. Added "Cleanup Tool" and "Common Issues" functions in the program settings page
6. Added the current version display in the program settings page
- PS: The "Cleanup Tool" function in the current version is an experimental feature. If some files are not cleaned up, please feedback in time
- PS: 1.5.0 version will be the last open source version, please be aware
2025-02-05 23:09:46 +08:00

818 lines
30 KiB
Swift

//
// Adobe Downloader
//
// Created by X1a0He on 2024/10/30.
//
import SwiftUI
struct DownloadProgressView: View {
@EnvironmentObject private var networkManager: NetworkManager
@ObservedObject var task: NewDownloadTask
let onCancel: () -> Void
let onPause: () -> Void
let onResume: () -> Void
let onRetry: () -> Void
let onRemove: () -> Void
@State private var showInstallPrompt = false
@State private var isInstalling = false
@State private var isPackageListExpanded: Bool = false
@State private var expandedProducts: Set<String> = []
@State private var iconImage: NSImage? = nil
@State private var showSetupProcessAlert = false
@State private var showCommandLineInstall = false
@State private var showCopiedAlert = false
private var statusLabel: some View {
Text(task.status.description)
.font(.caption)
.foregroundColor(statusColor)
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(statusBackgroundColor)
.cornerRadius(4)
}
private var statusColor: Color {
switch task.status {
case .downloading:
return .white
case .preparing:
return .white
case .completed:
return .white
case .failed:
return .white
case .paused:
return .white
case .waiting:
return .white
case .retrying:
return .white
}
}
private var statusBackgroundColor: Color {
switch task.status {
case .downloading:
return Color.blue
case .preparing:
return Color.purple.opacity(0.8)
case .completed:
return Color.green.opacity(0.8)
case .failed:
return Color.red.opacity(0.8)
case .paused:
return Color.orange.opacity(0.8)
case .waiting:
return Color.gray.opacity(0.8)
case .retrying:
return Color.yellow.opacity(0.8)
}
}
private var actionButtons: some View {
HStack(spacing: 8) {
switch task.status {
case .downloading, .preparing, .waiting:
Button(action: onPause) {
Label("暂停", systemImage: "pause.fill")
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.regular)
Button(action: onCancel) {
Label("取消", systemImage: "xmark")
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.regular)
case .paused:
Button(action: onResume) {
Label("继续", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.controlSize(.regular)
Button(action: onCancel) {
Label("取消", systemImage: "xmark")
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.regular)
case .failed(let info):
if info.recoverable {
Button(action: onRetry) {
Label("重试", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.controlSize(.regular)
}
Button(action: onRemove) {
Label("移除", systemImage: "xmark")
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.regular)
case .completed:
HStack(spacing: 8) {
if task.displayInstallButton {
Button(action: {
#if DEBUG
do {
_ = try PrivilegedHelperManager.shared.getHelperProxy()
showInstallPrompt = false
isInstalling = true
Task {
await networkManager.installProduct(at: task.directory)
}
} catch {
showSetupProcessAlert = true
}
#else
if !ModifySetup.isSetupModified() {
showSetupProcessAlert = true
} else {
do {
_ = try PrivilegedHelperManager.shared.getHelperProxy()
showInstallPrompt = false
isInstalling = true
Task {
await networkManager.installProduct(at: task.directory)
}
} catch {
showSetupProcessAlert = true
}
}
#endif
}) {
Label("安装", systemImage: "square.and.arrow.down.on.square")
}
.buttonStyle(.borderedProminent)
.tint(.green)
.controlSize(.regular)
.alert("Setup 组件未处理", isPresented: $showSetupProcessAlert) {
Button("确定") { }
} message: {
if !ModifySetup.isSetupModified() {
Text("未对 Setup 组件进行处理或者 Setup 组件不存在,无法使用安装功能\n你可以通过设置页面再次对 Setup 组件进行处理")
.font(.system(size: 18))
} else {
Text("Helper 未安装或未连接,请先在设置中安装并连接 Helper")
.font(.system(size: 18))
}
}
}
Button(action: onRemove) {
Label("删除", systemImage: "trash")
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.regular)
}
case .retrying:
Button(action: onCancel) {
Label("取消", systemImage: "xmark")
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.regular)
}
}
.controlSize(.small)
.sheet(isPresented: $showInstallPrompt) {
if task.displayInstallButton {
VStack(spacing: 20) {
Text("是否要安装 \(task.displayName)?")
.font(.headline)
HStack(spacing: 16) {
Button("取消") {
showInstallPrompt = false
}
.buttonStyle(.bordered)
Button("安装") {
showInstallPrompt = false
isInstalling = true
Task {
await networkManager.installProduct(at: task.directory)
}
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.frame(width: 300)
}
}
.sheet(isPresented: $isInstalling) {
Group {
if case .installing(let progress, let status) = networkManager.installationState {
InstallProgressView(
productName: task.displayName,
progress: progress,
status: status,
onCancel: {
networkManager.cancelInstallation()
isInstalling = false
},
onRetry: nil
)
} else if case .completed = networkManager.installationState {
InstallProgressView(
productName: task.displayName,
progress: 1.0,
status: String(localized: "安装完成"),
onCancel: {
isInstalling = false
},
onRetry: nil
)
} else if case .failed(let error) = networkManager.installationState {
InstallProgressView(
productName: task.displayName,
progress: 0,
status: String(localized: "安装失败: \(error.localizedDescription)"),
onCancel: {
isInstalling = false
},
onRetry: {
Task {
await networkManager.retryInstallation(at: task.directory)
}
}
)
} else {
InstallProgressView(
productName: task.displayName,
progress: 0,
status: String(localized: "准备安装..."),
onCancel: {
networkManager.cancelInstallation()
isInstalling = false
},
onRetry: nil
)
}
}
.frame(minWidth: 400, minHeight: 200)
.background(Color(NSColor.windowBackgroundColor))
}
}
private func formatFileSize(_ size: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: size)
}
private func formatSpeed(_ bytesPerSecond: Double) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter.string(fromByteCount: Int64(bytesPerSecond)) + "/s"
}
private func openInFinder(_ path: String) {
NSWorkspace.shared.selectFile(URL(fileURLWithPath: path).path, inFileViewerRootedAtPath: URL(fileURLWithPath: path).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)
}
private func loadIcon() {
if let sap = networkManager.saps[task.sapCode],
let bestIcon = sap.getBestIcon(),
let iconURL = URL(string: bestIcon.url) {
if let cachedImage = IconCache.shared.getIcon(for: bestIcon.url) {
self.iconImage = cachedImage
return
}
Task {
do {
var request = URLRequest(url: iconURL)
request.timeoutInterval = 10
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = NSImage(data: data) else {
throw URLError(.badServerResponse)
}
IconCache.shared.setIcon(image, for: bestIcon.url)
await MainActor.run {
self.iconImage = image
}
} catch {
if let localImage = NSImage(named: task.sapCode) {
await MainActor.run {
self.iconImage = localImage
}
}
}
}
} else if let localImage = NSImage(named: task.sapCode) {
self.iconImage = localImage
}
}
private func formatPath(_ path: String) -> String {
let url = URL(fileURLWithPath: path)
let components = url.pathComponents
if components.count <= 4 {
return path
}
let lastComponents = components.suffix(2)
return "/" + lastComponents.joined(separator: "/")
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Group {
if let iconImage = iconImage {
Image(nsImage: iconImage)
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "app.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(.secondary)
}
}
.frame(width: 32, height: 32)
.onAppear(perform: loadIcon)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
HStack(spacing: 4) {
Text(task.displayName)
.font(.headline)
Text(task.version)
.foregroundColor(.secondary)
}
statusLabel
Spacer()
}
Text(formatPath(task.directory.path))
.font(.caption)
.foregroundColor(.blue)
.lineLimit(1)
.truncationMode(.middle)
.onTapGesture {
openInFinder(task.directory.path)
}
.help(task.directory.path)
}
}
VStack(alignment: .leading, spacing: 4) {
HStack {
HStack(spacing: 4) {
Text(task.formattedDownloadedSize)
Text("/")
Text(task.formattedTotalSize)
}
Spacer()
if task.totalSpeed > 0 {
Text(formatRemainingTime(
totalSize: task.totalSize,
downloadedSize: task.totalDownloadedSize,
speed: task.totalSpeed
))
.foregroundColor(.secondary)
}
Text("\(Int(task.totalProgress * 100))%")
if task.totalSpeed > 0 {
Text(formatSpeed(task.totalSpeed))
.foregroundColor(.secondary)
}
}
.font(.caption)
ProgressView(value: task.totalProgress)
.progressViewStyle(.linear)
}
if !task.productsToDownload.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 6) {
HStack {
Button(action: {
withAnimation {
isPackageListExpanded.toggle()
}
}) {
HStack {
Image(systemName: isPackageListExpanded ? "chevron.down" : "chevron.right")
.foregroundColor(.secondary)
Text("产品和包列表")
.font(.caption)
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer()
if case .completed = task.status {
Button(action: {
showCommandLineInstall.toggle()
}) {
Label("命令行安装", systemImage: "terminal")
}
.buttonStyle(.borderedProminent)
.tint(.purple)
.controlSize(.regular)
.popover(isPresented: $showCommandLineInstall, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 8) {
Button("复制命令") {
let setupPath = "/Library/Application Support/Adobe/Adobe Desktop Common/HDBox/Setup"
let driverPath = "\(task.directory.path)/driver.xml"
let command = "sudo \"\(setupPath)\" --install=1 --driverXML=\"\(driverPath)\""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(command, forType: .string)
showCopiedAlert = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
showCopiedAlert = false
}
}
if showCopiedAlert {
Text("已复制")
.font(.caption)
.foregroundColor(.green)
}
let setupPath = "/Library/Application Support/Adobe/Adobe Desktop Common/HDBox/Setup"
let driverPath = "\(task.directory.path)/driver.xml"
let command = "sudo \"\(setupPath)\" --install=1 --driverXML=\"\(driverPath)\""
Text(command)
.font(.system(.caption, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
.padding(8)
.background(Color.secondary.opacity(0.1))
.cornerRadius(6)
}
.padding()
.frame(width: 400)
}
}
actionButtons
}
if isPackageListExpanded {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 8) {
ForEach(task.productsToDownload, id: \.sapCode) { product in
ProductRow(
product: product,
isCurrentProduct: task.currentPackage?.id == product.packages.first?.id,
expandedProducts: $expandedProducts
)
}
}
}
.frame(maxHeight: 200)
}
}
}
}
.padding()
.background(Color(NSColor.windowBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
.shadow(color: Color.primary.opacity(0.05), radius: 2, x: 0, y: 1)
}
}
struct ProductRow: View {
@ObservedObject var product: ProductsToDownload
let isCurrentProduct: Bool
@Binding var expandedProducts: Set<String>
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Button(action: {
withAnimation {
if expandedProducts.contains(product.sapCode) {
expandedProducts.remove(product.sapCode)
} else {
expandedProducts.insert(product.sapCode)
}
}
}) {
HStack {
Image(systemName: "cube.box")
.foregroundColor(.blue)
Text("\(product.sapCode) \(product.version)\(product.sapCode != "APRO" ? " - (\(product.buildGuid))" : "")")
.font(.caption)
.fontWeight(.medium)
Spacer()
Text("\(product.completedPackages)/\(product.totalPackages)")
.font(.caption)
.foregroundColor(.secondary)
Image(systemName: expandedProducts.contains(product.sapCode) ? "chevron.down" : "chevron.right")
.foregroundColor(.secondary)
}
.padding(.vertical, 8)
.padding(.horizontal)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
.buttonStyle(.plain)
if expandedProducts.contains(product.sapCode) {
VStack(spacing: 8) {
ForEach(product.packages) { package in
PackageRow(package: package)
.padding(.horizontal)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
}
.padding(.leading, 24)
}
}
}
}
struct PackageRow: View {
@ObservedObject var package: Package
private func statusView() -> some View {
Group {
switch package.status {
case .waiting:
HStack {
Image(systemName: "hourglass.circle.fill")
Text(package.status.description)
}
.foregroundColor(.secondary)
case .downloading:
HStack {
Text("\(Int(package.progress * 100))%")
}
.foregroundColor(.blue)
case .completed:
HStack {
Image(systemName: "checkmark.circle.fill")
Text(package.status.description)
}
.foregroundColor(.green)
default:
HStack {
Text(package.status.description)
}
.foregroundColor(.secondary)
}
}
}
private func formatSpeed(_ bytesPerSecond: Double) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter.string(fromByteCount: Int64(bytesPerSecond)) + "/s"
}
var body: some View {
VStack(spacing: 6) {
HStack {
Text("\(package.fullPackageName) (\(package.packageVersion))")
.font(.caption)
.foregroundColor(.primary)
HStack(spacing: 4) {
Text(package.type)
.font(.caption2)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(Color.blue.opacity(0.1))
.cornerRadius(2)
Text(package.formattedSize)
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
statusView()
.font(.caption)
}
if package.status == .downloading {
VStack() {
ProgressView(value: package.progress)
.progressViewStyle(.linear)
HStack {
Text("\(package.formattedDownloadedSize) / \(package.formattedSize)")
Spacer()
if package.speed > 0 {
Text(formatSpeed(package.speed))
}
}
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.padding(.vertical, 10)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(6)
}
}
#Preview("下载中") {
let product = ProductsToDownload(
sapCode: "AUDT",
version: "25.0",
buildGuid: "123"
)
product.packages = [
Package(
type: "Application",
fullPackageName: "AdobeAudition25All",
downloadSize: 878454797,
downloadURL: "https://example.com/download",
packageVersion: "25.0.0.1"
)
]
return DownloadProgressView(
task: NewDownloadTask(
sapCode: "AUDT",
version: "25.0",
language: "zh_CN",
displayName: "Adobe Audition",
directory: URL(fileURLWithPath: "/Users/test/Downloads/Adobe Audition_25.0-zh_CN-macuniversal"),
productsToDownload: [product],
createAt: Date(),
totalStatus: .downloading(DownloadStatus.DownloadInfo(
fileName: "AdobeAudition25All_stripped.zip",
currentPackageIndex: 0,
totalPackages: 2,
startTime: Date(),
estimatedTimeRemaining: nil
)),
totalProgress: 0.45,
totalDownloadedSize: 457424883,
totalSize: 878454797,
totalSpeed: 1024 * 1024 * 2,
platform: "macuniversal"
),
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
)
.environmentObject(NetworkManager())
}
#Preview("已完成") {
let product = ProductsToDownload(
sapCode: "AUDT",
version: "25.0",
buildGuid: "123"
)
let package = Package(
type: "Application",
fullPackageName: "AdobeAudition25All",
downloadSize: 878454797,
downloadURL: "https://example.com/download",
packageVersion: "25.0.0.1"
)
package.status = .completed
package.progress = 1.0
package.downloadedSize = 878454797
package.downloaded = true
product.packages = [package]
return DownloadProgressView(
task: NewDownloadTask(
sapCode: "AUDT",
version: "25.0",
language: "zh_CN",
displayName: "Adobe Audition",
directory: URL(fileURLWithPath: "/Users/test/Downloads/Adobe Audition_25.0-zh_CN-macuniversal"),
productsToDownload: [product],
createAt: Date(),
totalStatus: .completed(DownloadStatus.CompletionInfo(
timestamp: Date(),
totalTime: 120,
totalSize: 878454797
)),
totalProgress: 1.0,
totalDownloadedSize: 878454797,
totalSize: 878454797,
totalSpeed: 0,
platform: "macuniversal"
),
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
)
.environmentObject(NetworkManager())
}
#Preview("暂停") {
let product = ProductsToDownload(
sapCode: "AUDT",
version: "25.0",
buildGuid: "123"
)
let package = Package(
type: "Application",
fullPackageName: "AdobeAudition25All",
downloadSize: 878454797,
downloadURL: "https://example.com/download",
packageVersion: "25.0.0.1"
)
package.status = .paused
package.progress = 0.52
package.downloadedSize = 457424883
product.packages = [package]
return DownloadProgressView(
task: NewDownloadTask(
sapCode: "AUDT",
version: "25.0",
language: "zh_CN",
displayName: "Adobe Audition",
directory: URL(fileURLWithPath: "/Users/test/Downloads/Adobe Audition_25.0-zh_CN-macuniversal"),
productsToDownload: [product],
createAt: Date(),
totalStatus: .paused(DownloadStatus.PauseInfo(
reason: .userRequested,
timestamp: Date(),
resumable: true
)),
totalProgress: 0.52,
totalDownloadedSize: 457424883,
totalSize: 878454797,
totalSpeed: 0,
platform: "macuniversal"
),
onCancel: {},
onPause: {},
onResume: {},
onRetry: {},
onRemove: {}
)
.environmentObject(NetworkManager())
}