import Darwin import Foundation import MacToolsPluginKit typealias XcodeCleanScanProgressHandler = @Sendable (XcodeCleanScanLogMessage) async -> Void protocol XcodeCleanScanning: Sendable { func scan( categories: Set, progress: XcodeCleanScanProgressHandler ) async throws -> XcodeCleanScanResult } extension XcodeCleanScanning { func scan(categories: Set) async throws -> XcodeCleanScanResult { try await scan(categories: categories, progress: { _ in }) } } protocol XcodeCleanFileSystemProviding: Sendable { func expandPathPattern(_ pattern: String) throws -> [String] func sizeOfItem(at path: String) throws -> Int64 func itemExists(at path: String) -> Bool func removeItem(at path: String) throws } struct LocalXcodeCleanFileSystem: XcodeCleanFileSystemProviding, @unchecked Sendable { private let fileManager: FileManager private let homeDirectory: String init( fileManager: FileManager = .default, homeDirectory: String = NSHomeDirectory() ) { self.fileManager = fileManager self.homeDirectory = homeDirectory } func expandPathPattern(_ pattern: String) throws -> [String] { let expanded = expandHome(in: pattern) guard Self.containsGlob(expanded) else { return itemExists(at: expanded) ? [expanded] : [] } return try Self.globPaths(matching: expanded).sorted() } func sizeOfItem(at path: String) throws -> Int64 { var isDir: ObjCBool = false guard fileManager.fileExists(atPath: path, isDirectory: &isDir) else { return 0 } if !isDir.boolValue { let attributes = try fileManager.attributesOfItem(atPath: path) return Int64((attributes[.size] as? NSNumber)?.int64Value ?? 0) } var total: Int64 = 0 let url = URL(fileURLWithPath: path) guard let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey], options: [.skipsPackageDescendants] ) else { return 0 } for case let fileURL as URL in enumerator { let values = try? fileURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey]) if values?.isDirectory == true || values?.isSymbolicLink == true { continue } total += Int64(values?.fileSize ?? 0) } return total } func itemExists(at path: String) -> Bool { if fileManager.fileExists(atPath: path) { return true } let attributes = try? fileManager.attributesOfItem(atPath: path) return (attributes?[.type] as? FileAttributeType) == .typeSymbolicLink } func removeItem(at path: String) throws { guard itemExists(at: path) else { return } try fileManager.removeItem(atPath: path) } private func expandHome(in pattern: String) -> String { if pattern == "~" { return homeDirectory } if pattern.hasPrefix("~/") { return homeDirectory + String(pattern.dropFirst()) } return pattern } private static func containsGlob(_ pattern: String) -> Bool { pattern.contains { "*?[".contains($0) } } private static func globPaths(matching pattern: String) throws -> [String] { var result = glob_t() defer { globfree(&result) } let status = pattern.withCString { glob($0, 0, nil, &result) } if status == GLOB_NOMATCH { return [] } guard status == 0 else { throw XcodeCleanFileSystemError.globFailed(pattern: pattern, status: status) } guard let pathv = result.gl_pathv else { return [] } return (0.. String { switch self { case let .globFailed(pattern, status): return localization.format( "fileSystemError.globFailed", defaultValue: "无法展开路径 %@(glob 状态 %d)", pattern, status ) } } } struct XcodeCleanScanner: XcodeCleanScanning { let ruleCatalog: XcodeCleanRuleCatalog let fileSystem: XcodeCleanFileSystemProviding let allowedRoots: [String] let now: @Sendable () -> Date let localization: PluginLocalization init( ruleCatalog: XcodeCleanRuleCatalog = .defaultCatalog, fileSystem: XcodeCleanFileSystemProviding = LocalXcodeCleanFileSystem(), allowedRoots: [String]? = nil, now: @escaping @Sendable () -> Date = Date.init, localization: PluginLocalization = PluginLocalization(bundle: .main) ) { self.ruleCatalog = ruleCatalog self.fileSystem = fileSystem self.allowedRoots = (allowedRoots ?? Self.defaultAllowedRoots()).map { Self.ensureTrailingSlash($0) } self.now = now self.localization = localization } func scan( categories: Set, progress: XcodeCleanScanProgressHandler ) async throws -> XcodeCleanScanResult { var candidates: [XcodeCleanCandidate] = [] for category in XcodeCleanCategory.allCases where categories.contains(category) { try Task.checkCancellation() await progress( XcodeCleanScanLogMessage( text: localization.format( "scanLog.category", defaultValue: "扫描分类:%@", category.title(localization: localization) ), tone: .info ) ) for rule in ruleCatalog.rules(for: category) { try Task.checkCancellation() for pattern in rule.pathPatterns { try Task.checkCancellation() let matches = (try? fileSystem.expandPathPattern(pattern)) ?? [] await progress( XcodeCleanScanLogMessage( text: localization.format( "scanLog.expanded", defaultValue: "展开 %@ → %d 项", pattern, matches.count ), tone: matches.isEmpty ? .info : .success ) ) for path in matches { try Task.checkCancellation() let safety = safetyStatus(for: path) let size = safety.isCleanable ? ((try? fileSystem.sizeOfItem(at: path)) ?? 0) : 0 let candidate = XcodeCleanCandidate( id: "\(rule.id)::\(path)", category: category, path: path, sizeBytes: size, safety: safety ) candidates.append(candidate) await progress(logMessage(for: candidate)) } } } } let cleanable = candidates.filter { $0.safety.isCleanable } await progress( XcodeCleanScanLogMessage( text: localization.format( "scanLog.completed", defaultValue: "扫描完成:%d 项,%d 项可清理", candidates.count, cleanable.count ), tone: .success ) ) return XcodeCleanScanResult( categories: categories, candidates: candidates, scannedAt: now() ) } private func safetyStatus(for path: String) -> XcodeCleanSafetyStatus { if !fileSystem.itemExists(at: path) { return .missing } if !isPathAllowed(path) { return .outsideAllowedRoot } return .allowed } private func isPathAllowed(_ path: String) -> Bool { let normalized = path.hasSuffix("/") ? path : path return allowedRoots.contains { normalized.hasPrefix($0) } } private func logMessage(for candidate: XcodeCleanCandidate) -> XcodeCleanScanLogMessage { switch candidate.safety { case .allowed: return XcodeCleanScanLogMessage( text: localization.format("scanLog.candidate.allowed", defaultValue: "可清理:%@", candidate.path), tone: .success ) case .outsideAllowedRoot: return XcodeCleanScanLogMessage( text: localization.format( "scanLog.candidate.outsideAllowedRoot", defaultValue: "越界拒绝:%@", candidate.path ), tone: .warning ) case .xcodeRunning: return XcodeCleanScanLogMessage( text: localization.format("scanLog.candidate.xcodeRunning", defaultValue: "Xcode 运行中:%@", candidate.path), tone: .warning ) case .missing: return XcodeCleanScanLogMessage( text: localization.format("scanLog.candidate.missing", defaultValue: "已不存在:%@", candidate.path), tone: .info ) } } private static func defaultAllowedRoots() -> [String] { let home = NSHomeDirectory() return XcodeCleanRuleCatalog.allowedRootPrefixes.map { prefix in prefix.hasPrefix("~/") ? home + String(prefix.dropFirst()) : prefix } } private static func ensureTrailingSlash(_ path: String) -> String { path.hasSuffix("/") ? path : path + "/" } }