Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 225 additions & 12 deletions PoolProblem/PoolProblem/AppService.swift

Large diffs are not rendered by default.

408 changes: 408 additions & 0 deletions PoolProblem/PoolProblem/Localizable.xcstrings

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions PoolProblem/PoolProblem/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ final class AppState: ObservableObject {
/// 正在删除的条目剩余比例:1 → 0,用于列表里大小逐渐缩小到消失的动画。
@Published var deletingProgress: Double = 1
@Published var lastCleanSummary: String?
@Published var lastOwnerCommandRecord: OwnerCommandRecord?
@Published var detailItem: ScanItem?
@Published var keptItemIDs: Set<String> = []
@Published var availableHistory: [Int64] = []
Expand Down
3 changes: 2 additions & 1 deletion PoolProblem/PoolProblem/Views/GrowthInsightsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,10 @@ struct GrowthInsightsView: View {
/// Observed assets stay outside every cleanup entry point. The figure is
/// allocated disk space, while reclaimable space remains zero.
private var watchSection: some View {
let watchItems = state.items
let watchItems = service.visibleItems(state.items)
.filter {
$0.cleanability == .watchOnly
&& $0.recipeID != OwnerCommandRecipe.pnpmStorePrune.id
&& $0.paths.contains { GrowthPathStatus.probe($0) != .missing }
}
.sorted { $0.allocatedBytes > $1.allocatedBytes }
Expand Down
393 changes: 324 additions & 69 deletions PoolProblem/PoolProblem/Views/MenuBarView.swift

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions PoolProblem/PoolProblemTests/AppServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,40 @@ import Foundation
@testable import PoolProblem
import DiskReservoirCore

@MainActor
@Test func pnpmStoreCacheChildIsNeitherListedForCleaningNorMoved() async throws {
let fixture = FileManager.default.temporaryDirectory
.appendingPathComponent("pp-pnpm-child-e2e-\(UUID())", isDirectory: true)
defer { try? FileManager.default.removeItem(at: fixture) }
let home = fixture.appendingPathComponent("home")
let caches = home.appendingPathComponent("Library/Caches")
let store = caches.appendingPathComponent("pnpm")
let data = fixture.appendingPathComponent("data")
try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true)
try Data(repeating: 1, count: 11_000_000).write(to: store.appendingPathComponent("package"))
let fakePnpm = fixture.appendingPathComponent("bin/pnpm")
try FileManager.default.createDirectory(at: fakePnpm.deletingLastPathComponent(), withIntermediateDirectories: true)
try ("#!/bin/sh\necho '\(store.path)'\n").write(to: fakePnpm, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakePnpm.path)
let paths = StoragePaths(baseURL: data, homeDirectory: home.path)
let state = AppState()
let service = AppService(
state: state, paths: paths, automationEnabled: false,
pnpmRunner: OwnerCommandRunner(
recipe: .pnpmStorePrune, executable: fakePnpm.path,
environment: ["PATH": fakePnpm.deletingLastPathComponent().path], home: home.path
)
)
await service.scanNow()
let cacheItem = try #require(state.items.first { $0.recipeID == "library-caches" })
let rawChild = try #require(ChildDirectoryExplorer().list(
parentPath: caches.path, growthEntries: [], protectedChildNames: []
).first { $0.path == store.path })
#expect(!(await service.cacheChildren(for: cacheItem)).contains { $0.path == store.path })
#expect(!(await service.cleanCacheChild(rawChild, in: cacheItem)))
#expect(FileManager.default.fileExists(atPath: store.appendingPathComponent("package").path))
}

