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 let scanResult: XcodeCleanScanResult? let executionResult: XcodeCleanExecutionResult? let isResultStale: Bool let isXcodeRunning: Bool let errorMessage: String? let scanLogEntries: [XcodeCleanScanLogEntry] init( phase: XcodeCleanPhase, selectedCategories: Set, 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) 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? private var currentOperationID: UUID? private var scanLogFlushTask: Task? 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) { 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 ) -> Bool { guard let scanResult else { return false } return scanResult.categories != selectedCategories } private func selectedCategoryTitles(_ categories: Set) -> 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 } }