475 lines
16 KiB
Swift
475 lines
16 KiB
Swift
import Combine
|
|
import Foundation
|
|
import MacToolsPluginKit
|
|
|
|
enum DiskCleanControllerPhase: Equatable, Sendable {
|
|
case idle
|
|
case scanning
|
|
case scanned
|
|
case cleaning
|
|
case completed
|
|
}
|
|
|
|
struct DiskCleanControllerSnapshot: Equatable, Sendable {
|
|
let phase: DiskCleanControllerPhase
|
|
let selectedChoices: Set<DiskCleanChoice>
|
|
let scanResult: DiskCleanScanResult?
|
|
let executionResult: DiskCleanExecutionResult?
|
|
let isResultStale: Bool
|
|
let errorMessage: String?
|
|
let scanLogEntries: [DiskCleanScanLogEntry]
|
|
|
|
init(
|
|
phase: DiskCleanControllerPhase,
|
|
selectedChoices: Set<DiskCleanChoice>,
|
|
scanResult: DiskCleanScanResult?,
|
|
executionResult: DiskCleanExecutionResult?,
|
|
isResultStale: Bool,
|
|
errorMessage: String?,
|
|
scanLogEntries: [DiskCleanScanLogEntry] = []
|
|
) {
|
|
self.phase = phase
|
|
self.selectedChoices = selectedChoices
|
|
self.scanResult = scanResult
|
|
self.executionResult = executionResult
|
|
self.isResultStale = isResultStale
|
|
self.errorMessage = errorMessage
|
|
self.scanLogEntries = scanLogEntries
|
|
}
|
|
|
|
var subtitle: String {
|
|
subtitle()
|
|
}
|
|
|
|
func subtitle(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
|
switch phase {
|
|
case .idle:
|
|
return localization.string("controller.subtitle.idle", defaultValue: "选择清理范围")
|
|
case .scanning:
|
|
return localization.string("controller.subtitle.scanning", defaultValue: "正在扫描")
|
|
case .scanned:
|
|
if isResultStale {
|
|
return localization.string("controller.subtitle.stale", defaultValue: "清理范围已变化")
|
|
}
|
|
return scanResult.map {
|
|
localization.format(
|
|
"controller.subtitle.scannedCount",
|
|
defaultValue: "%d 项可清理",
|
|
$0.cleanableCandidates.count
|
|
)
|
|
} ?? localization.string("controller.subtitle.scanned", defaultValue: "扫描完成")
|
|
case .cleaning:
|
|
return localization.string("controller.subtitle.cleaning", defaultValue: "正在清理")
|
|
case .completed:
|
|
return localization.string("controller.subtitle.completed", defaultValue: "清理完成")
|
|
}
|
|
}
|
|
|
|
var isBusy: Bool {
|
|
phase == .scanning || phase == .cleaning
|
|
}
|
|
|
|
var canScan: Bool {
|
|
!isBusy && !selectedChoices.isEmpty
|
|
}
|
|
|
|
var canClean: Bool {
|
|
phase == .scanned
|
|
&& !isResultStale
|
|
&& scanResult?.cleanableCandidates.isEmpty == false
|
|
}
|
|
|
|
static let initial = DiskCleanControllerSnapshot(
|
|
phase: .idle,
|
|
selectedChoices: Set(DiskCleanChoice.allCases),
|
|
scanResult: nil,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: nil
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
protocol DiskCleanControlling: AnyObject {
|
|
var onStateChange: (() -> Void)? { get set }
|
|
var snapshot: DiskCleanControllerSnapshot { get }
|
|
|
|
func setChoice(_ choice: DiskCleanChoice, isSelected: Bool)
|
|
func scan()
|
|
func cleanSelected(candidateIDs: Set<DiskCleanCandidate.ID>)
|
|
func cancelCurrentOperation()
|
|
}
|
|
|
|
@MainActor
|
|
final class DiskCleanController: ObservableObject, DiskCleanControlling {
|
|
private static let scanLogFlushIntervalNanoseconds: UInt64 = 100_000_000
|
|
|
|
var onStateChange: (() -> Void)?
|
|
|
|
@Published private(set) var snapshot: DiskCleanControllerSnapshot {
|
|
didSet {
|
|
onStateChange?()
|
|
}
|
|
}
|
|
|
|
private let scanner: DiskCleanScanning
|
|
private let executor: DiskCleanExecuting
|
|
private let localization: PluginLocalization
|
|
|
|
private var currentTask: Task<Void, Never>?
|
|
private var currentOperationID: UUID?
|
|
private var scanLogFlushTask: Task<Void, Never>?
|
|
private var nextLogEntryID = 1
|
|
|
|
init(
|
|
scanner: DiskCleanScanning = DiskCleanScanner(),
|
|
executor: DiskCleanExecuting = DiskCleanExecutor(),
|
|
initialSnapshot: DiskCleanControllerSnapshot = .initial,
|
|
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
|
) {
|
|
self.scanner = scanner
|
|
self.executor = executor
|
|
self.localization = localization
|
|
snapshot = initialSnapshot
|
|
}
|
|
|
|
deinit {
|
|
currentTask?.cancel()
|
|
}
|
|
|
|
func setChoice(_ choice: DiskCleanChoice, isSelected: Bool) {
|
|
var nextChoices = snapshot.selectedChoices
|
|
if isSelected {
|
|
nextChoices.insert(choice)
|
|
} else {
|
|
nextChoices.remove(choice)
|
|
}
|
|
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: snapshot.phase,
|
|
selectedChoices: nextChoices,
|
|
scanResult: snapshot.scanResult,
|
|
executionResult: snapshot.executionResult,
|
|
isResultStale: isStale(scanResult: snapshot.scanResult, selectedChoices: nextChoices),
|
|
errorMessage: snapshot.errorMessage,
|
|
scanLogEntries: snapshot.scanLogEntries
|
|
)
|
|
}
|
|
|
|
func scan() {
|
|
guard snapshot.canScan else { return }
|
|
|
|
cancelTaskOnly()
|
|
|
|
let selectedChoices = snapshot.selectedChoices
|
|
let operationID = UUID()
|
|
currentOperationID = operationID
|
|
nextLogEntryID = 1
|
|
let scanLogBuffer = DiskCleanScanLogBuffer()
|
|
let initialLogEntries = [
|
|
makeLogEntry(
|
|
DiskCleanScanLogMessage(
|
|
text: localization.format(
|
|
"scanLog.started",
|
|
defaultValue: "开始扫描:%@",
|
|
selectedChoiceTitleList(selectedChoices)
|
|
),
|
|
tone: .info
|
|
)
|
|
)
|
|
]
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .scanning,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: nil,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: nil,
|
|
scanLogEntries: initialLogEntries
|
|
)
|
|
startScanLogFlushLoop(operationID: operationID, buffer: scanLogBuffer)
|
|
|
|
currentTask = Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let result = try await scanner.scan(choices: selectedChoices) { message in
|
|
await scanLogBuffer.append(message)
|
|
}
|
|
guard isCurrentOperation(operationID) else { return }
|
|
await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer)
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .scanned,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: result,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: nil,
|
|
scanLogEntries: snapshot.scanLogEntries
|
|
)
|
|
finishOperation(operationID)
|
|
} catch is CancellationError {
|
|
guard isCurrentOperation(operationID) else { return }
|
|
await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer)
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .idle,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: nil,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: nil,
|
|
scanLogEntries: snapshot.scanLogEntries
|
|
)
|
|
finishOperation(operationID)
|
|
} catch {
|
|
guard isCurrentOperation(operationID) else { return }
|
|
await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer)
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .idle,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: nil,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: Self.userFacingMessage(for: error),
|
|
scanLogEntries: scanLogEntries(
|
|
adding: [
|
|
DiskCleanScanLogMessage(
|
|
text: localization.format(
|
|
"scanLog.failed",
|
|
defaultValue: "扫描失败:%@",
|
|
Self.userFacingMessage(for: error)
|
|
),
|
|
tone: .error
|
|
)
|
|
],
|
|
to: snapshot.scanLogEntries
|
|
)
|
|
)
|
|
finishOperation(operationID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cleanSelected(candidateIDs: Set<DiskCleanCandidate.ID>) {
|
|
guard snapshot.canClean, let scanResult = snapshot.scanResult else { return }
|
|
|
|
cancelTaskOnly()
|
|
|
|
let selectedChoices = snapshot.selectedChoices
|
|
let operationID = UUID()
|
|
currentOperationID = operationID
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .cleaning,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: scanResult,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: nil,
|
|
scanLogEntries: snapshot.scanLogEntries
|
|
)
|
|
|
|
currentTask = Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let executionResult = try await executor.clean(
|
|
candidates: scanResult.candidates,
|
|
selectedCandidateIDs: candidateIDs
|
|
)
|
|
guard isCurrentOperation(operationID) else { return }
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .completed,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: scanResult,
|
|
executionResult: executionResult,
|
|
isResultStale: false,
|
|
errorMessage: nil,
|
|
scanLogEntries: snapshot.scanLogEntries
|
|
)
|
|
finishOperation(operationID)
|
|
} catch is CancellationError {
|
|
guard isCurrentOperation(operationID) else { return }
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .scanned,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: scanResult,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: nil,
|
|
scanLogEntries: snapshot.scanLogEntries
|
|
)
|
|
finishOperation(operationID)
|
|
} catch {
|
|
guard isCurrentOperation(operationID) else { return }
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .scanned,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: scanResult,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: Self.userFacingMessage(for: error),
|
|
scanLogEntries: snapshot.scanLogEntries
|
|
)
|
|
finishOperation(operationID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelCurrentOperation() {
|
|
let phase = snapshot.phase
|
|
let selectedChoices = snapshot.selectedChoices
|
|
let scanResult = snapshot.scanResult
|
|
let isResultStale = snapshot.isResultStale
|
|
let scanLogEntries = snapshot.scanLogEntries
|
|
|
|
cancelTaskOnly()
|
|
|
|
switch phase {
|
|
case .scanning:
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .idle,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: nil,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
errorMessage: nil,
|
|
scanLogEntries: scanLogEntries + [
|
|
makeLogEntry(
|
|
DiskCleanScanLogMessage(
|
|
text: localization.string("scanLog.stopped", defaultValue: "扫描已停止"),
|
|
tone: .warning
|
|
)
|
|
)
|
|
]
|
|
)
|
|
case .cleaning:
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: .scanned,
|
|
selectedChoices: selectedChoices,
|
|
scanResult: scanResult,
|
|
executionResult: nil,
|
|
isResultStale: isResultStale,
|
|
errorMessage: nil,
|
|
scanLogEntries: scanLogEntries
|
|
)
|
|
case .idle, .scanned, .completed:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func cancelTaskOnly() {
|
|
currentTask?.cancel()
|
|
currentTask = nil
|
|
currentOperationID = nil
|
|
scanLogFlushTask?.cancel()
|
|
scanLogFlushTask = nil
|
|
}
|
|
|
|
private func finishOperation(_ operationID: UUID) {
|
|
guard isCurrentOperation(operationID) else { return }
|
|
currentTask = nil
|
|
currentOperationID = nil
|
|
scanLogFlushTask?.cancel()
|
|
scanLogFlushTask = nil
|
|
}
|
|
|
|
private func startScanLogFlushLoop(operationID: UUID, buffer: DiskCleanScanLogBuffer) {
|
|
scanLogFlushTask?.cancel()
|
|
scanLogFlushTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
do {
|
|
try await Task.sleep(nanoseconds: Self.scanLogFlushIntervalNanoseconds)
|
|
} catch {
|
|
break
|
|
}
|
|
await self?.flushScanLogEntriesIfCurrent(operationID, buffer: buffer)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func flushScanLogEntriesIfCurrent(
|
|
_ operationID: UUID,
|
|
buffer: DiskCleanScanLogBuffer
|
|
) async {
|
|
let messages = await buffer.drain()
|
|
guard isCurrentOperation(operationID) else { return }
|
|
appendScanLogs(messages)
|
|
}
|
|
|
|
private func appendScanLogs(_ messages: [DiskCleanScanLogMessage]) {
|
|
guard !messages.isEmpty else { return }
|
|
|
|
snapshot = DiskCleanControllerSnapshot(
|
|
phase: snapshot.phase,
|
|
selectedChoices: snapshot.selectedChoices,
|
|
scanResult: snapshot.scanResult,
|
|
executionResult: snapshot.executionResult,
|
|
isResultStale: snapshot.isResultStale,
|
|
errorMessage: snapshot.errorMessage,
|
|
scanLogEntries: scanLogEntries(adding: messages, to: snapshot.scanLogEntries)
|
|
)
|
|
}
|
|
|
|
private func scanLogEntries(
|
|
adding messages: [DiskCleanScanLogMessage],
|
|
to existingEntries: [DiskCleanScanLogEntry]
|
|
) -> [DiskCleanScanLogEntry] {
|
|
var entries = existingEntries
|
|
entries.reserveCapacity(min(existingEntries.count + messages.count, 500))
|
|
for message in messages {
|
|
entries.append(makeLogEntry(message))
|
|
}
|
|
if entries.count > 500 {
|
|
entries.removeFirst(entries.count - 500)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
private func makeLogEntry(_ message: DiskCleanScanLogMessage) -> DiskCleanScanLogEntry {
|
|
defer { nextLogEntryID += 1 }
|
|
return DiskCleanScanLogEntry(
|
|
id: nextLogEntryID,
|
|
text: message.text,
|
|
tone: message.tone
|
|
)
|
|
}
|
|
|
|
private func selectedChoiceTitleList(_ choices: Set<DiskCleanChoice>) -> String {
|
|
DiskCleanChoice.allCases
|
|
.filter { choices.contains($0) }
|
|
.map { $0.title(localization: localization) }
|
|
.joined(separator: localization.string("list.separator", defaultValue: "、"))
|
|
}
|
|
|
|
private func isCurrentOperation(_ operationID: UUID) -> Bool {
|
|
currentOperationID == operationID
|
|
}
|
|
|
|
private func isStale(
|
|
scanResult: DiskCleanScanResult?,
|
|
selectedChoices: Set<DiskCleanChoice>
|
|
) -> Bool {
|
|
guard let scanResult else { return false }
|
|
return scanResult.choices != selectedChoices
|
|
}
|
|
|
|
private static func userFacingMessage(for error: Error) -> String {
|
|
if let localizedError = error as? LocalizedError,
|
|
let description = localizedError.errorDescription {
|
|
return description
|
|
}
|
|
return error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private actor DiskCleanScanLogBuffer {
|
|
private var messages: [DiskCleanScanLogMessage] = []
|
|
|
|
func append(_ message: DiskCleanScanLogMessage) {
|
|
messages.append(message)
|
|
}
|
|
|
|
func drain() -> [DiskCleanScanLogMessage] {
|
|
defer { messages.removeAll(keepingCapacity: true) }
|
|
return messages
|
|
}
|
|
}
|