294 lines
10 KiB
Swift
294 lines
10 KiB
Swift
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 + "/"
|
||
}
|
||
}
|