Files
ggbond268--mactools/Plugins/XcodeClean/Sources/XcodeCleanController.swift
T
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

500 lines
17 KiB
Swift

import Combine
import Foundation
import MacToolsPluginKit
enum XcodeCleanPhase: Equatable, Sendable {
case idle
case scanning
case scanned
case cleaning
case completed
}
struct XcodeCleanSnapshot: Equatable, Sendable {
let phase: XcodeCleanPhase
let selectedCategories: Set<XcodeCleanCategory>
let scanResult: XcodeCleanScanResult?
let executionResult: XcodeCleanExecutionResult?
let isResultStale: Bool
let isXcodeRunning: Bool
let errorMessage: String?
let scanLogEntries: [XcodeCleanScanLogEntry]
init(
phase: XcodeCleanPhase,
selectedCategories: Set<XcodeCleanCategory>,
scanResult: XcodeCleanScanResult?,
executionResult: XcodeCleanExecutionResult?,
isResultStale: Bool,
isXcodeRunning: Bool,
errorMessage: String?,
scanLogEntries: [XcodeCleanScanLogEntry] = []
) {
self.phase = phase
self.selectedCategories = selectedCategories
self.scanResult = scanResult
self.executionResult = executionResult
self.isResultStale = isResultStale
self.isXcodeRunning = isXcodeRunning
self.errorMessage = errorMessage
self.scanLogEntries = scanLogEntries
}
var isBusy: Bool {
phase == .scanning || phase == .cleaning
}
var canScan: Bool {
!isXcodeRunning && !isBusy && !selectedCategories.isEmpty
}
var canClean: Bool {
!isXcodeRunning
&& phase == .scanned
&& !isResultStale
&& scanResult?.cleanableCandidates.isEmpty == false
}
static let initial = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: Set(XcodeCleanCategory.allCases),
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: false,
errorMessage: nil
)
}
@MainActor
protocol XcodeCleanControlling: AnyObject {
var onStateChange: (() -> Void)? { get set }
var snapshot: XcodeCleanSnapshot { get }
func setCategory(_ category: XcodeCleanCategory, isSelected: Bool)
func scan()
func cleanSelected(candidateIDs: Set<XcodeCleanCandidate.ID>)
func cancelCurrentOperation()
func updateXcodeRunningState(_ isRunning: Bool)
}
@MainActor
final class XcodeCleanController: ObservableObject, XcodeCleanControlling {
private static let scanLogFlushIntervalNanoseconds: UInt64 = 100_000_000
private static let maxLogEntries = 500
var onStateChange: (() -> Void)?
@Published private(set) var snapshot: XcodeCleanSnapshot {
didSet { onStateChange?() }
}
private let scanner: XcodeCleanScanning
private let executor: XcodeCleanExecuting
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: XcodeCleanScanning = XcodeCleanScanner(),
executor: XcodeCleanExecuting = XcodeCleanExecutor(),
initialSnapshot: XcodeCleanSnapshot = .initial,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.scanner = scanner
self.executor = executor
self.localization = localization
snapshot = initialSnapshot
}
deinit {
currentTask?.cancel()
scanLogFlushTask?.cancel()
}
func setCategory(_ category: XcodeCleanCategory, isSelected: Bool) {
var next = snapshot.selectedCategories
if isSelected {
next.insert(category)
} else {
next.remove(category)
}
snapshot = XcodeCleanSnapshot(
phase: snapshot.phase,
selectedCategories: next,
scanResult: snapshot.scanResult,
executionResult: snapshot.executionResult,
isResultStale: isStale(scanResult: snapshot.scanResult, selectedCategories: next),
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: snapshot.errorMessage,
scanLogEntries: snapshot.scanLogEntries
)
}
func updateXcodeRunningState(_ isRunning: Bool) {
guard snapshot.isXcodeRunning != isRunning else { return }
if isRunning {
cancelTaskOnly()
snapshot = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: snapshot.selectedCategories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: true,
errorMessage: nil,
scanLogEntries: appendLogs(
[
XcodeCleanScanLogMessage(
text: localization.string(
"scanLog.xcodeStarted",
defaultValue: "Xcode 已启动,操作已中断"
),
tone: .warning
)
],
to: snapshot.scanLogEntries
)
)
} else {
snapshot = XcodeCleanSnapshot(
phase: snapshot.phase,
selectedCategories: snapshot.selectedCategories,
scanResult: snapshot.scanResult,
executionResult: snapshot.executionResult,
isResultStale: snapshot.isResultStale,
isXcodeRunning: false,
errorMessage: snapshot.errorMessage,
scanLogEntries: snapshot.scanLogEntries
)
}
}
func scan() {
guard snapshot.canScan else { return }
cancelTaskOnly()
let categories = snapshot.selectedCategories
let operationID = UUID()
currentOperationID = operationID
nextLogEntryID = 1
let buffer = XcodeCleanScanLogBuffer()
let initialLogs = appendLogs(
[XcodeCleanScanLogMessage(
text: localization.format(
"scanLog.started",
defaultValue: "开始扫描:%@",
selectedCategoryTitles(categories)
),
tone: .info
)],
to: []
)
snapshot = XcodeCleanSnapshot(
phase: .scanning,
selectedCategories: categories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: initialLogs
)
startScanLogFlushLoop(operationID: operationID, buffer: buffer)
currentTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
let result = try await scanner.scan(categories: categories) { message in
await buffer.append(message)
}
guard isCurrentOperation(operationID) else { return }
await flushLogs(operationID: operationID, buffer: buffer)
snapshot = XcodeCleanSnapshot(
phase: .scanned,
selectedCategories: categories,
scanResult: result,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
} catch is CancellationError {
guard isCurrentOperation(operationID) else { return }
await flushLogs(operationID: operationID, buffer: buffer)
snapshot = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: categories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
} catch {
guard isCurrentOperation(operationID) else { return }
await flushLogs(operationID: operationID, buffer: buffer)
let message = Self.userFacingMessage(for: error)
snapshot = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: categories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: message,
scanLogEntries: appendLogs(
[
XcodeCleanScanLogMessage(
text: localization.format(
"scanLog.failed",
defaultValue: "扫描失败:%@",
message
),
tone: .error
)
],
to: snapshot.scanLogEntries
)
)
finishOperation(operationID)
}
}
}
func cleanSelected(candidateIDs: Set<XcodeCleanCandidate.ID>) {
guard snapshot.canClean, let scanResult = snapshot.scanResult else { return }
cancelTaskOnly()
let categories = snapshot.selectedCategories
let operationID = UUID()
currentOperationID = operationID
snapshot = XcodeCleanSnapshot(
phase: .cleaning,
selectedCategories: categories,
scanResult: scanResult,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
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 = XcodeCleanSnapshot(
phase: .completed,
selectedCategories: categories,
scanResult: scanResult,
executionResult: executionResult,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
} catch is CancellationError {
guard isCurrentOperation(operationID) else { return }
snapshot = XcodeCleanSnapshot(
phase: .scanned,
selectedCategories: categories,
scanResult: scanResult,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
} catch {
guard isCurrentOperation(operationID) else { return }
snapshot = XcodeCleanSnapshot(
phase: .scanned,
selectedCategories: categories,
scanResult: scanResult,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: Self.userFacingMessage(for: error),
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
}
}
}
func cancelCurrentOperation() {
let phase = snapshot.phase
let categories = snapshot.selectedCategories
let scanResult = snapshot.scanResult
let isResultStale = snapshot.isResultStale
let logs = snapshot.scanLogEntries
cancelTaskOnly()
switch phase {
case .scanning:
snapshot = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: categories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: appendLogs(
[
XcodeCleanScanLogMessage(
text: localization.string("scanLog.stopped", defaultValue: "扫描已停止"),
tone: .warning
)
],
to: logs
)
)
case .cleaning:
snapshot = XcodeCleanSnapshot(
phase: .scanned,
selectedCategories: categories,
scanResult: scanResult,
executionResult: nil,
isResultStale: isResultStale,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: logs
)
case .idle, .scanned, .completed:
break
}
}
// MARK: - Private
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: XcodeCleanScanLogBuffer) {
scanLogFlushTask?.cancel()
scanLogFlushTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: Self.scanLogFlushIntervalNanoseconds)
} catch {
break
}
await self?.flushLogs(operationID: operationID, buffer: buffer)
}
}
}
private func flushLogs(operationID: UUID, buffer: XcodeCleanScanLogBuffer) async {
let messages = await buffer.drain()
guard isCurrentOperation(operationID), !messages.isEmpty else { return }
snapshot = XcodeCleanSnapshot(
phase: snapshot.phase,
selectedCategories: snapshot.selectedCategories,
scanResult: snapshot.scanResult,
executionResult: snapshot.executionResult,
isResultStale: snapshot.isResultStale,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: snapshot.errorMessage,
scanLogEntries: appendLogs(messages, to: snapshot.scanLogEntries)
)
}
private func appendLogs(
_ messages: [XcodeCleanScanLogMessage],
to existing: [XcodeCleanScanLogEntry]
) -> [XcodeCleanScanLogEntry] {
guard !messages.isEmpty else { return existing }
var entries = existing
entries.reserveCapacity(min(existing.count + messages.count, Self.maxLogEntries))
for message in messages {
let entry = XcodeCleanScanLogEntry(
id: nextLogEntryID,
text: message.text,
tone: message.tone
)
nextLogEntryID += 1
entries.append(entry)
}
if entries.count > Self.maxLogEntries {
entries.removeFirst(entries.count - Self.maxLogEntries)
}
return entries
}
private func isCurrentOperation(_ operationID: UUID) -> Bool {
currentOperationID == operationID
}
private func isStale(
scanResult: XcodeCleanScanResult?,
selectedCategories: Set<XcodeCleanCategory>
) -> Bool {
guard let scanResult else { return false }
return scanResult.categories != selectedCategories
}
private func selectedCategoryTitles(_ categories: Set<XcodeCleanCategory>) -> String {
XcodeCleanCategory.allCases
.filter { categories.contains($0) }
.map { $0.title(localization: localization) }
.joined(separator: "、")
}
private static func userFacingMessage(for error: Error) -> String {
if let localized = error as? LocalizedError, let description = localized.errorDescription {
return description
}
return error.localizedDescription
}
}
private actor XcodeCleanScanLogBuffer {
private var messages: [XcodeCleanScanLogMessage] = []
func append(_ message: XcodeCleanScanLogMessage) {
messages.append(message)
}
func drain() -> [XcodeCleanScanLogMessage] {
defer { messages.removeAll(keepingCapacity: true) }
return messages
}
}