diff --git a/PoolProblem/PoolProblem/AppService.swift b/PoolProblem/PoolProblem/AppService.swift index 2383924..9fac6f5 100644 --- a/PoolProblem/PoolProblem/AppService.swift +++ b/PoolProblem/PoolProblem/AppService.swift @@ -336,12 +336,14 @@ final class AppService { /// 数据变化后预生成 E 字型标尺位图(避免弹窗打开时执行重活) private func refreshGaugeImage() { + let recipes = activeRecipes() let made = PoolWindowLayout.make( totalBytes: state.totalBytes, availableBytes: state.availableBytes, waterlineBytes: state.waterlineBytes, items: state.items, - estimatedRecipeIDs: Set(activeRecipes().filter(\.cloneProne).map(\.id)), + recipes: recipes, + estimatedRecipeIDs: Set(recipes.filter(\.cloneProne).map(\.id)), excludedItemIDs: state.cleanedItemIDs ) state.poolGaugeImage = GaugeImageRenderer.render(layout: made.layout) @@ -633,12 +635,15 @@ final class AppService { // 详情页点击“立即清理”即用户确认: // safeWhileRunning 与 userConfirm 放行,displayOnly(用户数据)除外; // requiresQuit 须先退出相关进程(如 Simulator)才能清理。 - guard item.cleanability.allowsManualCleanup, + guard let currentRecipe = activeRecipes().first(where: { $0.id == item.recipeID }), + item.cleanability.allowsManualCleanup, + currentRecipe.cleanability.allowsManualCleanup, // 废纸篓是特殊过渡区:只通过废纸篓详情页管理,不走通用清理 item.recipeID != "own-trash-batches", item.recipeID != "trash", // 仅按子目录清理的项(如应用缓存)不整项删除 - !item.cleanByChildOnly else { + !item.cleanByChildOnly, + !currentRecipe.cleanByChildOnly else { return .failed(.unavailable) } if item.recipeID == TemporaryBuildArtifacts.recipeID { @@ -832,62 +837,55 @@ final class AppService { return await work.value } - /// 应用缓存等“仅按子目录清理”项的详情:一级子目录(大小/增速/是否受保护)。 - func cacheChildren(for item: ScanItem) async -> [CacheChildEntry] { - let parent = URL(fileURLWithPath: item.path, isDirectory: true) - guard let children = try? FileManager.default.contentsOfDirectory( - at: parent, - includingPropertiesForKeys: [.isDirectoryKey] - ) else { return [] } - let recipe = activeRecipes().first { $0.id == item.recipeID } - let protected = recipe.map { - ProgressiveCleanupPolicy.mergedProtectedChildNames(recipe: $0, config: loadConfig()) - } ?? [] - let rates = childGrowthRates(parentPath: item.path, homeDirectory: NSHomeDirectory()) - let work = Task.detached(priority: .utility) { () -> [CacheChildEntry] in - var entries: [CacheChildEntry] = [] - for child in children { - guard ((try? child.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory) == true else { - continue - } - let bytes = POSIXDirectoryWalker.walk( - url: child, - itemID: "cache-child", - includeRecords: false - )?.allocatedBytes ?? 0 - entries.append(CacheChildEntry( - name: child.lastPathComponent, - path: child.path, - bytes: bytes, - ratePerDay: rates[child.path] ?? 0, - isProtected: protected.contains(child.lastPathComponent) - )) - } - return entries.sorted { lhs, rhs in - if lhs.isProtected != rhs.isProtected { return !lhs.isProtected } - let ls = Double(lhs.bytes) + lhs.ratePerDay * ProgressiveCleanupPolicy.growthWindowDays - let rs = Double(rhs.bytes) + rhs.ratePerDay * ProgressiveCleanupPolicy.growthWindowDays - return ls > rs - } - } - return await work.value + /// 一级子目录的当前占用与一次实际增长观测;小于 10 MB 的目录不在详情中列出。 + func cacheChildren(for item: ScanItem) async -> [ChildDirectoryInfo] { + guard let recipe = activeRecipes().first(where: { $0.id == item.recipeID }), + recipe.cleanByChildOnly, + recipe.resolvePaths(paths).contains(item.path) else { return [] } + let protected = ProgressiveCleanupPolicy.mergedProtectedChildNames( + recipe: recipe, config: loadConfig() + ) + let growth = (try? growthLedgerStore.entries()) ?? [] + return await Task.detached(priority: .utility) { + ChildDirectoryExplorer().list( + parentPath: item.path, + growthEntries: growth, + protectedChildNames: protected + ) + }.value } - /// 逐子目录清理(应用缓存等):把该子目录移入回收站并记录日志。 - func cleanCacheChild(path: String, recipeID: String, name: String) async { + /// 逐子目录清理:执行前用当前配方与文件树重新校验,不信任详情页中的旧路径。 + func cleanCacheChild(_ child: ChildDirectoryInfo, in item: ScanItem) async -> Bool { await cleanupCoordinator.run { [weak self] in - guard let self else { return } + guard let self, + let recipe = self.activeRecipes().first(where: { $0.id == item.recipeID }), + recipe.cleanByChildOnly, + recipe.cleanability.allowsManualCleanup, + item.cleanability.allowsManualCleanup else { return false } + let protected = ProgressiveCleanupPolicy.mergedProtectedChildNames( + recipe: recipe, config: self.loadConfig() + ) + guard ChildDirectoryAccess.canClean( + childPath: child.path, + parentPath: item.path, + authorizedParents: Set(recipe.resolvePaths(self.paths)), + protectedNames: protected, + expectedIdentity: child.identity, + minimumIdleSeconds: recipe.id == "deriveddata" + ? DerivedDataChildPolicy.minimumIdleSeconds : 0 + ) else { return false } let deleter = TrashBatchDeleter(batchName: Self.cleanupBatchName()) guard let deletion = try? deleter.deleteReturningResult( - url: URL(fileURLWithPath: path), + url: URL(fileURLWithPath: child.path), disposition: .trash - ) else { return } + ) else { return false } let entry = CleanLogEntry( id: UUID(), timestamp: Date(), - itemIDs: ["\(recipeID):\(path)"], - itemNames: [name], - originalPaths: [path], + itemIDs: ["\(item.recipeID):\(child.path)"], + itemNames: [child.name], + originalPaths: [child.path], trashPaths: [deletion.resultingURL?.path ?? ""], batchID: UUID(), freedBytes: deletion.freedBytes, @@ -899,13 +897,14 @@ final class AppService { } catch { if let moved = deletion.resultingURL, FileManager.default.fileExists(atPath: moved.path), - !FileManager.default.fileExists(atPath: path) { - try? FileManager.default.moveItem(at: moved, to: URL(fileURLWithPath: path)) + !FileManager.default.fileExists(atPath: child.path) { + try? FileManager.default.moveItem(at: moved, to: URL(fileURLWithPath: child.path)) } - return + return false } self.notifyTrashChanged() await self.scanNow(autoClean: false) + return true } } @@ -1214,18 +1213,6 @@ final class AppService { return outcome } - /// 近 7 天增长台账中,父目录下一级子项的日增长率(bytes/day)。 - private func childGrowthRates(parentPath: String, homeDirectory: String) -> [String: Double] { - let cutoff = Date().addingTimeInterval(-7 * 86_400) - let entries = (try? growthLedgerStore.entries()) ?? [] - let prefix = parentPath.hasSuffix("/") ? parentPath : parentPath + "/" - var rates: [String: Double] = [:] - for entry in entries where entry.observedAt >= cutoff && entry.path.hasPrefix(prefix) { - rates[entry.path, default: 0] = max(rates[entry.path, default: 0], entry.rateBytesPerDay) - } - return rates - } - private func firstAutoPlannedItem( scan: ScanResult, config: Config, @@ -1476,8 +1463,29 @@ final class AppService { } } + /// 只筛选界面列表;完整扫描结果继续用于容量归因与清理决策。 + func visibleItems(_ items: [ScanItem]) -> [ScanItem] { + ScanDisplayPolicy.visibleItems(items, recipes: activeRecipes()) + } + + /// 旧快照可能还没有逐子目录标志;始终以当前配方为删除权限来源。 + func isChildOnly(_ item: ScanItem) -> Bool { + item.cleanByChildOnly + || activeRecipes().first(where: { $0.id == item.recipeID })?.cleanByChildOnly == true + } + + func canCleanWholeItem(_ item: ScanItem) -> Bool { + guard let recipe = activeRecipes().first(where: { $0.id == item.recipeID }) else { + return false + } + return item.cleanability.allowsManualCleanup + && recipe.cleanability.allowsManualCleanup + && !item.cleanByChildOnly + && !recipe.cleanByChildOnly + } + /// 当前生效的配方:系统内置 + 用户确认的项目目录配方。 - private func activeRecipes() -> [Recipe] { + func activeRecipes() -> [Recipe] { let config = loadConfig() return RecipeRegistry.builtIn() + [PackageManagerRecipes.make( diff --git a/PoolProblem/PoolProblem/Localizable.xcstrings b/PoolProblem/PoolProblem/Localizable.xcstrings index fca3498..dbf971c 100644 --- a/PoolProblem/PoolProblem/Localizable.xcstrings +++ b/PoolProblem/PoolProblem/Localizable.xcstrings @@ -198,13 +198,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cleaned per subdirectory by automatic maintenance; the largest or fastest-growing ones are listed below." + "value" : "Subdirectories currently using at least 10 MB. Open a name in Finder; cleaning moves only the selected directory to Trash." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "由自动维护按子目录渐进清理;以下列出最大/增长最快的子目录。" + "value" : "显示当前占用至少 10 MB 的一级目录。点击名称可在 Finder 中查看;清理只会将选中的目录移入废纸篓。" } } } @@ -225,34 +225,130 @@ } } }, + "cache.clean_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not move the selected directory to Trash. Its state may have changed; the list has been refreshed." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未能将选中的目录移入废纸篓。目录状态可能已变化,列表已刷新。" + } + } + } + }, "cache.cleaned" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cleaned %@" + "value" : "Moved %@ to Trash" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已清理 %@" + "value" : "已将 %@ 移入废纸篓" } } } }, - "cache.growth_rate" : { + "cache.confirm_cache" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "+%@/day" + "value" : "Move %@ (%@) to Trash? Only this directory is moved. You can restore it until you empty Trash." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "+%@/天" + "value" : "将 %@(%@)移入废纸篓?只移动这个目录,清空废纸篓前可恢复。" + } + } + } + }, + "cache.confirm_move" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move to Trash" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移入废纸篓" + } + } + } + }, + "cache.confirm_project" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move %@ (%@) to Trash? Xcode may re-index or rebuild this project and download dependencies again. The directory remains in Trash until you empty it." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将 %@(%@)移入废纸篓?Xcode 可能重新索引或编译该项目,并重新下载依赖。清空废纸篓前仍可恢复。" + } + } + } + }, + "cache.confirm_shared" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move %@ (%@) to Trash? This shared Xcode directory may cause multiple projects to rebuild. SourcePackages may need to download dependencies again. The directory remains in Trash until you empty it." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将 %@(%@)移入废纸篓?这是 Xcode 的共享目录,可能使多个项目重新编译;SourcePackages 还可能需要重新下载依赖。清空废纸篓前仍可恢复。" + } + } + } + }, + "cache.confirm_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm directory cleanup" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认清理目录" + } + } + } + }, + "cache.observed_growth" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observed +%@ over %@, ending %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观测增长 +%@ · 历时 %@ · 截至 %@" } } } @@ -262,13 +358,77 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "No subdirectories found." + "value" : "No subdirectories currently use at least 10 MB." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "没有找到子目录。" + "value" : "当前没有占用达到 10 MB 的一级目录。" + } + } + } + }, + "cache.open_in_finder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show in Finder" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 Finder 中显示" + } + } + } + }, + "cache.quit_xcode_help" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit Xcode before cleaning DerivedData." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清理 DerivedData 前请先退出 Xcode。" + } + } + } + }, + "cache.recent" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recently used" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "近期使用" + } + } + } + }, + "cache.recent_help" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This directory was written in the last minute. Try again after writes stop." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此目录最近一分钟仍有写入;停止写入后再试。" } } } @@ -294,13 +454,45 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Build-critical cache; never auto-deleted." + "value" : "Protected application cache; cleanup is unavailable." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "受保护的应用缓存,不能在此清理。" + } + } + } + }, + "cache.shared" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shared cache" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "共享缓存" + } + } + } + }, + "cache.xcode_running_warning" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xcode is running. Check that this directory is not in use before confirming." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "构建必需缓存,永不自动删除。" + "value" : "Xcode 正在运行。请确认当前没有使用此目录,再决定是否清理。" } } } @@ -1155,13 +1347,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "This cache is cleaned per subdirectory by automatic maintenance, never as a whole directory." + "value" : "Choose individual subdirectories in the detail view; the entire directory cannot be cleaned here." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此缓存由自动维护按子目录渐进清理,不会整目录删除。" + "value" : "请在详情中逐个选择子目录;这里不会清理整个目录。" } } } @@ -1454,6 +1646,22 @@ } } }, + "detail.current_size" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current size" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前占用" + } + } + } + }, "detail.path" : { "localizations" : { "en" : { diff --git a/PoolProblem/PoolProblem/Models/AppState.swift b/PoolProblem/PoolProblem/Models/AppState.swift index 816eab7..cd7567a 100644 --- a/PoolProblem/PoolProblem/Models/AppState.swift +++ b/PoolProblem/PoolProblem/Models/AppState.swift @@ -18,16 +18,6 @@ struct TrashEntry: Identifiable, Equatable { var id: String { name } } -/// 应用缓存详情页里的一级子目录条目。 -struct CacheChildEntry: Identifiable, Equatable { - let name: String - let path: String - let bytes: Int64 - let ratePerDay: Double - let isProtected: Bool - var id: String { path } -} - enum DashboardConsistencyStatus: Equatable { case current case reconciling diff --git a/PoolProblem/PoolProblem/Views/CacheChildrenView.swift b/PoolProblem/PoolProblem/Views/CacheChildrenView.swift index 71131d0..5314f73 100644 --- a/PoolProblem/PoolProblem/Views/CacheChildrenView.swift +++ b/PoolProblem/PoolProblem/Views/CacheChildrenView.swift @@ -1,15 +1,20 @@ import SwiftUI +import Combine import DiskReservoirCore -/// “应用缓存”等仅按子目录清理的聚合项详情:列出子目录(大小/增速/受保护), -/// 支持逐子目录清理,避免整目录删除。 +/// “应用缓存”和 DerivedData 的一级子目录详情;只允许主动选择单个目录移入废纸篓。 struct CacheChildrenView: View { @ObservedObject var state: AppState let service: AppService let item: ScanItem - @State private var children: [CacheChildEntry] = [] + @State private var children: [ChildDirectoryInfo] = [] @State private var notice: String? + @State private var isLoading = true + @State private var xcodeRunning = false + @State private var currentDate = Date() + @State private var pendingChild: ChildDirectoryInfo? + @State private var cleaningChildID: String? var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -31,15 +36,23 @@ struct CacheChildrenView: View { Text(Localized.string("cache.children_hint")) .font(.caption) .foregroundStyle(.secondary) + if item.recipeID == "deriveddata" && xcodeRunning { + Text(Localized.string("cache.xcode_running_warning")) + .font(.caption2) + .foregroundStyle(.orange) + } - if children.isEmpty { + if isLoading { + ProgressView() + .controlSize(.small) + } else if children.isEmpty { Text(Localized.string("cache.no_children")) .font(.caption) .foregroundStyle(.secondary) } else { ScrollView { VStack(alignment: .leading, spacing: 4) { - ForEach(children.prefix(15)) { child in + ForEach(children) { child in childRow(child) } } @@ -74,53 +87,139 @@ struct CacheChildrenView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black.opacity(0.15)) .onAppear { - Task { children = await service.cacheChildren(for: item) } + xcodeRunning = item.recipeID == "deriveddata" + && PGrepProcessInspector().isRunning("Xcode") + Task { + children = await service.cacheChildren(for: item) + isLoading = false + } + } + .onReceive(Timer.publish(every: 10, on: .main, in: .common).autoconnect()) { date in + currentDate = date + if item.recipeID == "deriveddata" { + xcodeRunning = PGrepProcessInspector().isRunning("Xcode") + } + } + .alert(item: $pendingChild) { child in + Alert( + title: Text(Localized.string("cache.confirm_title")), + message: Text(confirmMessage(for: child)), + primaryButton: .destructive(Text(Localized.string("cache.confirm_move"))) { + cleaningChildID = child.id + Task { + let cleaned = await service.cleanCacheChild(child, in: item) + notice = cleaned + ? Localized.string("cache.cleaned", child.name) + : Localized.string("cache.clean_failed") + children = await service.cacheChildren(for: item) + cleaningChildID = nil + } + }, + secondaryButton: .cancel(Text(Localized.string("common.cancel"))) + ) } } - private func childRow(_ child: CacheChildEntry) -> some View { - HStack(spacing: 6) { - Image(systemName: "folder") - .font(.caption2) - .foregroundStyle(.secondary) - Text(child.name) - .font(.caption) - .lineLimit(1) - .truncationMode(.middle) - if child.isProtected { - Text(Localized.string("cache.protected")) + private func childRow(_ child: ChildDirectoryInfo) -> some View { + let derived = item.recipeID == "deriveddata" + let writing = derived && (child.lastModified.map { + $0 > currentDate.addingTimeInterval(-DerivedDataChildPolicy.minimumIdleSeconds) + } ?? true) + let recentlyUsed = derived && (child.lastModified.map { + $0 > currentDate.addingTimeInterval(-86_400) + } ?? false) + let shared = derived && DerivedDataChildPolicy.isSharedCache(name: child.name) + let blocked = child.isProtected || writing || cleaningChildID != nil + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: "folder") .font(.caption2) - .foregroundStyle(.orange) + .foregroundStyle(.secondary) + Button { + FinderReveal.reveal(child.path) + } label: { + Text(child.name) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + .help(Localized.string("cache.open_in_finder")) + .focusEffectDisabled() + .cursorPointingHand() + if child.isProtected { + Text(Localized.string("cache.protected")) + .font(.caption2) + .foregroundStyle(.orange) + } else if shared { + Text(Localized.string("cache.shared")) + .font(.caption2) + .foregroundStyle(.orange) + } else if recentlyUsed { + Text(Localized.string("cache.recent")) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 4) + Text(Format.bytes(child.bytes)) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + Button { + if derived { + xcodeRunning = PGrepProcessInspector().isRunning("Xcode") + } + pendingChild = child + } label: { + Image(systemName: "trash") + } + .buttonStyle(.bordered) + .controlSize(.mini) + .tint(.red) + .disabled(blocked) + .help(child.isProtected + ? Localized.string("cache.protected_help") + : (writing + ? Localized.string("cache.recent_help") + : Localized.string("cache.clean_child"))) + .focusEffectDisabled() + .cursorPointingHand(enabled: !blocked) } - Spacer() - if child.ratePerDay > 0 { - Text(Localized.string("cache.growth_rate", Format.bytes(Int64(child.ratePerDay)))) + if let growth = child.growth { + Text(Localized.string( + "cache.observed_growth", + Format.bytes(growth.deltaBytes), + observationDuration(growth.elapsedDays), + growth.observedAt.formatted(date: .abbreviated, time: .shortened) + )) .font(.caption2) .foregroundStyle(.secondary) + .padding(.leading, 18) } - Text(Format.bytes(child.bytes)) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.secondary) - Button { - Task { - await service.cleanCacheChild(path: child.path, recipeID: item.recipeID, name: child.name) - notice = Localized.string("cache.cleaned", child.name) - children = await service.cacheChildren(for: item) - } - } label: { - Image(systemName: "trash") - } - .buttonStyle(.bordered) - .controlSize(.mini) - .tint(.red) - .disabled(child.isProtected) - .help(child.isProtected - ? Localized.string("cache.protected_help") - : Localized.string("cache.clean_child")) - .focusEffectDisabled() - .cursorPointingHand(enabled: !child.isProtected) } - .frame(height: 22) + .padding(.vertical, 2) + } + + private func confirmMessage(for child: ChildDirectoryInfo) -> String { + let message: String + if item.recipeID == "deriveddata" { + message = DerivedDataChildPolicy.isSharedCache(name: child.name) + ? Localized.string("cache.confirm_shared", child.name, Format.bytes(child.bytes)) + : Localized.string("cache.confirm_project", child.name, Format.bytes(child.bytes)) + } else { + message = Localized.string("cache.confirm_cache", child.name, Format.bytes(child.bytes)) + } + return xcodeRunning && item.recipeID == "deriveddata" + ? message + "\n\n" + Localized.string("cache.xcode_running_warning") + : message + } + + private func observationDuration(_ days: Double) -> String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.allowedUnits = [.day, .hour, .minute] + formatter.maximumUnitCount = 2 + return formatter.string(from: max(60, days * 86_400)) ?? "" } } diff --git a/PoolProblem/PoolProblem/Views/MenuBarView.swift b/PoolProblem/PoolProblem/Views/MenuBarView.swift index 0008304..2a7d55f 100644 --- a/PoolProblem/PoolProblem/Views/MenuBarView.swift +++ b/PoolProblem/PoolProblem/Views/MenuBarView.swift @@ -12,6 +12,7 @@ struct MenuBarView: View { @State private var spinning = false @State private var showNonCleanableInfo = false @State private var manualExpanded = false + @State private var observedExpanded = false @State private var showCleanHistory = false @State private var cleanFailureNotice: String? @State private var quitProcessRunning = false @@ -27,6 +28,7 @@ struct MenuBarView: View { availableBytes: state.availableBytes, waterlineBytes: state.waterlineBytes, cleanableItems: state.items, + activeRecipes: service.activeRecipes(), estimatedRecipeIDs: estimatedRecipeIDs, inflowLabels: state.topInflows, excludedItemIDs: state.cleanedItemIDs, @@ -115,7 +117,7 @@ struct MenuBarView: View { Group { if item.recipeID == "trash" { TrashDetailView(state: state, service: service) - } else if item.cleanByChildOnly { + } else if service.isChildOnly(item) { CacheChildrenView(state: state, service: service, item: item) } else { detailOverlay(item) @@ -370,27 +372,41 @@ struct MenuBarView: View { } private var legend: some View { + let recipes = service.activeRecipes() + let displayedItems = service.visibleItems(state.items) + let displayedIDs = Set(displayedItems.map(\.id)) let poolLayers = PoolLayers.make( items: state.items, + recipes: recipes, totalBytes: state.totalBytes, availableBytes: state.availableBytes, estimatedRecipeIDs: estimatedRecipeIDs, excludedItemIDs: state.cleanedItemIDs ) + let displayedLayers = poolLayers.layers.filter { displayedIDs.contains($0.itemID) } // 手动清理项:应用无法删除,只能提示用户到对应应用/Finder 清理 - let manualItems = state.items + let manualItems = displayedItems .filter { $0.reclaimableBytes > 0 - && $0.cleanability != .watchOnly + && ScanDisplayPolicy.effectiveCleanability($0, recipes: recipes).allowsManualCleanup && $0.recipeID != "trash" - && CleanupRationale.make(for: $0).isManual + && CleanupRationale.make( + for: $0, + cleanability: ScanDisplayPolicy.effectiveCleanability($0, recipes: recipes) + ).isManual } .sorted { $0.reclaimableBytes > $1.reclaimableBytes } + let observedItems = displayedItems + .filter { + !ScanDisplayPolicy.effectiveCleanability($0, recipes: recipes).allowsManualCleanup + && $0.recipeID != "trash" + } + .sorted { $0.allocatedBytes > $1.allocatedBytes } return VStack(alignment: .leading, spacing: 5) { - Text(Localized.string("section.cleanable_count", poolLayers.layers.count)) + Text(Localized.string("section.cleanable_count", displayedLayers.count)) .font(.caption) .foregroundStyle(.secondary) - ForEach(poolLayers.layers.filter { layer in + ForEach(displayedLayers.filter { layer in !(layer.itemID == state.deletingItemID && state.deletingProgress <= 0) }) { layer in Button { @@ -552,6 +568,51 @@ struct MenuBarView: View { .buttonStyle(.plain) .focusEffectDisabled() .cursorPointingHand() + if !observedItems.isEmpty { + Button { + withAnimation { observedExpanded.toggle() } + } label: { + HStack(spacing: 5) { + Text(Localized.string("insights.tab_watched")) + .font(.caption) + Image(systemName: observedExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text(Format.bytes(observedItems.reduce(Int64(0)) { $0 + $1.allocatedBytes })) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .focusEffectDisabled() + .cursorPointingHand() + if observedExpanded { + VStack(alignment: .leading, spacing: 3) { + ForEach(observedItems) { item in + Button { + withAnimation(overlaySpring) { state.detailItem = item } + } label: { + HStack(spacing: 5) { + Text(Localized.recipeName(item.recipeID, fallback: item.name)) + .lineLimit(1) + .font(.caption) + Spacer() + Text(Format.bytes(item.allocatedBytes)) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .frame(height: 22) + } + .buttonStyle(.plain) + .focusEffectDisabled() + .cursorPointingHand() + } + } + .padding(.leading, 14) + } + } } } .padding(.top, 2) @@ -645,7 +706,7 @@ struct MenuBarView: View { .filter { $0.reclaimableBytes > 0 && !CleanupRationale.make(for: $0).isManual - && !$0.cleanByChildOnly + && service.canCleanWholeItem($0) && $0.recipeID != "own-trash-batches" && $0.recipeID != "trash" } @@ -682,9 +743,11 @@ struct MenuBarView: View { state.showCleanConfirm = !outcome.entries.isEmpty } - /// 点击可清理项后弹出的说明浮层 + /// 配方详情说明浮层;只观察项也通过它展示路径和当前占用。 private func detailOverlay(_ item: ScanItem) -> some View { let isKept = state.keptItemIDs.contains(item.id) + let cleanability = ScanDisplayPolicy.effectiveCleanability(item, recipes: service.activeRecipes()) + let rationale = CleanupRationale.make(for: item, cleanability: cleanability) return VStack(alignment: .leading, spacing: 12) { HStack { Text(Localized.recipeName(item.recipeID, fallback: item.name)) @@ -701,11 +764,11 @@ struct MenuBarView: View { .cursorPointingHand() } - let rationale = CleanupRationale.make(for: item) - let appCleanable = !rationale.isManual - && item.cleanability.allowsManualCleanup + let appCleanable = !rationale.isManual && service.canCleanWholeItem(item) VStack(alignment: .leading, spacing: 4) { - Text(Localized.string("detail.why_suggested")) + Text(cleanability.allowsManualCleanup + ? Localized.string("detail.why_suggested") + : Localized.string("insights.tab_watched")) .font(.caption2) .foregroundStyle(.secondary) Text(Localized.suggestionText(rationale.suggestion)) @@ -717,7 +780,9 @@ struct MenuBarView: View { Text(Localized.string("detail.why_cleanable_reason")) .font(.caption) } - if item.safety == .requiresQuit, let appName = processName(for: item) { + if cleanability.allowsManualCleanup, + item.safety == .requiresQuit, + let appName = processName(for: item) { Text(Localized.string("detail.why_quit")) .font(.caption2) .foregroundStyle(.secondary) @@ -778,8 +843,14 @@ struct MenuBarView: View { Divider() HStack(spacing: 16) { - LabeledContent(Localized.string("detail.reclaimable"), value: Format.bytes(item.reclaimableBytes)) - LabeledContent(Localized.string("detail.safety"), value: safetyText(for: item)) + if cleanability.allowsManualCleanup { + LabeledContent(Localized.string("detail.reclaimable"), value: Format.bytes(item.reclaimableBytes)) + LabeledContent(Localized.string("detail.safety"), value: safetyText(for: item)) + } else { + LabeledContent(Localized.string("detail.current_size"), value: Format.bytes(item.allocatedBytes)) + Text(Localized.string("insights.tab_watched")) + .foregroundStyle(.secondary) + } } .font(.caption) if let rate = state.growthRates[item.id], rate > 0 { @@ -788,8 +859,7 @@ struct MenuBarView: View { } HStack(spacing: 10) { - if item.cleanability.allowsManualCleanup, - !item.cleanByChildOnly, + if service.canCleanWholeItem(item), !rationale.isManual, item.safety == .safeWhileRunning || item.safety == .userConfirm @@ -812,11 +882,11 @@ struct MenuBarView: View { .controlSize(.large) .focusEffectDisabled() .cursorPointingHand() - } else if item.cleanByChildOnly { + } else if service.isChildOnly(item) && cleanability.allowsManualCleanup { Text(Localized.string("detail.by_child_hint")) .font(.caption2) .foregroundStyle(.secondary) - } else if item.safety == .requiresQuit { + } else if cleanability.allowsManualCleanup && item.safety == .requiresQuit { if let appName = processName(for: item) { Text(Localized.string("detail.clean_requires_quit_app", appName)) .font(.caption2) diff --git a/PoolProblem/PoolProblem/Views/PoolTankView.swift b/PoolProblem/PoolProblem/Views/PoolTankView.swift index 3bf1627..a469ba9 100644 --- a/PoolProblem/PoolProblem/Views/PoolTankView.swift +++ b/PoolProblem/PoolProblem/Views/PoolTankView.swift @@ -42,6 +42,7 @@ enum PoolLayers { static func make( items: [ScanItem], + recipes: [Recipe], totalBytes: Int64, availableBytes: Int64, estimatedRecipeIDs: Set, @@ -51,12 +52,15 @@ enum PoolLayers { let cleanable = items .filter { $0.reclaimableBytes > 0 - && $0.cleanability != .watchOnly + && ScanDisplayPolicy.effectiveCleanability($0, recipes: recipes).allowsManualCleanup && !excludedItemIDs.contains($0.id) && $0.recipeID != "trash" && $0.recipeID != "own-trash-batches" // 应用无法删除的项(需手动在 Xcode/Finder 清理)不进入可清理图层 - && !CleanupRationale.make(for: $0).isManual + && !CleanupRationale.make( + for: $0, + cleanability: ScanDisplayPolicy.effectiveCleanability($0, recipes: recipes) + ).isManual } .sorted { $0.reclaimableBytes > $1.reclaimableBytes } var layers: [CleanableLayer] = [] @@ -80,10 +84,13 @@ enum PoolLayers { let manualBytes = items .filter { $0.reclaimableBytes > 0 - && $0.cleanability != .watchOnly + && ScanDisplayPolicy.effectiveCleanability($0, recipes: recipes).allowsManualCleanup && $0.recipeID != "trash" && !excludedItemIDs.contains($0.id) - && CleanupRationale.make(for: $0).isManual + && CleanupRationale.make( + for: $0, + cleanability: ScanDisplayPolicy.effectiveCleanability($0, recipes: recipes) + ).isManual } .reduce(Int64(0)) { $0 + $1.reclaimableBytes } return PoolLayerModel( @@ -111,6 +118,7 @@ struct PoolTankView: View { let availableBytes: Int64 let waterlineBytes: Int64 let cleanableItems: [ScanItem] + let activeRecipes: [Recipe] let estimatedRecipeIDs: Set let inflowLabels: [(name: String, bytes: Int64)] let excludedItemIDs: Set @@ -147,6 +155,7 @@ struct PoolTankView: View { availableBytes: availableBytes, waterlineBytes: waterlineBytes, items: cleanableItems, + recipes: activeRecipes, estimatedRecipeIDs: estimatedRecipeIDs, excludedItemIDs: excludedItemIDs, height: height @@ -209,7 +218,11 @@ struct PoolTankView: View { $0.recipeID != "trash" && $0.recipeID != "own-trash-batches" && $0.reclaimableBytes > 0 - && !CleanupRationale.make(for: $0).isManual + && ScanDisplayPolicy.effectiveCleanability($0, recipes: activeRecipes).allowsManualCleanup + && !CleanupRationale.make( + for: $0, + cleanability: ScanDisplayPolicy.effectiveCleanability($0, recipes: activeRecipes) + ).isManual }.count let pipeCount = min(2, max(1, (cleanableCount + 3) / 4)) let pipes: [(xStart: CGFloat, yTop: CGFloat, xElbow: CGFloat, verticalLen: CGFloat)] diff --git a/PoolProblem/PoolProblem/Views/PoolWindowLayout.swift b/PoolProblem/PoolProblem/Views/PoolWindowLayout.swift index 0fbb52f..6231eba 100644 --- a/PoolProblem/PoolProblem/Views/PoolWindowLayout.swift +++ b/PoolProblem/PoolProblem/Views/PoolWindowLayout.swift @@ -135,12 +135,14 @@ extension PoolWindowLayout { availableBytes: Int64, waterlineBytes: Int64, items: [ScanItem], + recipes: [Recipe], estimatedRecipeIDs: Set, excludedItemIDs: Set = [], height: CGFloat = 560 ) -> (layout: PoolWindowLayout, model: PoolLayerModel) { let model = PoolLayers.make( items: items, + recipes: recipes, totalBytes: totalBytes, availableBytes: availableBytes, estimatedRecipeIDs: estimatedRecipeIDs, diff --git a/Sources/DiskReservoirCore/Cleaner/ProgressiveCleaner.swift b/Sources/DiskReservoirCore/Cleaner/ProgressiveCleaner.swift index 0e6039a..2832948 100644 --- a/Sources/DiskReservoirCore/Cleaner/ProgressiveCleaner.swift +++ b/Sources/DiskReservoirCore/Cleaner/ProgressiveCleaner.swift @@ -49,13 +49,13 @@ public struct ProgressiveCleanupPolicy: Equatable, Sendable { } extension ProgressiveCleanupPolicy { - /// 合并配方自带保护名单与全局配置保护名单: - /// 这两处的子目录在渐进清理中永远不会被自动删除。 + /// 应用缓存的用户保护名单只适用于应用缓存;其他配方只采用各自的名单。 public static func mergedProtectedChildNames( recipe: Recipe, config: Config ) -> Set { - Set(recipe.protectedChildren + config.protectedCacheChildren) + Set(recipe.protectedChildren + (recipe.id == "library-caches" + ? config.protectedCacheChildren : [])) } } diff --git a/Sources/DiskReservoirCore/Flow/FlowAnalyzer.swift b/Sources/DiskReservoirCore/Flow/FlowAnalyzer.swift index 3ece942..e506391 100644 --- a/Sources/DiskReservoirCore/Flow/FlowAnalyzer.swift +++ b/Sources/DiskReservoirCore/Flow/FlowAnalyzer.swift @@ -152,6 +152,11 @@ public struct FlowAnalyzer: Sendable { } var result: [String: Double] = [:] for (id, points) in series where points.count >= 2 { + // A few minutes of scans cannot support a daily or weekly claim. + // Also do not show an upward trend after the latest size fell back. + guard let first = points.first, let last = points.last, + last.x - first.x >= 1, + last.y > first.y else { continue } let x = points.map(\.x) let y = points.map(\.y) let n = Double(x.count) diff --git a/Sources/DiskReservoirCore/Models/CleanupRationale.swift b/Sources/DiskReservoirCore/Models/CleanupRationale.swift index a6324de..0827c73 100644 --- a/Sources/DiskReservoirCore/Models/CleanupRationale.swift +++ b/Sources/DiskReservoirCore/Models/CleanupRationale.swift @@ -51,8 +51,8 @@ public struct CleanupRationale: Equatable, Sendable { self.lastUsed = lastUsed } - public static func make(for item: ScanItem) -> CleanupRationale { - switch item.cleanability { + public static func make(for item: ScanItem, cleanability: Cleanability? = nil) -> CleanupRationale { + switch cleanability ?? item.cleanability { case .displayOnly, .watchOnly: return CleanupRationale( suggestion: .userDataOnly, diff --git a/Sources/DiskReservoirCore/Recipes/BuiltInRecipes.swift b/Sources/DiskReservoirCore/Recipes/BuiltInRecipes.swift index 067105a..70b2b53 100644 --- a/Sources/DiskReservoirCore/Recipes/BuiltInRecipes.swift +++ b/Sources/DiskReservoirCore/Recipes/BuiltInRecipes.swift @@ -30,6 +30,7 @@ enum BuiltInRecipes { defaultAgeDays: 7, minimumSizeMB: 100, processName: nil, + cleanByChildOnly: true, resolvePaths: { paths in [paths.homeDirectory + "/Library/Developer/Xcode/DerivedData"] } @@ -71,8 +72,8 @@ enum BuiltInRecipes { category: .simulator, group: .xcode, safety: .requiresQuit, - disposition: .trash, - cleanability: .trashOnly, + disposition: .none, + cleanability: .displayOnly, defaultAgeDays: 30, minimumSizeMB: 100, processName: "Simulator", diff --git a/Sources/DiskReservoirCore/Scanner/ChildDirectoryExplorer.swift b/Sources/DiskReservoirCore/Scanner/ChildDirectoryExplorer.swift new file mode 100644 index 0000000..48190b8 --- /dev/null +++ b/Sources/DiskReservoirCore/Scanner/ChildDirectoryExplorer.swift @@ -0,0 +1,156 @@ +import Foundation +import Darwin + +public struct ChildGrowthObservation: Equatable, Sendable { + public let deltaBytes: Int64 + public let elapsedDays: Double + public let observedAt: Date +} + +public struct DirectoryIdentity: Equatable, Sendable { + public let deviceID: UInt64 + public let inode: UInt64 +} + +public struct ChildDirectoryInfo: Equatable, Identifiable, Sendable { + public let name: String + public let path: String + public let identity: DirectoryIdentity + public let bytes: Int64 + public let growth: ChildGrowthObservation? + public let isProtected: Bool + public let lastModified: Date? + + public var id: String { path } +} + +/// DerivedData 的共享目录影响多个项目;它们仍需用户逐项确认后才能清理。 +public enum DerivedDataChildPolicy { + public static let minimumIdleSeconds: TimeInterval = 60 + private static let sharedCacheNames: Set = [ + "CompilationCache.noindex", "ModuleCache.noindex", + "SDKExplicitPrecompiledModules", "SDKStatCaches.noindex", + "SymbolCache.noindex", "SourcePackages", + ] + + public static func isSharedCache(name: String) -> Bool { + sharedCacheNames.contains(name) + } +} + +/// 当前文件树与历史增长记录的交集。历史增量只作为一次观测展示,不推算每天速率。 +public struct ChildDirectoryExplorer: Sendable { + public init() {} + + public func list( + parentPath: String, + growthEntries: [GrowthEntry], + protectedChildNames: Set, + minimumBytes: Int64 = 10_000_000, + now: Date = Date() + ) -> [ChildDirectoryInfo] { + let parent = URL(fileURLWithPath: parentPath, isDirectory: true) + guard !ChildDirectoryAccess.isSymbolicLink(parentPath), + let children = try? FileManager.default.contentsOfDirectory( + at: parent, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey] + ) else { return [] } + let cutoff = now.addingTimeInterval(-7 * 86_400) + let canonicalParent = parent.resolvingSymlinksInPath().path + let latestGrowth = Dictionary(grouping: growthEntries.filter { + URL(fileURLWithPath: $0.path).resolvingSymlinksInPath().path.hasPrefix(canonicalParent + "/") + && $0.observedAt >= cutoff && $0.observedAt <= now + }, by: { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath().path }) + .compactMapValues { $0.max(by: { $0.observedAt < $1.observedAt }) } + + return children.compactMap { child -> ChildDirectoryInfo? in + guard !ChildDirectoryAccess.isSymbolicLink(child.path), + ((try? child.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory) == true, + let identity = ChildDirectoryAccess.identity(path: child.path), + let measured = POSIXDirectoryWalker.walk( + url: child, itemID: "child-directory", includeRecords: false, + includeDirectoryDates: true + ), measured.allocatedBytes >= minimumBytes else { return nil } + let historical = latestGrowth[child.resolvingSymlinksInPath().path] + let growth: ChildGrowthObservation? + if let historical, + historical.elapsedDays > 0, + historical.deltaBytes > 0, + historical.deltaBytes <= measured.allocatedBytes { + growth = ChildGrowthObservation( + deltaBytes: historical.deltaBytes, + elapsedDays: historical.elapsedDays, + observedAt: historical.observedAt + ) + } else { + growth = nil + } + return ChildDirectoryInfo( + name: child.lastPathComponent, + path: child.path, + identity: identity, + bytes: measured.allocatedBytes, + growth: growth, + isProtected: protectedChildNames.contains(child.lastPathComponent), + lastModified: max( + measured.newest ?? .distantPast, + POSIXDirectoryWalker.modificationDate(path: child.path) ?? .distantPast + ) + ) + }.sorted { lhs, rhs in + if lhs.isProtected != rhs.isProtected { return !lhs.isProtected } + let left = Double(lhs.bytes) + Double(lhs.growth?.deltaBytes ?? 0) + let right = Double(rhs.bytes) + Double(rhs.growth?.deltaBytes ?? 0) + if left != right { return left > right } + return lhs.path < rhs.path + } + } +} + +/// 在真正移动到废纸篓前再次校验目标,避免过期 UI 路径或符号链接越界。 +public enum ChildDirectoryAccess { + public static func canClean( + childPath: String, + parentPath: String, + authorizedParents: Set, + protectedNames: Set, + expectedIdentity: DirectoryIdentity? = nil, + minimumIdleSeconds: TimeInterval = 0, + now: Date = Date() + ) -> Bool { + let parent = URL(fileURLWithPath: parentPath, isDirectory: true) + let child = URL(fileURLWithPath: childPath, isDirectory: true) + guard authorizedParents.contains(parent.path), + parent.path == parentPath, + child.path == childPath, + child.deletingLastPathComponent().path == parent.path, + !protectedNames.contains(child.lastPathComponent), + !isSymbolicLink(parent.path), + !isSymbolicLink(child.path), + let currentIdentity = identity(path: child.path), + expectedIdentity.map({ $0 == currentIdentity }) ?? true else { return false } + guard minimumIdleSeconds > 0 else { return true } + guard let walk = POSIXDirectoryWalker.walk( + url: child, itemID: "child-idle-check", includeRecords: false, + includeDirectoryDates: true + ), walk.isComplete, + let rootModified = POSIXDirectoryWalker.modificationDate(path: child.path) else { + return false + } + let newest = max(walk.newest ?? .distantPast, rootModified) + return newest <= now.addingTimeInterval(-minimumIdleSeconds) + } + + static func isSymbolicLink(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFLNK + } + + public static func identity(path: String) -> DirectoryIdentity? { + var info = stat() + guard lstat(path, &info) == 0, (info.st_mode & S_IFMT) == S_IFDIR else { + return nil + } + return DirectoryIdentity(deviceID: UInt64(truncatingIfNeeded: info.st_dev), inode: UInt64(info.st_ino)) + } +} diff --git a/Sources/DiskReservoirCore/Scanner/POSIXDirectoryWalker.swift b/Sources/DiskReservoirCore/Scanner/POSIXDirectoryWalker.swift index 6588716..5d42e56 100644 --- a/Sources/DiskReservoirCore/Scanner/POSIXDirectoryWalker.swift +++ b/Sources/DiskReservoirCore/Scanner/POSIXDirectoryWalker.swift @@ -14,6 +14,9 @@ public enum POSIXDirectoryWalker { public var fileCount: Int = 0 public var newest: Date? public var files: [FileRecord] = [] + /// False when any entry or nested directory could not be inspected. + /// Size summaries may still use partial results; cleanup guards must not. + public var isComplete = true public init() {} } @@ -67,7 +70,7 @@ public enum POSIXDirectoryWalker { } /// 递归统计目录(大小、占用块、文件数、最新修改时间、文件记录)。 - /// 根目录无法打开时返回 `nil`;深层子目录打开失败时跳过该子树。 + /// 根目录无法打开时返回 `nil`;深层读取失败时保留已读统计并标记为不完整。 /// `includeRecords` 为 false 时跳过逐文件记录,只做汇总—— /// 用于废纸篓这类“只展示大小、不参与清理”的目录,速度提升明显。 /// `skipSubtrees`:命中(目录路径完全匹配)的子树不统计, @@ -76,7 +79,8 @@ public enum POSIXDirectoryWalker { url: URL, itemID: String, includeRecords: Bool = true, - skipSubtrees: Set = [] + skipSubtrees: Set = [], + includeDirectoryDates: Bool = false ) -> WalkResult? { guard let dir = opendir(url.path) else { return nil } defer { closedir(dir) } @@ -88,6 +92,7 @@ public enum POSIXDirectoryWalker { itemID: itemID, includeRecords: includeRecords, skipSubtrees: skipSubtrees, + includeDirectoryDates: includeDirectoryDates, entriesSinceCheckpoint: &entriesSinceCheckpoint, result: &result ) @@ -100,10 +105,16 @@ public enum POSIXDirectoryWalker { itemID: String, includeRecords: Bool, skipSubtrees: Set, + includeDirectoryDates: Bool, entriesSinceCheckpoint: inout Int, result: inout WalkResult ) { - while let entry = readdir(dir) { + while true { + errno = 0 + guard let entry = readdir(dir) else { + if errno != 0 { result.isComplete = false } + break + } entriesSinceCheckpoint += 1 if entriesSinceCheckpoint >= 128 { ScanWorkloadGate.shared.checkpoint() @@ -113,13 +124,22 @@ public enum POSIXDirectoryWalker { if name == "." || name == ".." { continue } let childURL = baseURL.appendingPathComponent(name) var st = stat() - guard lstat(childURL.path, &st) == 0 else { continue } + guard lstat(childURL.path, &st) == 0 else { + result.isComplete = false + continue + } switch st.st_mode & S_IFMT { case S_IFLNK: // 与 FileManager 版本一致:符号链接不计入 continue case S_IFDIR: if skipSubtrees.contains(childURL.path) { continue } + if includeDirectoryDates { + let modified = Date(timeIntervalSince1970: TimeInterval(st.st_mtimespec.tv_sec)) + if modified > (result.newest ?? .distantPast) { + result.newest = modified + } + } if let sub = opendir(childURL.path) { walkLevel( dir: sub, @@ -127,10 +147,13 @@ public enum POSIXDirectoryWalker { itemID: itemID, includeRecords: includeRecords, skipSubtrees: skipSubtrees, + includeDirectoryDates: includeDirectoryDates, entriesSinceCheckpoint: &entriesSinceCheckpoint, result: &result ) closedir(sub) + } else { + result.isComplete = false } case S_IFREG: let allocated = Int64(st.st_blocks) * 512 diff --git a/Sources/DiskReservoirCore/Scanner/ScanDisplayPolicy.swift b/Sources/DiskReservoirCore/Scanner/ScanDisplayPolicy.swift new file mode 100644 index 0000000..c3f59c4 --- /dev/null +++ b/Sources/DiskReservoirCore/Scanner/ScanDisplayPolicy.swift @@ -0,0 +1,24 @@ +/// 只影响界面上的配方列表,不删除扫描数据,也不改变自动清理候选。 +public enum ScanDisplayPolicy { + /// Persisted scan items describe the recipe used at scan time. Present them + /// with the stricter of that policy and the currently installed recipe. + /// A removed recipe is never presented as cleanable from old scan data. + public static func effectiveCleanability(_ item: ScanItem, recipes: [Recipe]) -> Cleanability { + guard let current = recipes.first(where: { $0.id == item.recipeID }) else { + return .displayOnly + } + if item.cleanability == .watchOnly || current.cleanability == .watchOnly { return .watchOnly } + if item.cleanability == .displayOnly || current.cleanability == .displayOnly { return .displayOnly } + if item.cleanability == .trashOnly || current.cleanability == .trashOnly { return .trashOnly } + return .regenerable + } + + public static func visibleItems(_ items: [ScanItem], recipes: [Recipe]) -> [ScanItem] { + let minimumByID = Dictionary(uniqueKeysWithValues: recipes.map { ($0.id, $0.minimumSizeMB) }) + return items.filter { item in + if item.recipeID == "trash" || item.recipeID == "own-trash-batches" { return true } + let minimumMB = minimumByID[item.recipeID] ?? 0 + return Double(item.allocatedBytes) >= max(0, minimumMB) * 1_000_000 + } + } +} diff --git a/Tests/DiskReservoirCoreTests/ChildDirectoryFlowEndToEndTests.swift b/Tests/DiskReservoirCoreTests/ChildDirectoryFlowEndToEndTests.swift new file mode 100644 index 0000000..964ef17 --- /dev/null +++ b/Tests/DiskReservoirCoreTests/ChildDirectoryFlowEndToEndTests.swift @@ -0,0 +1,280 @@ +import Foundation +import Testing +@testable import DiskReservoirCore + +@Test func childDirectoryFlowKeepsOnlySignificantCurrentDirectoriesAndHonestGrowth() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pp-child-flow-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let parent = root.appendingPathComponent("Caches", isDirectory: true) + try Fixtures.makeTree(root: parent, files: [ + ("large/data.bin", 11_000_000), + ("small/data.bin", 1_000_000), + ]) + let outside = root.appendingPathComponent("outside", isDirectory: true) + try Fixtures.makeTree(root: outside, files: [("data.bin", 11_000_000)]) + try FileManager.default.createSymbolicLink( + at: parent.appendingPathComponent("linked"), + withDestinationURL: outside + ) + + let now = Date(timeIntervalSince1970: 2_000_000_000) + let large = parent.appendingPathComponent("large").path + func growth(delta: Int64, observedAt: Date, elapsedDays: Double) -> GrowthEntry { + GrowthEntry( + observedAt: observedAt, + elapsedDays: elapsedDays, + name: "large", path: large, pattern: "~/Caches/large", kind: .surface, + deltaBytes: delta, rateBytesPerDay: Double(delta) / elapsedDays + ) + } + let olderPeak = growth(delta: 9_000_000, observedAt: now.addingTimeInterval(-86_400), elapsedDays: 0.01) + let latest = growth(delta: 3_000_000, observedAt: now.addingTimeInterval(-3_600), elapsedDays: 0.125) + let listed = ChildDirectoryExplorer().list( + parentPath: parent.path, + growthEntries: [olderPeak, latest], + protectedChildNames: [], + minimumBytes: 10_000_000, + now: now + ) + #expect(listed.map(\.name) == ["large"]) + #expect(listed.first.map { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath().path } + == URL(fileURLWithPath: large).resolvingSymlinksInPath().path) + #expect(listed.first?.bytes ?? 0 >= 10_000_000) + #expect(listed.first?.identity == ChildDirectoryAccess.identity(path: large)) + #expect(listed.first?.growth?.deltaBytes == 3_000_000) + #expect(listed.first?.growth?.elapsedDays == 0.125) + + let impossible = ChildDirectoryExplorer().list( + parentPath: parent.path, + growthEntries: [latest, growth(delta: 800_000_000, observedAt: now, elapsedDays: 0.001)], + protectedChildNames: [], minimumBytes: 10_000_000, now: now + ) + #expect(impossible.first?.growth == nil) +} + +@Test func childCleanupValidationRequiresExactAuthorizedIdleUnprotectedDirectory() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pp-child-guard-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let parent = root.appendingPathComponent("DerivedData", isDirectory: true) + let project = parent.appendingPathComponent("OldProject", isDirectory: true) + let shared = parent.appendingPathComponent("ModuleCache.noindex", isDirectory: true) + let nested = project.appendingPathComponent("Build", isDirectory: true) + let outside = root.appendingPathComponent("other", isDirectory: true) + for url in [nested, shared, outside] { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + let linked = parent.appendingPathComponent("linked") + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: outside) + let now = Date() + let old = now.addingTimeInterval(-2 * 86_400) + for url in [project, nested] { + try FileManager.default.setAttributes([.modificationDate: old], ofItemAtPath: url.path) + } + let originalIdentity = try #require(ChildDirectoryAccess.identity(path: project.path)) + func allowed(_ path: String, parents: Set = []) -> Bool { + ChildDirectoryAccess.canClean( + childPath: path, + parentPath: parent.path, + authorizedParents: parents.isEmpty ? [parent.path] : parents, + protectedNames: ["ModuleCache.noindex"], + expectedIdentity: originalIdentity, + minimumIdleSeconds: 86_400, + now: now + ) + } + #expect(allowed(project.path)) + #expect(!allowed(parent.path)) + #expect(!allowed(nested.path)) + #expect(!allowed(shared.path)) + #expect(!allowed(linked.path)) + #expect(!allowed(outside.path)) + #expect(!allowed(project.path, parents: [outside.path])) + try FileManager.default.setAttributes([.modificationDate: now], ofItemAtPath: nested.path) + #expect(!allowed(project.path)) + try FileManager.default.setAttributes([.modificationDate: old], ofItemAtPath: nested.path) + let trashRoot = root.appendingPathComponent("test-trash", isDirectory: true) + let moved = try TrashBatchDeleter(trashRoot: trashRoot, batchName: "Selected directory") + .deleteReturningResult(url: project, disposition: .trash) + #expect(!FileManager.default.fileExists(atPath: project.path)) + #expect(FileManager.default.fileExists(atPath: parent.path)) + #expect(FileManager.default.fileExists(atPath: shared.path)) + #expect(moved.resultingURL.map { FileManager.default.fileExists(atPath: $0.path) } == true) + #expect(!allowed(project.path)) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.modificationDate: old], ofItemAtPath: project.path) + #expect(!allowed(project.path)) // A replacement at the same path is a different target. +} + +@Test func unreadableNestedDirectoryCannotPassDerivedDataIdleCheck() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pp-child-unreadable-\(UUID().uuidString)", isDirectory: true) + let parent = root.appendingPathComponent("DerivedData", isDirectory: true) + let project = parent.appendingPathComponent("OldProject", isDirectory: true) + let unreadable = project.appendingPathComponent("Build", isDirectory: true) + try Fixtures.makeTree(root: project, files: [("Build/recent.bin", 4_096)]) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: unreadable.path) + try? FileManager.default.removeItem(at: root) + } + let now = Date() + let old = now.addingTimeInterval(-2 * 86_400) + for url in [project, unreadable] { + try FileManager.default.setAttributes([.modificationDate: old], ofItemAtPath: url.path) + } + let identity = try #require(ChildDirectoryAccess.identity(path: project.path)) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: unreadable.path) + + let walk = try #require(POSIXDirectoryWalker.walk( + url: project, itemID: "child-idle-check", includeRecords: false, + includeDirectoryDates: true + )) + #expect(!walk.isComplete) + #expect(!ChildDirectoryAccess.canClean( + childPath: project.path, parentPath: parent.path, + authorizedParents: [parent.path], protectedNames: [], + expectedIdentity: identity, minimumIdleSeconds: 86_400, now: now + )) +} + +@Test func restoredSnapshotUsesCurrentRecipeForCleanability() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pp-stale-recipe-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = SnapshotStore(paths: StoragePaths(baseURL: root)) + let item = ScanItem( + id: "old-simulator-devices", recipeID: "core-simulator-devices", + name: "Simulator devices", path: root.path, category: .simulator, + safety: .userConfirm, disposition: .trash, sizeBytes: 12_000_000, + allocatedBytes: 12_000_000, reclaimableBytes: 12_000_000, + fileCount: 1, lastModified: nil, cleanability: .trashOnly + ) + try store.append(Snapshot( + volume: VolumeInfo(totalBytes: 100_000_000, availableBytes: 40_000_000, timestamp: Date()), + items: [item] + )) + let restored = try #require(store.snapshots().last?.items.first) + #expect(restored.cleanability == .trashOnly) // The historical record remains intact. + #expect(ScanDisplayPolicy.effectiveCleanability( + restored, recipes: RecipeRegistry.builtIn() + ) == .displayOnly) + #expect(ScanDisplayPolicy.effectiveCleanability(restored, recipes: []) == .displayOnly) +} + +@Test func recipeMinimumIsPresentationOnlyAndTrashRemainsVisible() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pp-display-min-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try Fixtures.makeTree(root: root, files: [("small/data.bin", 1024), ("trash/data.bin", 1024)]) + let smallPath = root.appendingPathComponent("small").path + let trashPath = root.appendingPathComponent("trash").path + let small = Recipe( + id: "small", name: "Small", category: .common, + safety: .userConfirm, disposition: .trash, cleanability: .regenerable, + defaultAgeDays: 1, minimumSizeMB: 100, processName: nil, + resolvePaths: { _ in [smallPath] } + ) + let trash = Recipe( + id: "trash", name: "Trash", category: .common, + safety: .userConfirm, disposition: .none, cleanability: .displayOnly, + defaultAgeDays: 1, minimumSizeMB: 100, processName: nil, + resolvePaths: { _ in [trashPath] } + ) + let scan = try Scanner().scan(recipes: [small, trash], homeDirectory: root.path) + #expect(scan.items.count == 2) + let visible = ScanDisplayPolicy.visibleItems(scan.items, recipes: [small, trash]) + #expect(visible.map(\.recipeID) == ["trash"]) +} + +@Test func broadDeveloperRootsDoNotExposeWholeDirectoryDeletion() throws { + let recipes = Dictionary(uniqueKeysWithValues: RecipeRegistry.builtIn().map { ($0.id, $0) }) + let derived = try #require(recipes["deriveddata"]) + #expect(derived.cleanByChildOnly) + #expect(derived.disposition == .trash) + #expect(!derived.protectedChildren.contains("ModuleCache.noindex")) + + let simulatorDevices = try #require(recipes["core-simulator-devices"]) + #expect(simulatorDevices.cleanability == .displayOnly) + #expect(simulatorDevices.disposition == .none) +} + +@Test func derivedDataSharedAndProjectDirectoriesCanBeChosenAfterTheyBecomeIdle() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pp-derived-manual-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let logicalParent = root.appendingPathComponent("DerivedData", isDirectory: true) + try Fixtures.makeTree(root: logicalParent, files: [ + ("ModuleCache.noindex/cache.bin", 11_000_000), + ("RecentProject/Build/data.bin", 11_000_000), + ]) + let parent = logicalParent.resolvingSymlinksInPath() + let recipes = Dictionary(uniqueKeysWithValues: RecipeRegistry.builtIn().map { ($0.id, $0) }) + let derived = try #require(recipes["deriveddata"]) + let cache = try #require(recipes["library-caches"]) + var config = Config.default + config.protectedCacheChildren = ["ModuleCache.noindex"] + #expect(ProgressiveCleanupPolicy.mergedProtectedChildNames(recipe: cache, config: config) + .contains("ModuleCache.noindex")) + let protected = ProgressiveCleanupPolicy.mergedProtectedChildNames(recipe: derived, config: config) + #expect(!protected.contains("ModuleCache.noindex")) + let children = ChildDirectoryExplorer().list( + parentPath: parent.path, growthEntries: [], protectedChildNames: protected + ) + #expect(Set(children.map(\.name)) == ["ModuleCache.noindex", "RecentProject"]) + #expect(children.allSatisfy { !$0.isProtected }) + #expect(DerivedDataChildPolicy.isSharedCache(name: "ModuleCache.noindex")) + #expect(!DerivedDataChildPolicy.isSharedCache(name: "RecentProject")) + let checkedParent = try #require(children.first.map { + URL(fileURLWithPath: $0.path).deletingLastPathComponent().path + }) + let now = Date() + let idle = now.addingTimeInterval(-120) + for child in children { + let file = child.name == "RecentProject" + ? URL(fileURLWithPath: child.path).appendingPathComponent("Build/data.bin") + : URL(fileURLWithPath: child.path).appendingPathComponent("cache.bin") + try FileManager.default.setAttributes([.modificationDate: idle], ofItemAtPath: file.path) + try FileManager.default.setAttributes([.modificationDate: idle], ofItemAtPath: file.deletingLastPathComponent().path) + try FileManager.default.setAttributes([.modificationDate: idle], ofItemAtPath: child.path) + #expect(ChildDirectoryAccess.canClean( + childPath: child.path, parentPath: checkedParent, + authorizedParents: [checkedParent], protectedNames: protected, + expectedIdentity: child.identity, minimumIdleSeconds: 60, now: now + )) + } + let recent = try #require(children.first(where: { $0.name == "RecentProject" })) + let file = URL(fileURLWithPath: recent.path).appendingPathComponent("Build/data.bin") + try FileManager.default.setAttributes([.modificationDate: now], ofItemAtPath: file.path) + #expect(!ChildDirectoryAccess.canClean( + childPath: recent.path, parentPath: checkedParent, + authorizedParents: [checkedParent], protectedNames: protected, + expectedIdentity: recent.identity, minimumIdleSeconds: 60, now: now + )) +} + +@Test func shortRealScanIntervalDoesNotBecomeAWeeklyGrowthClaim() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pp-short-growth-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try Fixtures.makeTree(root: root, files: [("cache/first.bin", 1_000_000)]) + let recipe = Fixtures.recipe(id: "cache", path: root.appendingPathComponent("cache").path) + let first = try Scanner().scan(recipes: [recipe], homeDirectory: root.path) + try Fixtures.makeTree(root: root, files: [("cache/second.bin", 1_000_000)]) + let second = try Scanner().scan(recipes: [recipe], homeDirectory: root.path) + let now = Date() + func snapshot(_ scan: ScanResult, at date: Date) -> Snapshot { + Snapshot( + volume: VolumeInfo( + totalBytes: scan.volume.totalBytes, + availableBytes: scan.volume.availableBytes, + timestamp: date + ), + items: scan.items + ) + } + let short = [snapshot(first, at: now.addingTimeInterval(-3_600)), snapshot(second, at: now)] + #expect(FlowAnalyzer().growthRates(snapshots: short).isEmpty) + let long = [snapshot(first, at: now.addingTimeInterval(-2 * 86_400)), snapshot(second, at: now)] + #expect((FlowAnalyzer().growthRates(snapshots: long).values.first ?? 0) > 0) +} diff --git a/Tests/DiskReservoirCoreTests/ProgressiveCleanerTests.swift b/Tests/DiskReservoirCoreTests/ProgressiveCleanerTests.swift index e28d739..9f3b521 100644 --- a/Tests/DiskReservoirCoreTests/ProgressiveCleanerTests.swift +++ b/Tests/DiskReservoirCoreTests/ProgressiveCleanerTests.swift @@ -196,7 +196,7 @@ private struct RecorderDeleter: FileDeleting { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - let sizes: [Int64] = [600_000_000, 120_000_000, 50_000_000] + let sizes: [Int64] = [600_000, 120_000, 50_000] for (index, bytes) in sizes.enumerated() { let child = root.appendingPathComponent("child-\(index)", isDirectory: true) try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) @@ -219,7 +219,7 @@ private struct RecorderDeleter: FileDeleting { minimumAgeSeconds: 86_400, disposition: .deletePermanently, minimumCleanBytes: 0, - minimumCandidateBytes: 500_000_000 + minimumCandidateBytes: 500_000 )) #expect(outcome.trimmedCount == 1) @@ -232,9 +232,8 @@ private struct RecorderDeleter: FileDeleting { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - // child-a:600MB 但日增长 1GB(7 天加权后远超静态大项) - // child-b:800MB、无增长 - let children = [("child-a", 600_000_000), ("child-b", 800_000_000)] + // 只需维持候选项的大小和增长排序,不创建真实的大文件。 + let children = [("child-a", 600_000), ("child-b", 800_000)] for (name, bytes) in children { let child = root.appendingPathComponent(name, isDirectory: true) try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) @@ -254,19 +253,24 @@ private struct RecorderDeleter: FileDeleting { minimumAgeSeconds: 86_400, disposition: .trash, minimumCleanBytes: 0, - minimumCandidateBytes: 500_000_000, + minimumCandidateBytes: 500_000, childGrowthRates: [ - root.appendingPathComponent("child-a").path: 1_000_000_000, + root.appendingPathComponent("child-a").path: 1_000_000, root.appendingPathComponent("child-b").path: 0, ] ) + let fixtureTrash = root.appendingPathComponent(".Trash", isDirectory: true) let outcome = try ProgressiveCleaner( - deleter: FileManagerFileDeleter(), + deleter: TrashBatchDeleter(trashRoot: fixtureTrash, batchName: "PoolProblem Cleanup fixture"), logStore: CleanLogStore(paths: paths) ).run(policy: policy) #expect(outcome.trimmedCount == 1) #expect(outcome.entries.first?.itemNames == ["child-a"]) + #expect(FileManager.default.fileExists( + atPath: fixtureTrash.appendingPathComponent("PoolProblem Cleanup fixture/child-a/data.bin").path + )) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("child-a").path)) } @Test func mergedProtectedChildNamesCombinesRecipeAndConfig() { diff --git a/Tests/DiskReservoirCoreTests/RecipeRegistryTests.swift b/Tests/DiskReservoirCoreTests/RecipeRegistryTests.swift index 8fcf9f2..0815e3f 100644 --- a/Tests/DiskReservoirCoreTests/RecipeRegistryTests.swift +++ b/Tests/DiskReservoirCoreTests/RecipeRegistryTests.swift @@ -71,7 +71,7 @@ import Foundation @Test func recipesCarryCleanabilityAndProtection() { let recipes = Dictionary(uniqueKeysWithValues: RecipeRegistry.builtIn().map { ($0.id, $0) }) #expect(recipes["trash"]?.cleanability == .displayOnly) - #expect(recipes["core-simulator-devices"]?.cleanability == .trashOnly) + #expect(recipes["core-simulator-devices"]?.cleanability == .displayOnly) #expect(recipes["deriveddata"]?.disposition == .trash) #expect(recipes["xcode-archives"]?.cleanability == .displayOnly) #expect(recipes["xcode-archives"]?.allowsAutomaticPermanentDeletion == false) diff --git a/docs/product/cache-child-scope.md b/docs/product/cache-child-scope.md new file mode 100644 index 0000000..a2104d8 --- /dev/null +++ b/docs/product/cache-child-scope.md @@ -0,0 +1,26 @@ +# 子目录清理与小项展示边界 + +本次只调整展示与用户主动选择的清理入口。扫描结果仍完整保留,容量归因和自动清理继续使用原始结果;隐藏的小项不会被当成未扫描。 + +## 预期行为 + +- 应用缓存与 DerivedData 的详情展示当前仍存在、物理占用至少 10 MB 的一级目录;名称可在 Finder 中定位。 +- 增长只展示一次真实观测到的增量及观测时长,不把短间隔换算为“每天”。旧记录、已消失路径、以及增量大于当前占用的记录均不作为当前增长说明。 +- 主面板的日/周增长拟合至少需要 24 小时的观测跨度;短时间内的扫描变化只作为实际增量记录。 +- DerivedData 的根目录没有整目录清理入口。一级目录(含共享编译缓存)由用户单独选择、核对影响并确认后移入废纸篓;共享缓存可能使多个项目重新编译,项目目录可能重新索引、重建并重新下载依赖。 +- Xcode 仅仅打开、目录在过去一天有改动,都不足以永久禁用手动操作。执行前仍需核对路径和目录身份;最近 60 秒有写入的目录暂缓清理,以避开正在进行的构建。 +- CoreSimulator/Devices 内含模拟器用户数据;整目录清理入口改为只观察,与 Archives 等不可清理项目一起放入可展开的“只观察”列表。后续如需逐设备清理,应通过 `simctl` 或 Xcode 的设备管理能力另行设计。 +- 主列表按各配方已有 `minimumSizeMB` 门槛隐藏小项,废纸篓等专门入口保持可见。 + +## 清理前必须覆盖的失败方式 + +1. 路径已不存在、原路径被换成符号链接、子路径越过配方根目录、选择的是根目录本身或更深层目录。 +2. 配方不是逐子目录清理配方、旧扫描记录仍允许整目录清理、子目录名称在受保护名单内。 +3. DerivedData 中共享缓存、正在写入的项目被误当成普通项目;点击后到执行前目录发生变化;用户尚未确认影响就发生移动。 +4. 删除失败却提示成功,或移动到废纸篓后把磁盘可用空间误报为已释放。 +5. 小目录被列出;门槛附近的目录漏列;筛选影响扫描、历史、废纸篓或自动清理的真实数据。 +6. 短间隔速率被放大成每天增长;旧峰值覆盖较新观测;历史增长超过现存占用却仍显示为当前增长。 +7. 用户打开详情后,同名路径被替换为另一目录;执行前须核对设备与 inode。 +8. 共享缓存或过去 24 小时使用过的项目目录被永久禁用,导致实际可再生内容无法按用户意愿清理;应用缓存保护配置错误地施加到 DerivedData。 + +验证使用临时目录构造文件树和观测记录,走真实扫描、子目录枚举、清理资格判断;构建后由当前仓库中的 App 进行界面检查。 diff --git a/docs/verification/2026-09-27-cache-child-clarity.md b/docs/verification/2026-09-27-cache-child-clarity.md new file mode 100644 index 0000000..96a211a --- /dev/null +++ b/docs/verification/2026-09-27-cache-child-clarity.md @@ -0,0 +1,31 @@ +# 子目录展示与清理边界验证(2026-09-27) + +## 环境与可重复命令 + +- 当前仓库分支:`codex/cache-child-clarity`,基于已合并 PR34 的 `ab809de`。 +- macOS 26.6.2(Apple Silicon)、Xcode 27.0、Swift 6.4。 +- 前置条件:在仓库根目录执行;端到端夹具只读写系统临时目录内的 UUID 文件树,模拟废纸篓也设在同一夹具内,不触碰用户真实缓存或废纸篓。 +- 全套包测试:`swift test --scratch-path "$TMPDIR/poolproblem-child-clarity-build"`。 +- App 无签名编译和链接:`xcodebuild -project PoolProblem/PoolProblem.xcodeproj -scheme PoolProblem -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath "$TMPDIR/poolproblem-child-clarity-derived-data" CODE_SIGNING_ALLOWED=NO build`。 +- 现有 App 测试尝试:在上一条命令中把 `build` 改为 `-only-testing:PoolProblemTests CODE_SIGNING_ALLOWED=NO test`(只执行现有测试,不新增隔离测试)。 +- 资料格式与补丁:`python3 -m json.tool PoolProblem/PoolProblem/Localizable.xcstrings > /dev/null`、`git diff --check`。 +- DerivedData 后续修复:`/usr/bin/arch -arm64 /usr/bin/env PATH="$PATH" swift test --scratch-path "$TMPDIR/poolproblem-derived-manual-build"`;App 构建使用上面的 `xcodebuild` 命令,但将 `-derivedDataPath` 改为 `"$TMPDIR/poolproblem-derived-manual-dd"`。 + +## 端到端输入与结果 + +| 场景 | 输入与操作 | 结果 | +| --- | --- | --- | +| 子目录展示与增长 | 临时缓存树内构造约 11 MB、1 MB 的目录及一个指向树外的符号链接;给大目录先后写入 9 MB、3 MB 的增长观测,另模拟 800 MB 历史增量 | 仅展示达到 10 MB 且真实存在的目录;取最近一次 3 MB 观测及其实际时长;增量超过当前占用时不显示增长 | +| 清理资格与移动(原版) | 临时 DerivedData 树中构造普通项目、显式保护的共享缓存、深层目录、树外路径和符号链接;修改目录时间、替换同名目录;把允许的项目移入夹具内的模拟废纸篓 | 根目录、深层目录、显式保护项、符号链接、近期活跃目录、未授权父目录及被替换的同名目录均拒绝;只移动选择的旧项目,父目录与共享缓存留存 | +| 主列表门槛 | 扫描临时 1 KB 配方目录及 1 KB 废纸篓目录,二者配方门槛均设为 100 MB | 原始扫描仍含两项;界面筛选隐藏小配方项,废纸篓入口保留 | +| 短间隔趋势 | 对临时目录做两次真实扫描,第二次增加文件,分别用 1 小时和 2 天的快照跨度输入趋势计算 | 1 小时跨度不产生“每日/每周增长”速率;2 天跨度保留增长趋势 | +| 开发工具大目录 | 读取当前内置配方 | DerivedData 只允许逐项;CoreSimulator 设备数据列入可展开的“只观察”项目,不提供整目录删除 | +| DerivedData 可选性修复 | 临时 DerivedData 内建立 11 MB 共享缓存与 11 MB 项目目录;给应用缓存保护配置添加同名项;模拟上次写入在 120 秒前与刚刚写入 | 共享缓存和项目目录都可列出且不受应用缓存保护配置误伤;120 秒无写入时允许逐项清理,刚写入时拒绝。路径和目录身份仍在执行前复核 | + +原实现的新断言先于实现添加并观察到失败;当时全套 `swift test` **202 项通过**(199 项核心、3 项 CLI)。DerivedData 修复的端到端断言也先于实现添加并观察到失败;修正后全套 `swift test` **203 项通过**(200 项核心、3 项 CLI)。修复后的 macOS Debug 无签名构建 **BUILD SUCCEEDED**,确认弹窗、标签与定时刷新均编译通过。原版现有 Xcode App 测试宿主在建立测试连接前退出,报 `Early unexpected exit, operation never finished bootstrapping`,没有执行到测试断言;此项未验证通过,也不能作为代码失败的证据。本文档是可复核工件,临时构建目录和日志可由上面的命令重新生成。 + +## 验收边界 + +- 已验证真实文件树枚举、增长数据筛选、子项清理资格、模拟废纸篓内的单项移动、配方边界、编译和链接。 +- 未对用户真实缓存、DerivedData 或模拟器设备执行清理。移动到废纸篓不等于立即增加磁盘可用容量。 +- 菜单栏浮层的实际排版、Finder 按钮点击和辅助功能仍需在用户当前仓库重建运行后人工检查;本次无签名构建不代表此项已通过。 diff --git a/docs/verification/2026-09-27-pr35-review-tests-swift-testing.xml b/docs/verification/2026-09-27-pr35-review-tests-swift-testing.xml new file mode 100644 index 0000000..8c871e4 --- /dev/null +++ b/docs/verification/2026-09-27-pr35-review-tests-swift-testing.xml @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/verification/2026-09-27-pr35-review.md b/docs/verification/2026-09-27-pr35-review.md new file mode 100644 index 0000000..abcd85e --- /dev/null +++ b/docs/verification/2026-09-27-pr35-review.md @@ -0,0 +1,27 @@ +# PR35 审核意见修复验证(2026-09-27) + +## 输入与范围 + +- PR35 的三条行内意见:Xcode 退出后的详情状态、旧快照的可清理分类、DerivedData 深层目录读取失败时的闲置判断。 +- 测试只在系统临时目录创建夹具。不可读目录通过权限设为 `000` 模拟,测试结束先恢复权限再删除。没有向系统废纸篓移动文件。 +- `PoolProblem/PoolProblem/Localizable.xcstrings` 在本次开始前已有本地改动;本次未编辑它。 + +## 复现与自动验证 + +环境:macOS、Apple Silicon、当前 Xcode 与 Swift 6;在仓库根目录运行。SwiftPM 和 Xcode 首次受限运行分别遇到缓存权限与依赖获取限制,因此使用已允许的本地构建环境重试。构建目录位于 `$TMPDIR`。 + +回归场景先于实现添加。修复前,新增场景因缺少 `WalkResult.isComplete` 和 `ScanDisplayPolicy.effectiveCleanability` 而无法编译;修复后用以下命令验证: + +```sh +/usr/bin/arch -arm64 /usr/bin/env PATH="$PATH" CLANG_MODULE_CACHE_PATH="$TMPDIR/poolproblem-pr35-clang-cache" swift test --scratch-path "$TMPDIR/poolproblem-pr35-review-build" --xunit-output docs/verification/2026-09-27-pr35-review-tests.xml +``` + +输入覆盖:旧 `.trashOnly` 模拟器设备快照写入并恢复后按当前 `.displayOnly` 配方展示;DerivedData 项目内有不可读的深层目录时,即使其可读部分看起来已闲置,清理前校验仍拒绝整个项目。完整报告为 [2026-09-27-pr35-review-tests-swift-testing.xml](2026-09-27-pr35-review-tests-swift-testing.xml),结果为核心 202 项、CLI 3 项通过,0 失败。 + +App 编译: + +```sh +xcodebuild -project PoolProblem/PoolProblem.xcodeproj -scheme PoolProblem -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath "$TMPDIR/poolproblem-pr35-review-dd" CODE_SIGNING_ALLOWED=NO build +``` + +结果:未签名 Debug 构建成功。`git diff --check` 通过。编译不代表运行中的浮层已做视觉或交互验收;退出 Xcode 后提示在最多约 10 秒内刷新、旧快照的水池分类,仍需在 App 中观察。 diff --git a/docs/verification/2026-09-27-test-trash-isolation.md b/docs/verification/2026-09-27-test-trash-isolation.md new file mode 100644 index 0000000..25c948a --- /dev/null +++ b/docs/verification/2026-09-27-test-trash-isolation.md @@ -0,0 +1,18 @@ +# 测试夹具污染系统废纸篓:诊断与隔离验证 + +## 原因与现存状态 + +- `progressiveCleanerPrefersFastGrowingCandidates` 原先每次在系统临时目录创建 `child-a/data.bin`(600,000,000 字节)和 `child-b/data.bin`(800,000,000 字节),把目录修改时间设为 30 天前,再用 `FileManagerFileDeleter` 按 `.trash` 清理 `child-a`。该删除器调用系统 `FileManager.trashItem`,所以临时目录虽随后删除,`child-a` 仍留在用户真正的废纸篓。 +- Finder 只读检查见到多个 `~/.Trash/child-a .../data.bin`,展开条目的文件大小为 600 MB;在本次修复前,Finder 显示系统废纸篓共有 13 个顶层项目。终端直接枚举 `~/.Trash` 被 macOS 拒绝;上述判断依据 Finder 可见条目和代码,不对其他废纸篓项目作推断。 +- 现存条目没有删除、恢复或修改。修正仅防止将来的测试继续产生此类条目。 + +## 修正与可重复验证 + +- 把增长优先场景的夹具大小缩为 600,000 与 800,000 字节,增长输入和候选门槛等比例降低,仍覆盖“增长较快的小候选优先于更大的静态候选”。同文件的最小候选规模场景也从约 770 MB 的夹具缩至约 770 KB,保持原有候选排序和门槛关系。 +- 清理器使用 `TrashBatchDeleter(trashRoot: <该测试的临时根目录>/.Trash)`;断言选中项的 `data.bin` 进入该临时废纸篓,原路径消失。测试结束时临时根目录及其中的废纸篓一起移除。 +- 环境:macOS arm64;在仓库根目录执行。输入由测试在 `$TMPDIR` 下用 UUID 目录自动生成;不依赖用户文件。 +- 单项:`/usr/bin/arch -arm64 /usr/bin/env PATH="$PATH" swift test --scratch-path "$TMPDIR/poolproblem-trash-test-fix/build" --filter progressiveCleanerPrefersFastGrowingCandidates`,1 项通过。 +- 全套:`/usr/bin/arch -arm64 /usr/bin/env PATH="$PATH" swift test --scratch-path "$TMPDIR/poolproblem-trash-test-fix/build"`,核心 200 项和 CLI 3 项通过。 +- 全套结束后用 Finder 再次只读核对:系统废纸篓仍显示 13 个顶层项目,未新增 `child-a`。这只证明本次运行没有增加可见顶层项目;不代表现存条目已被清理。 + +验证记录是持久工件;临时构建与日志目录可由上述命令重新生成。