mirror of
https://github.com/X1a0He/Adobe-Downloader.git
synced 2025-11-25 03:14:57 +08:00
646 lines
24 KiB
Swift
646 lines
24 KiB
Swift
//
|
|
// CleanupView.swift
|
|
// Adobe Downloader
|
|
//
|
|
// Created by X1a0He on 3/28/25.
|
|
//
|
|
import SwiftUI
|
|
|
|
struct CleanupView: View {
|
|
@State private var showConfirmation = false
|
|
@State private var showAlert = false
|
|
@State private var alertMessage = ""
|
|
@State private var selectedOptions = Set<CleanupOption>()
|
|
@State private var isProcessing = false
|
|
@State private var cleanupLogs: [CleanupLog] = []
|
|
@State private var currentCommandIndex = 0
|
|
@State private var totalCommands = 0
|
|
@State private var expandedOptions = Set<CleanupOption>()
|
|
@State private var isCancelled = false
|
|
@State private var isLogExpanded = false
|
|
|
|
private var percentage: Int {
|
|
totalCommands > 0 ? Int((Double(currentCommandIndex) / Double(totalCommands)) * 100) : 0
|
|
}
|
|
|
|
private func calculateProgressWidth(_ width: CGFloat) -> CGFloat {
|
|
if totalCommands <= 0 {
|
|
return 0
|
|
}
|
|
let progress = Double(currentCommandIndex) / Double(totalCommands)
|
|
let clampedProgress = min(1.0, max(0.0, progress))
|
|
return width * CGFloat(clampedProgress)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading) {
|
|
Text("选择要清理的内容")
|
|
.font(.headline)
|
|
.padding(.bottom, 4)
|
|
|
|
Text("注意:清理过程不会影响 Adobe Downloader 的文件和下载数据")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
LazyVStack(alignment: .leading) {
|
|
ForEach(CleanupOption.allCases) { option in
|
|
CleanupOptionView(
|
|
option: option,
|
|
isProcessing: isProcessing,
|
|
selectedOptions: $selectedOptions,
|
|
expandedOptions: $expandedOptions
|
|
)
|
|
.id(option.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.padding(.vertical, 8)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if isProcessing {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("清理进度:\(currentCommandIndex)/\(totalCommands)")
|
|
.font(.system(size: 12, weight: .medium))
|
|
|
|
Spacer()
|
|
|
|
Text("\(percentage)%")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundColor(.blue)
|
|
}
|
|
.padding(.horizontal, 2)
|
|
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color.secondary.opacity(0.2))
|
|
.frame(height: 12)
|
|
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(
|
|
LinearGradient(
|
|
gradient: Gradient(colors: [Color.blue.opacity(0.8), Color.blue]),
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: calculateProgressWidth(geometry.size.width), height: 12)
|
|
}
|
|
}
|
|
.frame(height: 12)
|
|
.animation(.linear(duration: 0.3), value: currentCommandIndex)
|
|
|
|
Button(action: {
|
|
isCancelled = true
|
|
}) {
|
|
Text("取消清理")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.white)
|
|
}
|
|
.buttonStyle(BeautifulButtonStyle(baseColor: Color.red.opacity(0.8)))
|
|
.disabled(isCancelled)
|
|
.opacity(isCancelled ? 0.5 : 1)
|
|
}
|
|
.padding(10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color(NSColor.controlBackgroundColor))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.blue.opacity(0.2), lineWidth: 1)
|
|
)
|
|
|
|
if let lastLog = cleanupLogs.last {
|
|
CurrentLogView(lastLog: lastLog)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Button(action: {
|
|
withAnimation {
|
|
isLogExpanded.toggle()
|
|
}
|
|
}) {
|
|
HStack {
|
|
Image(systemName: "terminal.fill")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.blue.opacity(0.8))
|
|
|
|
Text("最近日志:")
|
|
.font(.system(size: 12, weight: .medium))
|
|
|
|
if isProcessing {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(Color.green)
|
|
.frame(width: 6, height: 6)
|
|
|
|
Text("正在执行...")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 2)
|
|
.background(Color.secondary.opacity(0.1))
|
|
.cornerRadius(4)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(isLogExpanded ? "收起" : "展开")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.blue)
|
|
.padding(.trailing, 4)
|
|
|
|
Image(systemName: isLogExpanded ? "chevron.down" : "chevron.right")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.blue)
|
|
}
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 10)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
ScrollView {
|
|
if cleanupLogs.isEmpty {
|
|
EmptyLogView()
|
|
} else {
|
|
LogContentView(
|
|
logs: cleanupLogs,
|
|
isExpanded: isLogExpanded
|
|
)
|
|
}
|
|
}
|
|
.frame(height: cleanupLogs.isEmpty ? 80 : (isLogExpanded ? 220 : 54))
|
|
.animation(.easeInOut(duration: 0.3), value: isLogExpanded)
|
|
.background(Color(NSColor.textBackgroundColor).opacity(0.6))
|
|
.cornerRadius(6)
|
|
.padding(.bottom, 1)
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color(NSColor.textBackgroundColor))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
Group {
|
|
Button(action: {
|
|
selectedOptions = Set(CleanupOption.allCases)
|
|
}) {
|
|
Text("全选")
|
|
.frame(minWidth: 50)
|
|
}
|
|
.buttonStyle(BeautifulButtonStyle(baseColor: Color.blue.opacity(0.7)))
|
|
.foregroundColor(.white)
|
|
|
|
Button(action: {
|
|
selectedOptions.removeAll()
|
|
}) {
|
|
Text("取消全选")
|
|
.frame(minWidth: 65)
|
|
}
|
|
.buttonStyle(BeautifulButtonStyle(baseColor: Color.gray.opacity(0.7)))
|
|
.foregroundColor(.white)
|
|
|
|
#if DEBUG
|
|
Button(action: {
|
|
if expandedOptions.count == CleanupOption.allCases.count {
|
|
expandedOptions.removeAll()
|
|
} else {
|
|
expandedOptions = Set(CleanupOption.allCases)
|
|
}
|
|
}) {
|
|
Text(expandedOptions.count == CleanupOption.allCases.count ? "折叠全部" : "展开全部")
|
|
.frame(minWidth: 65)
|
|
}
|
|
.buttonStyle(BeautifulButtonStyle(baseColor: Color.purple.opacity(0.7)))
|
|
.foregroundColor(.white)
|
|
#endif
|
|
}
|
|
.disabled(isProcessing)
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
if !selectedOptions.isEmpty {
|
|
showConfirmation = true
|
|
}
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "trash")
|
|
Text("开始清理")
|
|
}
|
|
.frame(minWidth: 100)
|
|
}
|
|
.buttonStyle(BeautifulButtonStyle(baseColor: Color.red.opacity(0.8)))
|
|
.foregroundColor(.white)
|
|
.opacity(selectedOptions.isEmpty || isProcessing ? 0.5 : 1)
|
|
.saturation(selectedOptions.isEmpty || isProcessing ? 0.3 : 1)
|
|
.disabled(selectedOptions.isEmpty || isProcessing)
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
.padding()
|
|
.alert("确认清理", isPresented: $showConfirmation) {
|
|
Button("取消", role: .cancel) { }
|
|
Button("确定", role: .destructive) {
|
|
cleanupSelectedItems()
|
|
}
|
|
} message: {
|
|
Text("这将删除所选的 Adobe 相关文件,该操作不可撤销。清理过程不会影响 Adobe Downloader 的文件和下载数据。是否继续?")
|
|
}
|
|
.alert(isPresented: $showAlert) {
|
|
Alert(
|
|
title: Text("清理结果"),
|
|
message: Text(alertMessage),
|
|
dismissButton: .default(Text("确定"))
|
|
)
|
|
}
|
|
}
|
|
|
|
private func cleanupSelectedItems() {
|
|
isProcessing = true
|
|
cleanupLogs.removeAll()
|
|
currentCommandIndex = 0
|
|
isCancelled = false
|
|
|
|
let userHome = NSHomeDirectory()
|
|
|
|
var commands: [String] = []
|
|
for option in selectedOptions {
|
|
let userCommands = option.commands.map { command in
|
|
command.replacingOccurrences(of: "~/", with: "\(userHome)/")
|
|
}
|
|
commands.append(contentsOf: userCommands)
|
|
}
|
|
|
|
totalCommands = commands.count
|
|
|
|
executeNextCommand(commands: commands)
|
|
}
|
|
|
|
private func executeNextCommand(commands: [String]) {
|
|
guard currentCommandIndex < commands.count else {
|
|
DispatchQueue.main.async {
|
|
isProcessing = false
|
|
alertMessage = isCancelled ? String(localized: "清理已取消") : String(localized: "清理完成")
|
|
showAlert = true
|
|
selectedOptions.removeAll()
|
|
}
|
|
return
|
|
}
|
|
|
|
if isCancelled {
|
|
DispatchQueue.main.async {
|
|
isProcessing = false
|
|
alertMessage = String(localized: "清理已取消")
|
|
showAlert = true
|
|
selectedOptions.removeAll()
|
|
}
|
|
return
|
|
}
|
|
|
|
let command = commands[currentCommandIndex]
|
|
cleanupLogs.append(CleanupLog(
|
|
timestamp: Date(),
|
|
command: command,
|
|
status: .running,
|
|
message: String(localized: "正在执行...")
|
|
))
|
|
|
|
let timeoutTimer = DispatchSource.makeTimerSource(queue: .global())
|
|
timeoutTimer.schedule(deadline: .now() + 30)
|
|
timeoutTimer.setEventHandler { [self] in
|
|
if let index = cleanupLogs.lastIndex(where: { $0.command == command }) {
|
|
DispatchQueue.main.async {
|
|
cleanupLogs[index] = CleanupLog(
|
|
timestamp: Date(),
|
|
command: command,
|
|
status: .error,
|
|
message: String(localized: "执行结果:执行超时\n执行命令:\(command)")
|
|
)
|
|
currentCommandIndex += 1
|
|
executeNextCommand(commands: commands)
|
|
}
|
|
}
|
|
}
|
|
timeoutTimer.resume()
|
|
|
|
PrivilegedHelperAdapter.shared.executeCommand(command) { [self] (output: String) in
|
|
timeoutTimer.cancel()
|
|
DispatchQueue.main.async {
|
|
if let index = cleanupLogs.lastIndex(where: { $0.command == command }) {
|
|
if isCancelled {
|
|
cleanupLogs[index] = CleanupLog(
|
|
timestamp: Date(),
|
|
command: command,
|
|
status: .cancelled,
|
|
message: String(localized: "已取消")
|
|
)
|
|
} else {
|
|
let isSuccess = output.isEmpty || output.lowercased() == "success"
|
|
let message = if isSuccess {
|
|
String(localized: "执行成功")
|
|
} else {
|
|
String(localized: "执行结果:\(output)\n执行命令:\(command)")
|
|
}
|
|
cleanupLogs[index] = CleanupLog(
|
|
timestamp: Date(),
|
|
command: command,
|
|
status: isSuccess ? .success : .error,
|
|
message: message
|
|
)
|
|
}
|
|
}
|
|
currentCommandIndex += 1
|
|
executeNextCommand(commands: commands)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func statusIcon(for status: CleanupLog.LogStatus) -> String {
|
|
switch status {
|
|
case .running:
|
|
return "arrow.triangle.2.circlepath"
|
|
case .success:
|
|
return "checkmark.circle.fill"
|
|
case .error:
|
|
return "exclamationmark.circle.fill"
|
|
case .cancelled:
|
|
return "xmark.circle.fill"
|
|
}
|
|
}
|
|
|
|
private func statusColor(for status: CleanupLog.LogStatus) -> Color {
|
|
switch status {
|
|
case .running:
|
|
return .blue
|
|
case .success:
|
|
return .green
|
|
case .error:
|
|
return .red
|
|
case .cancelled:
|
|
return .orange
|
|
}
|
|
}
|
|
|
|
private func timeString(from date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm:ss"
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|
|
|
|
struct CleanupOptionView: View {
|
|
let option: CleanupOption
|
|
let isProcessing: Bool
|
|
@Binding var selectedOptions: Set<CleanupOption>
|
|
@Binding var expandedOptions: Set<CleanupOption>
|
|
|
|
private var isExpanded: Bool {
|
|
expandedOptions.contains(option)
|
|
}
|
|
|
|
private var isSelected: Bool {
|
|
selectedOptions.contains(option)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
#if DEBUG
|
|
Button(action: {
|
|
let animation = Animation.easeInOut(duration: 0.2)
|
|
withAnimation(animation) {
|
|
if expandedOptions.contains(option) {
|
|
expandedOptions.remove(option)
|
|
} else {
|
|
expandedOptions.insert(option)
|
|
}
|
|
}
|
|
}) {
|
|
HStack(spacing: 12) {
|
|
Toggle(isOn: Binding(
|
|
get: { isSelected },
|
|
set: { isSelected in
|
|
if isSelected {
|
|
selectedOptions.insert(option)
|
|
} else {
|
|
selectedOptions.remove(option)
|
|
}
|
|
}
|
|
)) {
|
|
EmptyView()
|
|
}
|
|
.toggleStyle(SwitchToggleStyle(tint: .green))
|
|
.disabled(isProcessing)
|
|
.labelsHidden()
|
|
.scaleEffect(0.85)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(option.localizedName)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
Text(option.description)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
|
.foregroundColor(.secondary)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.frame(width: 20, height: 20)
|
|
}
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 10)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isProcessing)
|
|
|
|
if isExpanded {
|
|
CommandListView(option: option)
|
|
}
|
|
#else
|
|
HStack(spacing: 12) {
|
|
Toggle(isOn: Binding(
|
|
get: { selectedOptions.contains(option) },
|
|
set: { isSelected in
|
|
if isSelected {
|
|
selectedOptions.insert(option)
|
|
} else {
|
|
selectedOptions.remove(option)
|
|
}
|
|
}
|
|
)) {
|
|
EmptyView()
|
|
}
|
|
.toggleStyle(SwitchToggleStyle(tint: .green))
|
|
.disabled(isProcessing)
|
|
.labelsHidden()
|
|
.scaleEffect(0.85)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(option.localizedName)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
Text(option.description)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 10)
|
|
#endif
|
|
}
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
|
)
|
|
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
|
}
|
|
}
|
|
|
|
struct CommandListView: View {
|
|
let option: CleanupOption
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("将执行的命令:")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
.padding(.top, 2)
|
|
.padding(.horizontal, 12)
|
|
|
|
LazyVStack(spacing: 6) {
|
|
ForEach(option.commands, id: \.self) { command in
|
|
Text(command)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(Color(.white))
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.black.opacity(0.85))
|
|
.cornerRadius(6)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.padding(.bottom, 12)
|
|
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
|
|
}
|
|
}
|
|
|
|
struct CurrentLogView: View {
|
|
let lastLog: CleanupLog
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "arrow.triangle.turn.up.right.circle.fill")
|
|
.foregroundColor(.blue.opacity(0.8))
|
|
.font(.system(size: 14))
|
|
|
|
Text("当前执行:")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
#if DEBUG
|
|
Text(lastLog.command)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
#else
|
|
Text(CleanupLog.getCleanupDescription(for: lastLog.command))
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
#endif
|
|
}
|
|
.frame(height: 70)
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
.frame(maxWidth: .infinity)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color(NSColor.textBackgroundColor))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct EmptyLogView: View {
|
|
var body: some View {
|
|
HStack {
|
|
Spacer()
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "doc.text.magnifyingglass")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(.secondary.opacity(0.6))
|
|
|
|
Text("暂无清理记录")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.vertical, 16)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct LogContentView: View {
|
|
let logs: [CleanupLog]
|
|
let isExpanded: Bool
|
|
|
|
var body: some View {
|
|
ScrollViewReader { scrollProxy in
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if isExpanded {
|
|
LazyVStack(alignment: .leading, spacing: 8) {
|
|
ForEach(logs.reversed()) { log in
|
|
LogEntryView(log: log)
|
|
.id(log.id)
|
|
}
|
|
}
|
|
} else if let lastLog = logs.last {
|
|
LogEntryView(log: lastLog)
|
|
.id(lastLog.id)
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 2)
|
|
.onChange(of: logs.count) { newCount in
|
|
if let lastLog = logs.last {
|
|
withAnimation {
|
|
scrollProxy.scrollTo(lastLog.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|