From 511fff84a52e7e36c03edd8d84e5cf0274ff5e21 Mon Sep 17 00:00:00 2001 From: Iliya Date: Mon, 1 Jun 2026 09:40:37 -0500 Subject: [PATCH 1/3] =?UTF-8?q?Add=20Array.filteringUncleanabl?= =?UTF-8?q?e()=20=E2=80=94=20the=20contract=20carrier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modules will call this on their results before returning so every consumer (ScanCoordinator AND per-module ViewModels/Views that call `module.scan()` directly) sees a filtered set. The single 1.5.1 wire in ScanCoordinator was a partial fix — it only covered Smart Scan; direct module.scan() calls from SystemJunkViewModel, MailAttachmentsView, TrashBinsView, MalwareView, PrivacyView, LargeOldFilesView, and DuplicatesView still leaked uncleanable items to the UI. Adds one Array-extension test asserting category + autoSelect are preserved across the filter. --- Sources/MacCleanKit/CleanFilter.swift | 19 +++++++++++ Tests/MacCleanKitTests/CleanFilterTests.swift | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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/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 From 15b960a7b0567b0fc6c6c0519baf391970aedc6d Mon Sep 17 00:00:00 2001 From: Iliya Date: Mon, 1 Jun 2026 09:41:11 -0500 Subject: [PATCH 2/3] Apply .filteringUncleanable() inside every producing module's scan() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each ScanModule now returns only items the current process could trash. Callers don't need to know about the filter — the contract travels with the result. This is what 1.5.1 was supposed to be: the fix has to live at the module boundary, not at the ScanCoordinator, because seven views/ViewModels call `module.scan()` directly: - SystemJunkViewModel — the path the user reported on - MailAttachmentsView - TrashBinsView - MalwareView - PrivacyView - LargeOldFilesView - DuplicatesView ScanCoordinator (Smart Scan) was the only path that went through the 1.5.1 inline filter, so the per-module Clean flow leaked the exact same 10 un-cleanable items the filter was designed to drop: /Library/Caches/com.apple.*, /private/var/log/com.apple.xpc.launchd/*, /Library/Logs/PaloAltoNetworks/*, and the data-vaulted ~/Library/Caches/com.apple.* set. The three modules that don't produce file results (Optimization, Maintenance, Updater, Uninstaller, SpaceLens, Shredder all return [] from scan()) are untouched. --- Sources/MacClean/Modules/Duplicates/DuplicatesModule.swift | 1 + .../MacClean/Modules/LargeOldFiles/LargeOldFilesModule.swift | 2 +- .../Modules/MailAttachments/MailAttachmentsModule.swift | 1 + Sources/MacClean/Modules/Malware/MalwareModule.swift | 1 + Sources/MacClean/Modules/Privacy/PrivacyModule.swift | 2 +- Sources/MacClean/Modules/SystemJunk/SystemJunkModule.swift | 4 +++- Sources/MacClean/Modules/TrashBins/TrashBinsModule.swift | 1 + 7 files changed, 9 insertions(+), 3 deletions(-) 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() } } From c1848d7a80dec6e09b00a7fdf98b400c0d3953f8 Mon Sep 17 00:00:00 2001 From: Iliya Date: Mon, 1 Jun 2026 09:41:22 -0500 Subject: [PATCH 3/3] =?UTF-8?q?Bump=201.5.1=20=E2=86=92=201.5.2:=20filter?= =?UTF-8?q?=20at=20module=20boundary,=20not=20just=20coordinator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch bump. Same UX outcome as 1.5.1 intended — un-cleanable items disappear from the post-clean error count — but the fix is applied inside each producing module's scan() instead of solely at ScanCoordinator, so it covers every per-module Clean flow. --- Sources/MacCleanKit/Constants.swift | 2 +- VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/VERSION b/VERSION index 26ca594..4cda8f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.1 +1.5.2