Files
ggbond268--mactools/Plugins/XcodeClean/Sources/XcodeCleanScanner.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

294 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Darwin
import Foundation
import MacToolsPluginKit
typealias XcodeCleanScanProgressHandler = @Sendable (XcodeCleanScanLogMessage) async -> Void
protocol XcodeCleanScanning: Sendable {
func scan(
categories: Set<XcodeCleanCategory>,
progress: XcodeCleanScanProgressHandler
) async throws -> XcodeCleanScanResult
}
extension XcodeCleanScanning {
func scan(categories: Set<XcodeCleanCategory>) 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..<Int(result.gl_pathc)).compactMap { index in
guard let cString = pathv[index] else { return nil }
return String(cString: cString)
}
}
}
enum XcodeCleanFileSystemError: LocalizedError {
case globFailed(pattern: String, status: Int32)
var errorDescription: String? {
errorDescription()
}
func errorDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> 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<XcodeCleanCategory>,
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 + "/"
}
}