@MainActor
@Test func appServiceScanWritesSnapshot() async throws {
let dir = FileManager.default.temporaryDirectory
Expand Down
44 changes: 43 additions & 1 deletion Sources/DiskReservoirCore/Cleaner/Cleaner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,39 @@ public struct Cleaner: Sendable {
private let deleter: FileDeleting
private let inspector: ProcessInspecting
private let logStore: CleanLogStore
private let homeDirectory: String
private let availableBytesReader: @Sendable (URL) -> Int64
private let now: @Sendable () -> Date
private let ownerStoreProbe: @Sendable () -> Result<OwnerCommandTarget, OwnerCommandFailure>
private let knownOwnerStorePaths: [String]
private let ownerHistoryOverflowed: Bool

public init(
evaluator: RuleEvaluator,
deleter: FileDeleting,
inspector: ProcessInspecting,
logStore: CleanLogStore,
homeDirectory: String = NSHomeDirectory(),
availableBytesReader: @escaping @Sendable (URL) -> Int64 = {
VolumeReader.read(fileURL: $0).availableBytes
},
now: @escaping @Sendable () -> Date = { Date() }
now: @escaping @Sendable () -> Date = { Date() },
ownerStoreProbe: (@Sendable () -> Result<OwnerCommandTarget, OwnerCommandFailure>)? = nil,
knownOwnerStorePaths: [String] = [],
ownerHistoryOverflowed: Bool = false
) {
self.evaluator = evaluator
self.deleter = deleter
self.inspector = inspector
self.logStore = logStore
self.homeDirectory = homeDirectory
self.availableBytesReader = availableBytesReader
self.now = now
self.ownerStoreProbe = ownerStoreProbe ?? {
OwnerCommandRunner(recipe: .pnpmStorePrune, home: homeDirectory).probe()
}
self.knownOwnerStorePaths = knownOwnerStorePaths
self.ownerHistoryOverflowed = ownerHistoryOverflowed
}

/// 清理底线兜底:任何删除决定都必须经过可清理性校验。
Expand Down Expand Up @@ -82,6 +96,7 @@ public struct Cleaner: Sendable {
}
let availableBefore = availableBytesReader(scan.volumeURL)
let candidates = scan.items
.filter { $0.recipeID != OwnerCommandRecipe.pnpmStorePrune.id }
.filter { !RuleEvaluator.isPathProtected(item: $0, whitelistPaths: config.whitelistPaths) }
// 应用无法删除的手动项(Xcode/Finder)不进入自动/强制清理候选
.filter { !CleanupRationale.make(for: $0).isManual }
Expand Down Expand Up @@ -126,6 +141,21 @@ public struct Cleaner: Sendable {
}
return left.id < right.id
}
let knownStorePaths = knownOwnerStorePaths + scan.items
.filter { $0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id }
.map(\.path)
let needsOwnerProbe = candidates.contains { item in
item.category == .packageManager
|| (item.paths.isEmpty ? [item.path] : item.paths).contains { path in
OwnerManagedPathGuard.isRecognizablePnpmLocation(
path, homeDirectory: homeDirectory
) || knownStorePaths.contains {
OwnerManagedPathGuard.overlaps(path, storePath: $0)
}
}
}
let ownerStore: Result<OwnerCommandTarget, OwnerCommandFailure> = needsOwnerProbe
? ownerStoreProbe() : .failure(.unavailable)
var entries: [CleanLogEntry] = []
var freedTotal: Int64 = 0
var below = true
Expand Down Expand Up @@ -167,6 +197,18 @@ public struct Cleaner: Sendable {
let targetPaths = item.paths.isEmpty ? [item.path] : item.paths
var itemFreed: Int64 = 0
for target in targetPaths {
guard OwnerManagedPathGuard.mayDelete(
path: target, recipeID: item.recipeID, probe: ownerStore,
homeDirectory: homeDirectory, knownStorePaths: knownStorePaths,
historyOverflowed: ownerHistoryOverflowed
) else { continue }
if item.recipeID == PackageManagerRecipes.familyID,
!PackageManagerRecipes.isApprovedDefaultCachePath(target, homeDirectory: homeDirectory) {
continue
}
guard !PackageManagerRecipes.isLegacyPnpmPath(target, homeDirectory: homeDirectory) else {
continue
}
// 单项失败(如 TCC 权限)不影响后续项:尽力而为,继续清理其他目标
guard let deletion = try? deleter.deleteReturningResult(
url: URL(fileURLWithPath: target),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Foundation

/// Durable safety history for owner-managed store paths. Snapshot retention
/// must not erase a previously observed target while its files still exist.
public struct OwnerCommandKnownTargetsStore: Sendable {
public struct State: Codable, Sendable {
public var paths: [String]
/// Once capacity was exceeded, deleted history is unknown. Callers
/// must fail closed for broad raw-path cleanup.
public var overflowed: Bool

public init(paths: [String] = [], overflowed: Bool = false) {
self.paths = paths
self.overflowed = overflowed
}
}

public static let maximumPaths = 1_024
private static let lock = NSLock()
private let pathsConfig: StoragePaths
private let store: JSONStoring

public init(paths: StoragePaths, store: JSONStoring = JSONStore()) {
self.pathsConfig = paths
self.store = store
}

public func state() throws -> State {
Self.lock.lock(); defer { Self.lock.unlock() }
return try read()
}

public func paths() throws -> [String] { try state().paths }

@discardableResult
public func record(_ newPaths: [String]) throws -> State {
Self.lock.lock(); defer { Self.lock.unlock() }
var history = try read()
for raw in newPaths {
guard let canonical = canonicalStorePath(raw) else { continue }
history.paths.removeAll { $0 == canonical }
history.paths.append(canonical)
}
if history.paths.count > Self.maximumPaths {
history.overflowed = true
history.paths = Array(history.paths.suffix(Self.maximumPaths))
}
try store.save(history, to: fileURL)
return history
}

/// One-time or repeatable migration from the retained snapshots. `record`
/// de-duplicates, so replaying startup history cannot grow the file.
@discardableResult
public func migrate(snapshots: [Snapshot]) throws -> State {
try record(snapshots.flatMap { snapshot in
snapshot.items.filter {
$0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id
}.map(\.path)
})
}

private var fileURL: URL {
pathsConfig.baseURL.appendingPathComponent("pnpm-known-store-paths.json")
}

private func read() throws -> State {
try store.load(State.self, from: fileURL) ?? State()
}

private func canonicalStorePath(_ raw: String) -> String? {
guard raw.hasPrefix("/"), !raw.contains("\0"), !raw.contains("\r"),
!raw.contains("\n"), raw.utf8.count <= 4_096 else { return nil }
let canonical = URL(fileURLWithPath: raw)
.resolvingSymlinksInPath().standardizedFileURL.path
let home = URL(fileURLWithPath: pathsConfig.homeDirectory)
.resolvingSymlinksInPath().standardizedFileURL.path
guard canonical != "/", canonical != home,
URL(fileURLWithPath: canonical).pathComponents.count > 2 else { return nil }
return canonical
}
}
Loading
Loading