diff --git a/Sources/MacClean/Modules/Duplicates/DuplicatesModule.swift b/Sources/MacClean/Modules/Duplicates/DuplicatesModule.swift index 5c1a3f0..32c6d50 100644 --- a/Sources/MacClean/Modules/Duplicates/DuplicatesModule.swift +++ b/Sources/MacClean/Modules/Duplicates/DuplicatesModule.swift @@ -30,6 +30,7 @@ public struct DuplicatesModule: ScanModule { guard !duplicates.isEmpty else { return [] } return [ScanResult(category: .duplicates, items: duplicates, autoSelect: false)] + .filteringUncleanable() } /// Run the full pipeline. Pure decisions come from `DuplicateDetection` diff --git a/Sources/MacClean/Modules/LargeOldFiles/LargeOldFilesModule.swift b/Sources/MacClean/Modules/LargeOldFiles/LargeOldFilesModule.swift index bdd56a7..ce93435 100644 --- a/Sources/MacClean/Modules/LargeOldFiles/LargeOldFilesModule.swift +++ b/Sources/MacClean/Modules/LargeOldFiles/LargeOldFilesModule.swift @@ -43,7 +43,7 @@ public struct LargeOldFilesModule: ScanModule { if !split.old.isEmpty { results.append(ScanResult(category: .oldFiles, items: split.old, autoSelect: false)) } - return results + return results.filteringUncleanable() } /// Pure splitter for testability: classifies file items into "large" and "old" diff --git a/Sources/MacClean/Modules/MailAttachments/MailAttachmentsModule.swift b/Sources/MacClean/Modules/MailAttachments/MailAttachmentsModule.swift index ff3638c..0c88c9a 100644 --- a/Sources/MacClean/Modules/MailAttachments/MailAttachmentsModule.swift +++ b/Sources/MacClean/Modules/MailAttachments/MailAttachmentsModule.swift @@ -52,5 +52,6 @@ public struct MailAttachmentsModule: ScanModule { guard !items.isEmpty else { return [] } return [ScanResult(category: .mailAttachments, items: items)] + .filteringUncleanable() } } diff --git a/Sources/MacClean/Modules/Malware/MalwareModule.swift b/Sources/MacClean/Modules/Malware/MalwareModule.swift index 2879941..0b47e0d 100644 --- a/Sources/MacClean/Modules/Malware/MalwareModule.swift +++ b/Sources/MacClean/Modules/Malware/MalwareModule.swift @@ -51,6 +51,7 @@ public struct MalwareModule: ScanModule { guard !suspiciousItems.isEmpty else { return [] } return [ScanResult(category: .malware, items: suspiciousItems, autoSelect: true)] + .filteringUncleanable() } private func knownMalwareLocations() -> [ScanTarget] { diff --git a/Sources/MacClean/Modules/Privacy/PrivacyModule.swift b/Sources/MacClean/Modules/Privacy/PrivacyModule.swift index 346f2a5..b73e17c 100644 --- a/Sources/MacClean/Modules/Privacy/PrivacyModule.swift +++ b/Sources/MacClean/Modules/Privacy/PrivacyModule.swift @@ -45,7 +45,7 @@ public struct PrivacyModule: ScanModule { if !system.isEmpty { results.append(ScanResult(category: .systemPrivacy, items: system)) } - return results + return results.filteringUncleanable() } private func scanBrowserData() async -> [FileItem] { diff --git a/Sources/MacClean/Modules/SystemJunk/SystemJunkModule.swift b/Sources/MacClean/Modules/SystemJunk/SystemJunkModule.swift index a8e9b7f..641c25e 100644 --- a/Sources/MacClean/Modules/SystemJunk/SystemJunkModule.swift +++ b/Sources/MacClean/Modules/SystemJunk/SystemJunkModule.swift @@ -89,7 +89,9 @@ public struct SystemJunkModule: ScanModule { results.append(result) } } - return results.sorted { $0.totalSize > $1.totalSize } + return results + .filteringUncleanable() + .sorted { $0.totalSize > $1.totalSize } } } } diff --git a/Sources/MacClean/Modules/TrashBins/TrashBinsModule.swift b/Sources/MacClean/Modules/TrashBins/TrashBinsModule.swift index f6bed4c..a30876a 100644 --- a/Sources/MacClean/Modules/TrashBins/TrashBinsModule.swift +++ b/Sources/MacClean/Modules/TrashBins/TrashBinsModule.swift @@ -33,5 +33,6 @@ public struct TrashBinsModule: ScanModule { guard !items.isEmpty else { return [] } return [ScanResult(category: .trashBins, items: items)] + .filteringUncleanable() } } diff --git a/Sources/MacCleanKit/CleanFilter.swift b/Sources/MacCleanKit/CleanFilter.swift index 679bab9..8f33165 100644 --- a/Sources/MacCleanKit/CleanFilter.swift +++ b/Sources/MacCleanKit/CleanFilter.swift @@ -53,3 +53,22 @@ public enum CleanFilter { return true } } + +public extension Array where Element == ScanResult { + /// Drops items the current process couldn't trash, per + /// `CleanFilter.isCleanableByCurrentProcess`. Producing modules + /// call this on their results before returning — that way every + /// caller (ScanCoordinator, each per-module ViewModel/View that + /// invokes `module.scan()` directly) sees a filtered set without + /// needing to know about the filter. The contract is: a + /// `ScanModule.scan()` only returns items the user can act on. + func filteringUncleanable() -> [ScanResult] { + map { result in + ScanResult( + category: result.category, + items: result.items.filter { CleanFilter.isCleanableByCurrentProcess($0.url) }, + autoSelect: result.autoSelect + ) + } + } +} diff --git a/Sources/MacCleanKit/Constants.swift b/Sources/MacCleanKit/Constants.swift index 9f54d66..0436f62 100644 --- a/Sources/MacCleanKit/Constants.swift +++ b/Sources/MacCleanKit/Constants.swift @@ -159,5 +159,5 @@ public enum MCConstants { // plugin was tried (commit history) but doesn't work under multi-arch // `swift build --arch arm64 --arch x86_64` because xcbuild doesn't // execute plugins. - public static let appVersion = "1.5.1" + public static let appVersion = "1.5.2" } diff --git a/Tests/MacCleanKitTests/CleanFilterTests.swift b/Tests/MacCleanKitTests/CleanFilterTests.swift index 094bb6a..d4851dc 100644 --- a/Tests/MacCleanKitTests/CleanFilterTests.swift +++ b/Tests/MacCleanKitTests/CleanFilterTests.swift @@ -77,6 +77,38 @@ final class CleanFilterTests: XCTestCase { [.posixPermissions: 0o755], ofItemAtPath: dataVaultLike.path) } + // MARK: Array extension + + func testFilteringUncleanableKeepsCleanableItemsAndDropsRest() throws { + // Real cleanable file in a writable dir. + let okFile = tmpRoot.appending(path: "ok.cache") + FileManager.default.createFile(atPath: okFile.path, contents: Data([1, 2, 3])) + // Unwritable parent: simulates root-owned `/Library/Caches/com.apple.*`. + let locked = tmpRoot.appending(path: "locked") + try FileManager.default.createDirectory(at: locked, withIntermediateDirectories: true) + let trapped = locked.appending(path: "trapped.log") + FileManager.default.createFile(atPath: trapped.path, contents: Data()) + try FileManager.default.setAttributes( + [.posixPermissions: 0o555], ofItemAtPath: locked.path) + + let ok = FileItem(url: okFile, name: "ok.cache", size: 3, allocatedSize: 3, isDirectory: false) + let bad = FileItem(url: trapped, name: "trapped.log", size: 0, allocatedSize: 0, isDirectory: false) + + let input: [ScanResult] = [ + ScanResult(category: .userCaches, items: [ok, bad]) + ] + let filtered = input.filteringUncleanable() + + XCTAssertEqual(filtered.count, 1) + XCTAssertEqual(filtered[0].items.count, 1, "Only the cleanable item should survive") + XCTAssertEqual(filtered[0].items.first?.name, "ok.cache") + // Category and autoSelect are preserved across the filter. + XCTAssertEqual(filtered[0].category, .userCaches) + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: locked.path) + } + func testFilesInsideUnwritableDirectoryAreNotCleanable() throws { // Even individual files inside an unwritable parent should be // dropped — this is what catches every leaf inside a root-owned diff --git a/VERSION b/VERSION index 26ca594..4cda8f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.1 +1.5.2