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
1 change: 1 addition & 0 deletions Sources/MacClean/Modules/Duplicates/DuplicatesModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ public struct MailAttachmentsModule: ScanModule {
guard !items.isEmpty else { return [] }

return [ScanResult(category: .mailAttachments, items: items)]
.filteringUncleanable()
}
}
1 change: 1 addition & 0 deletions Sources/MacClean/Modules/Malware/MalwareModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
2 changes: 1 addition & 1 deletion Sources/MacClean/Modules/Privacy/PrivacyModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
4 changes: 3 additions & 1 deletion Sources/MacClean/Modules/SystemJunk/SystemJunkModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
}
1 change: 1 addition & 0 deletions Sources/MacClean/Modules/TrashBins/TrashBinsModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ public struct TrashBinsModule: ScanModule {
guard !items.isEmpty else { return [] }

return [ScanResult(category: .trashBins, items: items)]
.filteringUncleanable()
}
}
19 changes: 19 additions & 0 deletions Sources/MacCleanKit/CleanFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
}
2 changes: 1 addition & 1 deletion Sources/MacCleanKit/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
32 changes: 32 additions & 0 deletions Tests/MacCleanKitTests/CleanFilterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.5.1
1.5.2
Loading