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
58 changes: 49 additions & 9 deletions Sources/MacClean/Core/Scanner/TargetedScanner.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Darwin
import MacCleanKit

// `ScanTarget` moved to MacCleanKit for testability — see MacCleanKit/ScanTarget.swift.
Expand All @@ -15,27 +16,66 @@ public actor TargetedScanner {
public init() {}

public func scan(targets: [ScanTarget]) async -> [FileItem] {
await scanReportingPermissions(targets: targets).items
}

/// Like `scan(targets:)` but also reports which target roots were
/// unreadable due to a permission/TCC denial. Use this when the caller
/// needs to tell "found nothing" apart from "blocked by missing Full
/// Disk Access" (e.g. the Trash module — see `ScanOutcome`).
///
/// Items are de-duplicated by URL across targets: overlapping targets
/// (e.g. `~` recursive *and* `~/Downloads` recursive) otherwise emit the
/// same file twice, which surfaced as duplicate UI rows and a
/// double-counted size estimate.
public func scanReportingPermissions(targets: [ScanTarget]) async -> ScanOutcome {
let keys = resourceKeys
return await withTaskGroup(of: [FileItem].self) { group in
return await withTaskGroup(of: (items: [FileItem], deniedPath: URL?).self) { group in
for target in targets {
group.addTask {
Self.scanTarget(target, keys: keys)
}
}

var allItems: [FileItem] = []
for await items in group {
allItems.append(contentsOf: items)
var seenURLs = Set<URL>()
var deniedPaths: [URL] = []
for await result in group {
for item in result.items where seenURLs.insert(item.url).inserted {
allItems.append(item)
}
if let denied = result.deniedPath {
deniedPaths.append(denied)
}
}
return allItems
return ScanOutcome(items: allItems, permissionDeniedPaths: deniedPaths)
}
}

private static func scanTarget(_ target: ScanTarget, keys: [URLResourceKey]) -> [FileItem] {
/// True if `url` exists as a directory whose contents can't be read
/// because of a permission/TCC denial (EPERM/EACCES). `open` faithfully
/// reproduces what enumeration would hit — and unlike enumeration, it
/// surfaces the errno instead of silently yielding nothing. O(1): no
/// directory listing. Returns false for readable dirs and for paths
/// that don't exist / aren't directories.
private static func isPermissionDenied(_ url: URL) -> Bool {
let fd = open(url.path(percentEncoded: false), O_RDONLY | O_DIRECTORY)
if fd >= 0 { close(fd); return false }
return errno == EPERM || errno == EACCES
}

private static func scanTarget(_ target: ScanTarget, keys: [URLResourceKey]) -> (items: [FileItem], deniedPath: URL?) {
let fm = FileManager.default

guard fm.fileExists(atPath: target.path.path(percentEncoded: false)) else {
return []
return ([], nil)
}

// The enumerator/`contentsOfDirectory` below swallow EPERM and just
// yield nothing, so probe readability up front to distinguish a
// permission denial from a genuinely empty directory.
if isPermissionDenied(target.path) {
return ([], target.path)
}

var results: [FileItem] = []
Expand All @@ -45,7 +85,7 @@ public actor TargetedScanner {
at: target.path,
includingPropertiesForKeys: keys,
options: [.skipsPackageDescendants]
) else { return [] }
) else { return ([], nil) }

while let obj = enumerator.nextObject() {
if Task.isCancelled { break }
Expand Down Expand Up @@ -81,7 +121,7 @@ public actor TargetedScanner {
guard let contents = try? fm.contentsOfDirectory(
at: target.path,
includingPropertiesForKeys: keys
) else { return [] }
) else { return ([], nil) }

for fileURL in contents {
if Task.isCancelled { break }
Expand All @@ -92,7 +132,7 @@ public actor TargetedScanner {
}
}

return results
return (results, nil)
}

private static func matchesExcludePattern(url: URL, target: ScanTarget) -> Bool {
Expand Down
26 changes: 20 additions & 6 deletions Sources/MacClean/Modules/TrashBins/TrashBinsModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ public struct TrashBinsModule: ScanModule {
public init() {}

public func scan() async -> [ScanResult] {
await scanReportingPermissions().results
}

/// Same scan as `scan()`, but also reports whether the Trash came back
/// empty because Full Disk Access is missing (macOS blocks reading
/// `~/.Trash` without it, and the enumerator silently yields nothing).
/// The Trash view uses this to show a "Grant Full Disk Access" prompt
/// instead of a false "Trash is empty."
public func scanReportingPermissions() async -> (results: [ScanResult], permissionDenied: Bool) {
let outcome = await scanner.scanReportingPermissions(targets: Self.targets())
guard !outcome.items.isEmpty else {
return ([], outcome.permissionDenied)
}
let results = [ScanResult(category: .trashBins, items: outcome.items)]
.filteringUncleanable()
return (results, outcome.permissionDenied)
}

private static func targets() -> [ScanTarget] {
var targets: [ScanTarget] = [
// Main user trash
ScanTarget(path: MCConstants.userTrash, recursive: true),
Expand All @@ -28,11 +47,6 @@ public struct TrashBinsModule: ScanModule {
}
}
}

let items = await scanner.scan(targets: targets)
guard !items.isEmpty else { return [] }

return [ScanResult(category: .trashBins, items: items)]
.filteringUncleanable()
return targets
}
}
14 changes: 10 additions & 4 deletions Sources/MacClean/Views/Cleanup/TrashBinsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct TrashBinsView: View {
@State private var selectedItems: Set<URL> = []
@State private var isScanning = false
@State private var scanComplete = false
@State private var permissionDenied = false
@State private var completion: CleanSummary?
@State private var cleaning: CleaningEngine.Progress?
@State private var cleanTask: Task<Void, Never>?
Expand All @@ -27,10 +28,12 @@ struct TrashBinsView: View {
scanComplete: scanComplete,
completion: completion,
cleaning: cleaning,
permissionDenied: permissionDenied,
onScan: scan,
onClean: clean,
onCancelClean: { cleanTask?.cancel() },
onReset: reset
onReset: reset,
onGrantAccess: { PermissionManager.shared.openFullDiskAccessSettings() }
)
}

Expand All @@ -40,6 +43,7 @@ struct TrashBinsView: View {
private func scan() {
isScanning = true
scanComplete = false
permissionDenied = false
scanProgress = 0
Task {
let scanStart = Date()
Expand All @@ -51,8 +55,10 @@ struct TrashBinsView: View {
scanPhase = "Checking external drives..."
scanProgress = 0.6

async let scanTask = module.scan()
results = await scanTask
async let scanTask = module.scanReportingPermissions()
let outcome = await scanTask
results = outcome.results
permissionDenied = outcome.permissionDenied

scanPhase = "Calculating sizes..."
scanProgress = 0.9
Expand Down Expand Up @@ -97,6 +103,6 @@ struct TrashBinsView: View {
}

private func reset() {
results = []; selectedItems = []; completion = nil; cleaning = nil; cleanTask = nil; scanComplete = false
results = []; selectedItems = []; completion = nil; cleaning = nil; cleanTask = nil; scanComplete = false; permissionDenied = false
}
}
64 changes: 59 additions & 5 deletions Sources/MacClean/Views/Shared/ModuleContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,18 @@ struct ModuleContainerView: View {
/// this window. nil means "no clean in flight" — either before any
/// clean or after one finishes (then `completion` takes over).
let cleaning: CleaningEngine.Progress?
/// True when the scan came back empty because a target couldn't be read
/// (Full Disk Access not granted) rather than because there was nothing
/// to clean. Drives the "Grant Full Disk Access" empty-state instead of
/// the misleading "nothing to clean up" one.
let permissionDenied: Bool
let onScan: () -> Void
let onClean: () -> Void
let onCancelClean: (() -> Void)?
let onReset: () -> Void
/// Opens System Settings → Full Disk Access. Required when
/// `permissionDenied` can be true; the empty-state button calls it.
let onGrantAccess: (() -> Void)?

init(
title: String,
Expand All @@ -39,10 +47,12 @@ struct ModuleContainerView: View {
scanComplete: Bool = false,
completion: CleanSummary? = nil,
cleaning: CleaningEngine.Progress? = nil,
permissionDenied: Bool = false,
onScan: @escaping () -> Void,
onClean: @escaping () -> Void,
onCancelClean: (() -> Void)? = nil,
onReset: @escaping () -> Void
onReset: @escaping () -> Void,
onGrantAccess: (() -> Void)? = nil
) {
self.title = title
self.subtitle = subtitle
Expand All @@ -56,19 +66,22 @@ struct ModuleContainerView: View {
self.scanComplete = scanComplete
self.completion = completion
self.cleaning = cleaning
self.permissionDenied = permissionDenied
self.onScan = onScan
self.onClean = onClean
self.onCancelClean = onCancelClean
self.onReset = onReset
self.onGrantAccess = onGrantAccess
}

@State private var showLargeSelectionConfirm = false
@State private var showActivityLog = false

private var totalSelected: UInt64 {
results.flatMap(\.items)
.filter { selectedItems.contains($0.url) }
.reduce(0) { $0 + $1.size }
// Dedupe by URL: a file can appear in two categories (large + old)
// and Clean trashes it once, so summing per-item would over-report
// versus what actually gets freed.
results.selectedSize(selectedItems)
}

private var selectedCount: Int {
Expand All @@ -90,7 +103,11 @@ struct ModuleContainerView: View {
} else if isScanning {
scanningView
} else if scanComplete {
emptyResultsView
if permissionDenied {
permissionDeniedView
} else {
emptyResultsView
}
} else {
idleView
}
Expand Down Expand Up @@ -219,6 +236,43 @@ struct ModuleContainerView: View {
}
}

/// Shown when the scan came back empty only because macOS blocked the
/// read (Full Disk Access not granted). Without this the user saw
/// "nothing to clean up" over a Trash that was actually full.
private var permissionDeniedView: some View {
VStack(spacing: 18) {
Spacer()
Image(systemName: "lock.fill")
.font(.system(size: 52))
.foregroundStyle(.white.opacity(0.9))
Text("Full Disk Access needed")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
Text("macOS is blocking access to this location. Grant Mac Clean Full Disk Access, then scan again.")
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.7))
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
HStack(spacing: 10) {
if let onGrantAccess {
Button("Open Settings") { onGrantAccess() }
.buttonStyle(.borderedProminent)
.tint(.white)
.controlSize(.large)
}
Button("Rescan") { onScan() }
.buttonStyle(.bordered)
.tint(.white)
.controlSize(.large)
Button("Done") { onReset() }
.buttonStyle(.bordered)
.tint(.white)
.controlSize(.large)
}
Spacer()
}
}

private var resultsView: some View {
VStack(spacing: 0) {
HStack {
Expand Down
18 changes: 18 additions & 0 deletions Sources/MacCleanKit/CleanFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,22 @@ public extension Array where Element == ScanResult {
)
}
}

/// On-disk size of the user's current selection, counting each URL
/// exactly once. The same file can appear in more than one category
/// (a file that's both "large" and "old") — and Clean trashes each
/// path only once — so summing per-item would over-report the estimate
/// versus what actually gets freed. Dedupe by URL to keep the
/// "X will be freed" preview honest.
func selectedSize(_ selected: Set<URL>) -> UInt64 {
var counted = Set<URL>()
var total: UInt64 = 0
for result in self {
for item in result.items
where selected.contains(item.url) && counted.insert(item.url).inserted {
total += item.size
}
}
return total
}
}
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.2"
public static let appVersion = "1.5.3"
}
22 changes: 22 additions & 0 deletions Sources/MacCleanKit/ScanOutcome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// Result of a `TargetedScanner` run that, unlike a bare `[FileItem]`,
/// distinguishes "found nothing" from "couldn't read because access was
/// denied." `~/.Trash` without Full Disk Access enumerates as empty —
/// the EPERM is swallowed by `NSDirectoryEnumerator` — so a plain item
/// count can't tell the two apart. Carrying the denied target paths lets
/// the UI show a "Grant Full Disk Access" prompt instead of a misleading
/// "Trash is empty."
public struct ScanOutcome: Sendable {
public let items: [FileItem]
/// Target roots that exist but whose contents this process is not
/// permitted to read (EPERM/EACCES — TCC denial or POSIX permissions).
public let permissionDeniedPaths: [URL]

public init(items: [FileItem], permissionDeniedPaths: [URL] = []) {
self.items = items
self.permissionDeniedPaths = permissionDeniedPaths
}

public var permissionDenied: Bool { !permissionDeniedPaths.isEmpty }
}
Loading
Loading