diff --git a/CITATION.cff b/CITATION.cff
index 4f53a0d..73869ce 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -5,8 +5,8 @@ message: >-
(non-commercial) in LICENSE. Commercial use requires a separate license.
type: software
title: MacOS Cleaner
-version: "2.1.1"
-date-released: "2026-08-12"
+version: "2.2.0"
+date-released: "2026-08-26"
url: "https://github.com/AlexTkDev/MacOSCleaner"
repository-code: "https://github.com/AlexTkDev/MacOSCleaner"
# SPDX has no Commons Clause compound; see LICENSE for the binding terms.
diff --git a/DONATE.md b/DONATE.md
index 9023493..e7c53e4 100644
--- a/DONATE.md
+++ b/DONATE.md
@@ -1,8 +1,13 @@
# Support My Work
+Thank you for supporting my projects!
-Thank you for supporting my projects.
+## ☕ Ko-fi
+Support via Card, Apple Pay, or PayPal:
+**[https://ko-fi.com/alextkdev](https://ko-fi.com/alextkdev)**
-## Recommended
+---
+
+## 💎 Cryptocurrency
### USDT (TRC20)
TAQBrzZuAvJ5Zga7touVzNGXXJykEVp7sp
diff --git a/MacOSCleaner/App/MacOSCleanerApp.swift b/MacOSCleaner/App/MacOSCleanerApp.swift
index 15f11b2..3ea79ba 100644
--- a/MacOSCleaner/App/MacOSCleanerApp.swift
+++ b/MacOSCleaner/App/MacOSCleanerApp.swift
@@ -120,6 +120,12 @@ struct MacOSCleanerApp: App {
}
}
.disabled(isCheckingForUpdates)
+
+ Button("menu_donate".localized) {
+ if let url = URL(string: "https://github.com/AlexTkDev/MacOSCleaner/blob/main/DONATE.md") {
+ NSWorkspace.shared.open(url)
+ }
+ }
}
}
diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift b/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift
index aec7067..ca8d594 100644
--- a/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift
+++ b/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift
@@ -142,6 +142,9 @@ extension CleanupCategory {
labels.insert("Duplicate Files")
case .unusedApps:
labels.insert("Unused Apps")
+ case .projectBuildArtifacts:
+ labels.insert("Project build artifacts")
+ labels.insert("Project-local build artifacts")
}
return labels
@@ -220,6 +223,7 @@ extension CleanupCategory {
case "itunes_backup_scanner": return .iosBackups
case "spotlight_index_scanner": return .systemCaches
case "swap_files_scanner": return .systemCaches
+ case "project_build_artifacts_scanner": return .projectBuildArtifacts
default: return nil
}
}
diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift
index 4d2468c..948bf67 100644
--- a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift
+++ b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift
@@ -146,7 +146,7 @@ public final class CleanupCoordinator: @unchecked Sendable {
let categories = self.itemManager.selectedCleanupCategories(from: options.categories())
// Review-only categories — never run category-level wipe.
let safeCategories = categories.filter {
- $0 != .oldBackups && $0 != .aiModels && $0 != .installerPackages && $0 != .largeFiles
+ $0 != .oldBackups && $0 != .aiModels && $0 != .installerPackages && $0 != .largeFiles && $0 != .projectBuildArtifacts
}
var records: [OperationRecord] = []
var hadPartialFailure = false
@@ -210,6 +210,7 @@ public final class CleanupCoordinator: @unchecked Sendable {
(.aiModels, "AI Models"),
(.installerPackages, "Installer Packages"),
(.largeFiles, "Large files"),
+ (.projectBuildArtifacts, "Project build artifacts"),
]
for (category, logLabel) in reviewGroups {
let selectedURLs = self.selectedReviewLeafURLs(for: category)
@@ -357,7 +358,7 @@ public final class CleanupCoordinator: @unchecked Sendable {
@MainActor
private func deselectReviewOnlyGroups() {
- for category in [CleanupCategory.oldBackups, .aiModels, .installerPackages, .largeFiles] {
+ for category in [CleanupCategory.oldBackups, .aiModels, .installerPackages, .largeFiles, .projectBuildArtifacts] {
for label in category.previewLabels {
itemManager.setSelection(underParentLabel: label, isSelected: false)
}
diff --git a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift
index 71885bd..00b20f7 100644
--- a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift
+++ b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift
@@ -144,6 +144,7 @@ public enum CleanupCategory: String, CaseIterable, Sendable {
case sleepImage = "sleep_image"
case duplicateFiles = "duplicate_files"
case unusedApps = "unused_apps"
+ case projectBuildArtifacts = "project_build_artifacts"
}
// MARK: - CleanupEngine Actor
@@ -350,6 +351,7 @@ public actor CleanupEngine {
case .sleepImage: return try await cleanSleepImage(dryRun: dryRun, progress: progress)
case .duplicateFiles: return try await cleanDuplicateFiles(dryRun: dryRun, progress: progress)
case .unusedApps: return try await cleanUnusedApps(dryRun: dryRun, progress: progress)
+ case .projectBuildArtifacts: return try await cleanProjectBuildArtifacts(dryRun: dryRun, progress: progress, olderThanDays: options.projectArtifactsOlderThanDays)
}
}
@@ -454,6 +456,10 @@ public struct CleanupOptions: Sendable, Equatable {
public var cleanModCache: Bool = true
/// When true, deep cleans project artifacts (.dart_tool directories).
public var cleanProjects: Bool = true
+ /// When true, cleans project-local build artifacts. Default is false (review-only).
+ public var cleanProjectArtifacts: Bool = false
+ /// Project build artifacts older than this many days will be cleaned (default: 60).
+ public var projectArtifactsOlderThanDays: Int = 60
/// Xcode Archives older than this many days will be cleaned.
public var xcodeArchivesOlderThanDays: Int = 90
/// When true, cleans CloudDocs (iCloud document cache).
@@ -469,11 +475,27 @@ public struct CleanupOptions: Sendable, Equatable {
/// When true, cleans Time Machine local snapshots.
public var cleanTimeMachineSnapshots: Bool = false
- public init(cleanDSStore: Bool = false, cleanMaven: Bool = true, cleanModCache: Bool = true, cleanProjects: Bool = true, xcodeArchivesOlderThanDays: Int = 90, cleanCloudDocs: Bool = false, cleanVoiceMemos: Bool = false, cleanGarageBandLogic: Bool = false, cleanIMovieFinalCut: Bool = false, cleanSleepImage: Bool = false, cleanTimeMachineSnapshots: Bool = false) {
+ public init(
+ cleanDSStore: Bool = false,
+ cleanMaven: Bool = true,
+ cleanModCache: Bool = true,
+ cleanProjects: Bool = true,
+ cleanProjectArtifacts: Bool = false,
+ projectArtifactsOlderThanDays: Int = 60,
+ xcodeArchivesOlderThanDays: Int = 90,
+ cleanCloudDocs: Bool = false,
+ cleanVoiceMemos: Bool = false,
+ cleanGarageBandLogic: Bool = false,
+ cleanIMovieFinalCut: Bool = false,
+ cleanSleepImage: Bool = false,
+ cleanTimeMachineSnapshots: Bool = false
+ ) {
self.cleanDSStore = cleanDSStore
self.cleanMaven = cleanMaven
self.cleanModCache = cleanModCache
self.cleanProjects = cleanProjects
+ self.cleanProjectArtifacts = cleanProjectArtifacts
+ self.projectArtifactsOlderThanDays = projectArtifactsOlderThanDays
self.xcodeArchivesOlderThanDays = xcodeArchivesOlderThanDays
self.cleanCloudDocs = cleanCloudDocs
self.cleanVoiceMemos = cleanVoiceMemos
@@ -494,7 +516,7 @@ public struct CleanupOptions: Sendable, Equatable {
/// per-item ownership proofs and explicit user selection:
/// orphaned remnants/files, old backups, AI/LLM user_content, installer packages,
/// large-file review items, launch agents/daemons,
- /// privileged helpers, package receipts, internet plugins.
+ /// privileged helpers, package receipts, internet plugins, project build artifacts.
public func categories() -> [CleanupCategory] {
var categories: [CleanupCategory] = [
.appCaches,
@@ -544,6 +566,9 @@ public struct CleanupOptions: Sendable, Equatable {
if cleanCloudDocs {
categories.append(.cloudDocs)
}
+ if cleanProjectArtifacts {
+ categories.append(.projectBuildArtifacts)
+ }
if cleanVoiceMemos {
categories.append(.voiceMemos)
}
@@ -1070,23 +1095,43 @@ extension CleanupEngine {
freed += af
if dryRun { emitFileItem(ai, category: "Xcode", parentName: nil, progress: progress) }
- // Project-local build artifacts (DerivedData, build/ inside project repos)
- let projectLocalFreed = try await cleanProjectLocalBuildArtifacts(home: home, dryRun: dryRun, progress: progress)
- freed += projectLocalFreed
-
let mb = Int(freed / (1024 * 1024))
progress?(.log("Xcode total: \(Self.formatBytes(freed))"))
progress?(.result(label: "Xcode cleanup", freedMB: mb))
return [CleanupEngineResult(label: "Xcode", freedMB: mb)]
}
+ // MARK: - Project Build Artifacts
+
+ func cleanProjectBuildArtifacts(
+ dryRun: Bool,
+ progress: (@Sendable (CleanupEngineEvent) -> Void)?,
+ olderThanDays: Int = 60
+ ) async throws -> [CleanupEngineResult] {
+ let home = fileSystemContext.homePath
+ progress?(.log("Scanning project build artifacts (older than \(olderThanDays) days)..."))
+ let freed = try await cleanProjectLocalBuildArtifacts(
+ home: home,
+ dryRun: dryRun,
+ progress: progress,
+ olderThanDays: olderThanDays
+ )
+ let mb = Int(freed / (1024 * 1024))
+ progress?(.log("Project build artifacts total: \(Self.formatBytes(freed))"))
+ progress?(.result(label: "Project build artifacts", freedMB: mb))
+ return [CleanupEngineResult(label: "Project build artifacts", freedMB: mb)]
+ }
+
/// Scans common developer directories for project-local build artifacts.
+ /// Filters by mtime of the artifact directory (older than olderThanDays).
/// All targets are 100% regenerable by their respective build tools.
private func cleanProjectLocalBuildArtifacts(
home: String,
dryRun: Bool,
- progress: (@Sendable (CleanupEngineEvent) -> Void)?
+ progress: (@Sendable (CleanupEngineEvent) -> Void)?,
+ olderThanDays: Int = 60
) async throws -> Int64 {
+ let cutoffDate = Calendar.current.date(byAdding: .day, value: -olderThanDays, to: Date()) ?? Date.distantPast
let searchRoots = [
"\(home)/Documents",
"\(home)/Developer",
@@ -1188,11 +1233,17 @@ extension CleanupEngine {
// Check if this is an always-removable artifact
if alwaysRemovable.contains(name) {
enumerator.skipDescendants()
+ if let attrs = try? fm.attributesOfItem(atPath: url.path),
+ let modDate = attrs[.modificationDate] as? Date,
+ modDate > cutoffDate {
+ // Modified recently (< olderThanDays), keep it
+ continue
+ }
do {
let (f, item) = try await removeDirectory(url.path, dryRun: dryRun, progress: progress)
freed += f
foundCount += 1
- if dryRun { emitFileItem(item, category: "Xcode", parentName: "Project build artifacts", progress: progress) }
+ if dryRun { emitFileItem(item, category: "Project build artifacts", parentName: "Project build artifacts", progress: progress) }
} catch is SafetyError {
progress?(.log(" \(shortPath(url.path)) — protected, skipped"))
}
@@ -1221,11 +1272,17 @@ extension CleanupEngine {
guard matched else { continue }
enumerator.skipDescendants()
+ if let attrs = try? fm.attributesOfItem(atPath: url.path),
+ let modDate = attrs[.modificationDate] as? Date,
+ modDate > cutoffDate {
+ // Modified recently (< olderThanDays), keep it
+ continue
+ }
do {
let (f, item) = try await removeDirectory(url.path, dryRun: dryRun, progress: progress)
freed += f
foundCount += 1
- if dryRun { emitFileItem(item, category: "Xcode", parentName: "Project build artifacts", progress: progress) }
+ if dryRun { emitFileItem(item, category: "Project build artifacts", parentName: "Project build artifacts", progress: progress) }
} catch is SafetyError {
progress?(.log(" \(shortPath(url.path)) — protected, skipped"))
}
@@ -2183,44 +2240,6 @@ extension CleanupEngine {
progress?(.log(" Checked \(scannedCount) files in \(downloadDir.replacingOccurrences(of: home, with: "~"))"))
}
- // node_modules directories > 100MB (recursive search)
- progress?(.log(" Scanning home directory for node_modules > 100 MB..."))
- let searchDirs = [home]
- for baseDir in searchDirs {
- guard fm.fileExists(atPath: baseDir) else { continue }
- guard let enumerator = fm.enumerator(atPath: baseDir) else { continue }
- while let item = enumerator.nextObject() as? String {
- try Task.checkCancellation()
- let fullPath = "\(baseDir)/\(item)"
- // Skip hidden dirs, Library
- if item.hasPrefix(".") || fullPath.contains("/Library/") {
- enumerator.skipDescendants()
- continue
- }
- // Skip heavy directories (Docker, VMs, etc.) — only for actual directories
- if Self.isHeavyDirectory(fullPath) {
- var isDir: ObjCBool = false
- fm.fileExists(atPath: fullPath, isDirectory: &isDir)
- if isDir.boolValue {
- progress?(.log(" Skipping heavy directory: \(shortPath(fullPath))"))
- enumerator.skipDescendants()
- }
- continue
- }
- if item == "node_modules" {
- let size = await getDirectorySizeWithTimeout(fullPath, timeout: .seconds(10))
- if size > 100 * 1024 * 1024 {
- items.append(("\(fullPath.replacingOccurrences(of: home, with: "~"))", size))
- totalFound += size
- if dryRun {
- emitFileItem(CleanupFileItem(path: fullPath, sizeBytes: size, modificationDate: nil, isDirectory: true), category: "Large files", parentName: "Large files", progress: progress)
- }
- }
- enumerator.skipDescendants()
- }
- }
- }
-
// IPSW firmware files
progress?(.log(" Scanning for IPSW firmware files..."))
let ipswSearchDirs = [home, "/tmp"]
@@ -2369,32 +2388,41 @@ extension CleanupEngine {
}
let snapshots = await TimeMachineScanner.listLocalSnapshots()
+ let purgeableMB = TimeMachineScanner.getPurgeableSpaceMB()
- progress?(.log(" Found \(snapshots.count) local snapshots"))
+ progress?(.log(" Found \(snapshots.count) local snapshots (purgeable: ~\(purgeableMB) MB)"))
if dryRun {
for snap in snapshots {
progress?(.log(" ⊘ \(snap.name)"))
}
+ progress?(.result(label: "Time Machine Snapshots", freedMB: purgeableMB))
+ return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: purgeableMB)]
+ }
+
+ if snapshots.isEmpty {
+ progress?(.log(" No local snapshots to thin"))
progress?(.result(label: "Time Machine Snapshots", freedMB: 0))
return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: 0)]
}
- var deleted = 0
- for snap in snapshots {
+ let availableBefore = (try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeAvailableCapacityKey]))?.volumeAvailableCapacity ?? 0
+ let purgeBytes = max(10_000_000_000, Int64(purgeableMB) * 1024 * 1024)
+
+ do {
try Task.checkCancellation()
- do {
- _ = try await PrivilegedTaskRunner.runAsAdmin(command: "/usr/bin/tmutil deletelocalsnapshots \(snap.name)")
- deleted += 1
- progress?(.log(" ✓ Deleted \(snap.name)"))
- } catch {
- progress?(.log(" ✗ Failed to delete \(snap.name): \(error.localizedDescription)"))
- }
+ _ = try await PrivilegedTaskRunner.runAsAdmin(command: "/usr/bin/tmutil thinlocalsnapshots / \(purgeBytes) 4")
+ let availableAfter = (try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeAvailableCapacityKey]))?.volumeAvailableCapacity ?? 0
+ let freedBytes = max(0, availableAfter - availableBefore)
+ let freedMB = Int(freedBytes / (1024 * 1024))
+ progress?(.log(" ✓ Thinned local snapshots (freed ~\(freedMB) MB)"))
+ progress?(.result(label: "Time Machine Snapshots", freedMB: freedMB))
+ return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: freedMB, freedBytes: Int64(freedBytes))]
+ } catch {
+ progress?(.log(" ✗ Failed to thin snapshots: \(error.localizedDescription)"))
+ progress?(.result(label: "Time Machine Snapshots", freedMB: 0))
+ return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: 0)]
}
-
- progress?(.log(" Deleted \(deleted) snapshots"))
- progress?(.result(label: "Time Machine Snapshots", freedMB: 0))
- return [CleanupEngineResult(label: "Time Machine Snapshots", freedMB: 0)]
}
// MARK: 24. iOS Backups
@@ -3086,15 +3114,16 @@ extension CleanupEngine {
func cleanDNSFlush(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] {
progress?(.log("Flushing DNS cache..."))
if dryRun {
- progress?(.log(" Would run: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder"))
+ progress?(.log(" Would run: dscacheutil -flushcache && killall -HUP mDNSResponder"))
progress?(.result(label: "DNS Cache", freedMB: 0))
return [CleanupEngineResult(label: "DNS Cache", freedMB: 0)]
}
- let result = try? await commandRunner.run(command: "/bin/bash", arguments: ["-c", "sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder"])
- if result?.exitCode == 0 {
- progress?(.log(" DNS cache flushed successfully"))
- } else {
- progress?(.log(" DNS cache flush failed (may need sudo without password)"))
+ do {
+ try Task.checkCancellation()
+ _ = try await PrivilegedTaskRunner.runAsAdmin(command: "/usr/bin/dscacheutil -flushcache; /usr/bin/killall -HUP mDNSResponder")
+ progress?(.log(" ✓ DNS cache flushed successfully"))
+ } catch {
+ progress?(.log(" ✗ DNS cache flush failed: \(error.localizedDescription)"))
}
progress?(.result(label: "DNS Cache", freedMB: 0))
return [CleanupEngineResult(label: "DNS Cache", freedMB: 0)]
diff --git a/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift
index f6e9158..c6cb546 100644
--- a/MacOSCleaner/Features/About/AboutView.swift
+++ b/MacOSCleaner/Features/About/AboutView.swift
@@ -130,6 +130,15 @@ struct AboutView: View {
private var linksCard: some View {
VStack(spacing: 0) {
+ Link(destination: URL(string: "https://github.com/AlexTkDev/MacOSCleaner/blob/main/DONATE.md")!) {
+ Label("about_donate".localized, systemImage: "heart.fill")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.pink)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .contentShape(Rectangle())
+ }
+ Divider().padding(.leading, 38)
Link(destination: URL(string: "https://github.com/AlexTkDev/MacOSCleaner")!) {
Label("about_star_github".localized, systemImage: "star.fill")
.font(.subheadline.weight(.semibold))
@@ -169,5 +178,5 @@ struct AboutView: View {
}
#Preview {
- AboutView(availableUpdate: AvailableUpdate(version: "2.1.1", dmgURL: nil))
+ AboutView(availableUpdate: AvailableUpdate(version: "2.2.0", dmgURL: nil))
}
diff --git a/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift b/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift
index 094b7dc..20e33af 100644
--- a/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift
+++ b/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift
@@ -51,12 +51,16 @@ public final class CleanupViewModel {
@MainActor
public func startScan() {
- coordinator.startScan(options: options)
+ var scanOptions = options
+ scanOptions.projectArtifactsOlderThanDays = settings.projectArtifactsOlderThanDays
+ coordinator.startScan(options: scanOptions)
}
@MainActor
public func executeCleanup() {
- coordinator.executeCleanup(options: options)
+ var cleanupOptions = options
+ cleanupOptions.projectArtifactsOlderThanDays = settings.projectArtifactsOlderThanDays
+ coordinator.executeCleanup(options: cleanupOptions)
}
@MainActor
diff --git a/MacOSCleaner/Features/Dashboard/DashboardView.swift b/MacOSCleaner/Features/Dashboard/DashboardView.swift
index f033b51..c48e0b8 100644
--- a/MacOSCleaner/Features/Dashboard/DashboardView.swift
+++ b/MacOSCleaner/Features/Dashboard/DashboardView.swift
@@ -105,7 +105,8 @@ struct DashboardView: View {
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 40)
- .glassCard()
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.6))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
} else {
VStack(spacing: 0) {
ForEach(viewModel.recentTransactions) { transaction in
@@ -115,7 +116,8 @@ struct DashboardView: View {
}
}
}
- .glassCard()
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.6))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift
index 49d4776..3adb6cf 100644
--- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift
+++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import QuickLook
public struct DiskAnalyzerView: View {
let settings: AppSettings
@@ -9,20 +10,23 @@ public struct DiskAnalyzerView: View {
}
public var body: some View {
- GlassEffectContainer {
- VStack(spacing: 16) {
- headerControlsView
-
- if viewModel.isScanning {
- scanningView
- } else if viewModel.filteredItems.isEmpty {
- emptyView
- } else {
- itemsListView
- }
+ VStack(spacing: 12) {
+ headerControlsView
+
+ if !viewModel.isScanning && viewModel.currentItem != nil {
+ breadcrumbsView
+ }
+
+ if viewModel.isScanning {
+ scanningView
+ } else if viewModel.displayedItems.isEmpty {
+ emptyView
+ } else {
+ itemsListView
}
- .padding()
}
+ .padding(16)
+ .quickLookPreview($viewModel.quickLookURL)
.onAppear {
if viewModel.rootURL == nil {
viewModel.startScan(for: FileManager.default.homeDirectoryForCurrentUser)
@@ -32,7 +36,7 @@ public struct DiskAnalyzerView: View {
private var headerControlsView: some View {
HStack(spacing: 12) {
- // Folder Selector Menu (matching DuplicatesView)
+ // Folder Selector Menu
Menu {
Button(action: { viewModel.startScan(for: FileManager.default.homeDirectoryForCurrentUser) }) {
Label("duplicate_folder_home".localized, systemImage: "house")
@@ -61,10 +65,32 @@ public struct DiskAnalyzerView: View {
Spacer()
+ // Search bar
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass")
+ .foregroundColor(.secondary)
+ .font(.caption)
+ TextField("settings_search_prompt".localized, text: $viewModel.searchQuery)
+ .textFieldStyle(.plain)
+ .frame(width: 140)
+ if !viewModel.searchQuery.isEmpty {
+ Button(action: { viewModel.searchQuery = "" }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.secondary)
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color.primary.opacity(0.05))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+
// Scan Action Button
Button(action: { viewModel.selectFolderAndScan() }) {
HStack(spacing: 6) {
- Image(systemName: "folder.badge.plus")
+ Image(systemName: "arrow.clockwise")
Text("disk_analyzer_scan".localized)
}
}
@@ -80,6 +106,84 @@ public struct DiskAnalyzerView: View {
)
}
+ private var breadcrumbsView: some View {
+ HStack(spacing: 8) {
+ Button(action: {
+ withAnimation(.easeInOut(duration: 0.15)) {
+ viewModel.navigateUp()
+ }
+ }) {
+ Image(systemName: "chevron.left")
+ .font(.body.weight(.semibold))
+ }
+ .buttonStyle(.plain)
+ .disabled(!viewModel.canNavigateUp)
+ .opacity(viewModel.canNavigateUp ? 1.0 : 0.3)
+ .help("disk_analyzer_back".localized)
+
+ Divider()
+ .frame(height: 16)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 4) {
+ ForEach(Array(viewModel.pathTrail.enumerated()), id: \.element.id) { index, item in
+ let isLast = index == viewModel.pathTrail.count - 1
+ Button(action: {
+ withAnimation(.easeInOut(duration: 0.15)) {
+ viewModel.navigateTo(item: item)
+ }
+ }) {
+ HStack(spacing: 4) {
+ if index == 0 {
+ Image(systemName: "house.fill")
+ .font(.caption)
+ }
+ Text(item.name.isEmpty ? "/" : item.name)
+ .font(.callout.weight(isLast ? .semibold : .regular))
+ .foregroundColor(isLast ? .primary : .secondary)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ .background(isLast ? Color.primary.opacity(0.08) : Color.clear)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ }
+ .buttonStyle(.plain)
+
+ if !isLast {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundColor(.secondary.opacity(0.6))
+ }
+ }
+ }
+ }
+
+ Spacer()
+
+ if let current = viewModel.currentItem {
+ HStack(spacing: 8) {
+ let count = viewModel.selectedCategory == .all ? current.fileCount : viewModel.displayedItems.count
+ Text(String(format: "disk_analyzer_items_count".localized, count))
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ let totalSize = viewModel.selectedCategory == .all ? current.size : viewModel.displayedItems.reduce(0) { $0 + $1.size }
+ Text(totalSize.formattedByteCount())
+ .font(.caption.monospaced().weight(.semibold))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(Color.accentColor.opacity(0.12))
+ .foregroundColor(.accentColor)
+ .clipShape(Capsule())
+ }
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .background(Color.primary.opacity(0.03))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+
private var scanningView: some View {
VStack(spacing: 20) {
Spacer()
@@ -105,17 +209,16 @@ public struct DiskAnalyzerView: View {
VStack(spacing: 12) {
Spacer()
Image(systemName: "folder.badge.questionmark")
- .font(.system(size: 48))
- .foregroundColor(.secondary)
-
- if viewModel.items.isEmpty {
- Text("disk_analyzer_empty".localized)
- .font(.headline)
- .foregroundColor(.secondary)
- } else {
- Text(String(format: "disk_analyzer_category_empty".localized, viewModel.selectedCategory.localizedName))
- .font(.headline)
- .foregroundColor(.secondary)
+ if viewModel.displayedItems.isEmpty {
+ if viewModel.selectedCategory != .all {
+ Text(String(format: "disk_analyzer_category_empty".localized, viewModel.selectedCategory.localizedName))
+ .font(.headline)
+ .foregroundColor(.secondary)
+ } else {
+ Text("disk_analyzer_empty".localized)
+ .font(.headline)
+ .foregroundColor(.secondary)
+ }
}
Spacer()
}
@@ -124,25 +227,53 @@ public struct DiskAnalyzerView: View {
private var itemsListView: some View {
ScrollView {
- LazyVStack(spacing: 1) {
- ForEach(viewModel.filteredItems) { item in
- DiskItemRow(item: item, settings: settings, onShowInFinder: {
- viewModel.showInFinder(item: item)
- }, onDelete: {
- viewModel.moveToTrash(item: item)
- })
+ LazyVStack(spacing: 2) {
+ ForEach(viewModel.displayedItems) { item in
+ let isSelected = viewModel.selectedItem?.id == item.id
+ DiskItemRow(
+ item: item,
+ isSelected: isSelected,
+ settings: settings,
+ onDrillDown: {
+ withAnimation(.easeInOut(duration: 0.15)) {
+ viewModel.drillDown(into: item)
+ }
+ },
+ onQuickLook: {
+ viewModel.toggleQuickLook(for: item)
+ },
+ onShowInFinder: {
+ viewModel.showInFinder(item: item)
+ },
+ onDelete: {
+ viewModel.moveToTrash(item: item)
+ }
+ )
+ .onTapGesture {
+ if item.isDirectory && !item.isPackage {
+ withAnimation(.easeInOut(duration: 0.15)) {
+ viewModel.drillDown(into: item)
+ }
+ } else {
+ viewModel.selectedItem = item
+ }
+ }
}
}
- .padding(.horizontal, 4)
+ .padding(4)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .glassCard(cornerRadius: 12)
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
}
}
struct DiskItemRow: View {
let item: DiskItem
+ let isSelected: Bool
let settings: AppSettings
+ let onDrillDown: () -> Void
+ let onQuickLook: () -> Void
let onShowInFinder: () -> Void
let onDelete: () -> Void
@@ -155,20 +286,28 @@ struct DiskItemRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
- HStack(spacing: 12) {
+ HStack(spacing: 10) {
Image(systemName: iconName)
.font(.title3)
.foregroundColor(iconColor)
.frame(width: 24, height: 24)
- VStack(alignment: .leading, spacing: 4) {
- Text(item.name)
- .font(.body)
- .lineLimit(1)
- .truncationMode(.middle)
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Text(item.name)
+ .font(.body.weight(item.isDirectory && !item.isPackage ? .medium : .regular))
+ .lineLimit(1)
+ .truncationMode(.middle)
+
+ if item.isDirectory && !item.isPackage {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundColor(.secondary.opacity(0.7))
+ }
+ }
- if item.isDirectory {
- Text("folder".localized)
+ if item.isDirectory && !item.isPackage {
+ Text(String(format: "disk_analyzer_items_count".localized, item.fileCount))
.font(.caption2)
.foregroundColor(.secondary)
} else {
@@ -201,10 +340,26 @@ struct DiskItemRow: View {
Text(item.size.formattedByteCount())
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
- .padding(.trailing, 8)
+ .padding(.trailing, 4)
- if isHovered {
+ if isHovered || isSelected {
HStack(spacing: 4) {
+ if !item.isDirectory || item.isPackage {
+ Button(action: onQuickLook) {
+ Image(systemName: "eye")
+ }
+ .buttonStyle(.plain)
+ .help("disk_analyzer_quick_look".localized)
+ }
+
+ if item.isDirectory && !item.isPackage {
+ Button(action: onDrillDown) {
+ Image(systemName: "folder")
+ }
+ .buttonStyle(.plain)
+ .help("disk_analyzer_open_folder".localized)
+ }
+
Button(action: onShowInFinder) {
Image(systemName: "magnifyingglass")
}
@@ -223,12 +378,12 @@ struct DiskItemRow: View {
.transition(.opacity)
}
}
- .padding(.vertical, 8)
- .padding(.horizontal, 12)
+ .padding(.vertical, 7)
+ .padding(.horizontal, 10)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6)
- .fill(isHovered ? Color.secondary.opacity(0.1) : Color.clear)
+ .fill(isSelected ? Color.accentColor.opacity(0.18) : (isHovered ? Color.primary.opacity(0.05) : Color.clear))
)
.onHover { hover in
withAnimation(.easeInOut(duration: 0.1)) {
@@ -308,7 +463,7 @@ struct DiskItemRow: View {
}
private var iconName: String {
- if item.isDirectory {
+ if item.isDirectory && !item.isPackage {
return "folder.fill"
}
switch item.fileType {
@@ -323,7 +478,7 @@ struct DiskItemRow: View {
}
private var iconColor: Color {
- if item.isDirectory {
+ if item.isDirectory && !item.isPackage {
return .blue
}
switch item.fileType {
@@ -337,3 +492,4 @@ struct DiskItemRow: View {
}
}
}
+
diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift
index 4cff327..a2394f4 100644
--- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift
+++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift
@@ -13,8 +13,11 @@ public final class DiskAnalyzerViewModel {
public var isScanning = false
public var currentScanningName = ""
public var rootURL: URL?
- public var currentURL: URL?
- public var items: [DiskItem] = []
+ public var rootItem: DiskItem?
+ public var pathTrail: [DiskItem] = []
+
+ public var selectedItem: DiskItem?
+ public var quickLookURL: URL?
public var selectedCategory: FileCategory = .all
public var searchQuery: String = ""
@@ -22,14 +25,33 @@ public final class DiskAnalyzerViewModel {
public init() {}
- public var filteredItems: [DiskItem] {
- items.filter { item in
- let matchesCategory = selectedCategory == .all || item.fileType == selectedCategory
- let matchesSearch = searchQuery.isEmpty || item.name.localizedCaseInsensitiveContains(searchQuery) || item.url.path.localizedCaseInsensitiveContains(searchQuery)
- return matchesCategory && matchesSearch
+ public var currentItem: DiskItem? {
+ pathTrail.last ?? rootItem
+ }
+
+ public var displayedItems: [DiskItem] {
+ guard let current = currentItem else { return [] }
+ let baseItems: [DiskItem]
+ if selectedCategory == .all {
+ baseItems = current.children ?? []
+ } else {
+ baseItems = current.allDescendantFiles(matching: selectedCategory)
+ }
+
+ if searchQuery.isEmpty {
+ return baseItems
+ }
+
+ return baseItems.filter { item in
+ item.name.localizedCaseInsensitiveContains(searchQuery) ||
+ item.url.path.localizedCaseInsensitiveContains(searchQuery)
}
}
+ public var canNavigateUp: Bool {
+ pathTrail.count > 1
+ }
+
public func selectFolderAndScan() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
@@ -48,21 +70,24 @@ public final class DiskAnalyzerViewModel {
scanTask?.cancel()
rootURL = url
- currentURL = url
isScanning = true
currentScanningName = ""
- items = []
+ rootItem = nil
+ pathTrail = []
+ selectedItem = nil
+ quickLookURL = nil
scanTask = Task {
do {
- let scannedItems = try await scanner.scan(directoryURL: url) { [weak self] folderName in
+ let scannedRoot = try await scanner.scan(directoryURL: url) { [weak self] folderName in
guard let self else { return }
Task { @MainActor in
self.currentScanningName = folderName
}
}
- self.items = scannedItems
+ self.rootItem = scannedRoot
+ self.pathTrail = [scannedRoot]
self.isScanning = false
} catch {
self.logger.error("Scan failed: \(error.localizedDescription)")
@@ -70,15 +95,45 @@ public final class DiskAnalyzerViewModel {
}
}
}
-
+
+ public func drillDown(into item: DiskItem) {
+ guard item.isDirectory && !item.isPackage else { return }
+ pathTrail.append(item)
+ selectedItem = nil
+ }
+
+ public func navigateUp() {
+ guard pathTrail.count > 1 else { return }
+ pathTrail.removeLast()
+ selectedItem = nil
+ }
+
+ public func navigateTo(item: DiskItem) {
+ guard let index = pathTrail.firstIndex(where: { $0.url == item.url }) else { return }
+ pathTrail = Array(pathTrail.prefix(through: index))
+ selectedItem = nil
+ }
+
+ public func toggleQuickLook(for item: DiskItem? = nil) {
+ let target = item ?? selectedItem
+ if let target {
+ if quickLookURL == target.url {
+ quickLookURL = nil
+ } else {
+ quickLookURL = target.url
+ }
+ } else {
+ quickLookURL = nil
+ }
+ }
public func moveToTrash(item: DiskItem) {
Task {
do {
_ = try await trashManager.trashItem(at: item.url)
- // Remove from local list
- self.items.removeAll { $0.url == item.url }
+ // Remove item from currentItem's children in memory
+ removeItemFromTree(item: item)
} catch {
self.logger.error("Failed to move item to trash: \(error.localizedDescription)")
}
@@ -88,4 +143,38 @@ public final class DiskAnalyzerViewModel {
public func showInFinder(item: DiskItem) {
NSWorkspace.shared.selectFile(item.url.path, inFileViewerRootedAtPath: "")
}
+
+ private func removeItemFromTree(item: DiskItem) {
+ guard var curr = pathTrail.last else { return }
+ let freedSize = item.size
+ let freedFiles = item.isDirectory ? item.fileCount : 1
+
+ curr.children?.removeAll { $0.url == item.url }
+ curr.size = max(0, curr.size - freedSize)
+ curr.fileCount = max(0, curr.fileCount - freedFiles)
+ pathTrail[pathTrail.count - 1] = curr
+
+ // Propagate size reduction up the trail
+ for i in (0..<(pathTrail.count - 1)).reversed() {
+ var ancestor = pathTrail[i]
+ ancestor.size = max(0, ancestor.size - freedSize)
+ ancestor.fileCount = max(0, ancestor.fileCount - freedFiles)
+ if let childIndex = ancestor.children?.firstIndex(where: { $0.url == pathTrail[i + 1].url }) {
+ ancestor.children?[childIndex] = pathTrail[i + 1]
+ }
+ pathTrail[i] = ancestor
+ }
+
+ if let first = pathTrail.first {
+ rootItem = first
+ }
+
+ if selectedItem?.url == item.url {
+ selectedItem = nil
+ }
+ if quickLookURL == item.url {
+ quickLookURL = nil
+ }
+ }
}
+
diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift
index dd51501..2c7c3cf 100644
--- a/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift
+++ b/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift
@@ -47,17 +47,54 @@ public struct DiskItem: Identifiable, Hashable, Sendable {
public let url: URL
public let name: String
public let isDirectory: Bool
+ public let isPackage: Bool
public var size: Int64
+ public var fileCount: Int
public var children: [DiskItem]?
public let fileType: FileCategory
+ public let parentURL: URL?
- public init(id: UUID = UUID(), url: URL, name: String, isDirectory: Bool, size: Int64, children: [DiskItem]? = nil, fileType: FileCategory) {
+ public init(
+ id: UUID = UUID(),
+ url: URL,
+ name: String,
+ isDirectory: Bool,
+ isPackage: Bool = false,
+ size: Int64,
+ fileCount: Int = 0,
+ children: [DiskItem]? = nil,
+ fileType: FileCategory,
+ parentURL: URL? = nil
+ ) {
self.id = id
self.url = url
self.name = name
self.isDirectory = isDirectory
+ self.isPackage = isPackage
self.size = size
+ self.fileCount = fileCount
self.children = children
self.fileType = fileType
+ self.parentURL = parentURL
+ }
+
+ /// Recursively gathers all leaf files/packages under this item matching an optional category.
+ public func allDescendantFiles(matching category: FileCategory? = nil) -> [DiskItem] {
+ var results: [DiskItem] = []
+
+ func traverse(_ item: DiskItem) {
+ if !item.isDirectory || item.isPackage {
+ if category == nil || category == .all || item.fileType == category {
+ results.append(item)
+ }
+ } else if let children = item.children {
+ for child in children {
+ traverse(child)
+ }
+ }
+ }
+
+ traverse(self)
+ return results.sorted { $0.size > $1.size }
}
}
diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift
index 80d5752..7eaa577 100644
--- a/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift
+++ b/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift
@@ -4,13 +4,18 @@ import OSLog
public actor DiskScanner {
private let logger = Logger(subsystem: "input.MacOSCleaner", category: "DiskScanner")
+ private static let packageExtensions: Set = [
+ "app", "bundle", "framework", "plugin", "kext", "photoslibrary",
+ "savedstate", "pkg", "dmg", "lproj", "workflow", "qlgenerator", "prefpane"
+ ]
+
public init() {}
- /// Scans a directory and returns its files (flattened) with calculated sizes.
+ /// Scans a directory and returns its hierarchical tree rooted at `directoryURL`.
public func scan(
directoryURL: URL,
onProgress: @Sendable @escaping (String) -> Void
- ) async throws -> [DiskItem] {
+ ) async throws -> DiskItem {
let activity = ProcessInfo.processInfo.beginActivity(
options: .userInitiated,
reason: "Scanning disk space at \(directoryURL.lastPathComponent)"
@@ -19,131 +24,244 @@ public actor DiskScanner {
ProcessInfo.processInfo.endActivity(activity)
}
+ let rootURL = directoryURL.standardizedFileURL
let fm = FileManager.default
- let keys: [URLResourceKey] = [.fileSizeKey, .isDirectoryKey]
+ let keys: [URLResourceKey] = [
+ .isDirectoryKey,
+ .isPackageKey,
+ .fileSizeKey,
+ .totalFileAllocatedSizeKey,
+ .isUbiquitousItemKey,
+ .ubiquitousItemDownloadingStatusKey
+ ]
+
+ let rootNode = DirectoryNode(url: rootURL, name: rootURL.lastPathComponent, parentURL: nil)
+ var directoryNodes: [URL: DirectoryNode] = [rootURL: rootNode]
guard let enumerator = fm.enumerator(
- at: directoryURL,
+ at: rootURL,
includingPropertiesForKeys: keys,
options: [.skipsHiddenFiles],
- errorHandler: { url, error in
- return true // Skip access errors and continue
- }
+ errorHandler: { _, _ in true }
) else {
- return []
+ return rootNode.toDiskItem()
}
- var results: [DiskItem] = []
- var appsToCalculate: [URL] = []
var count = 0
- let minSize: Int64 = 1024 * 1024 // 1 MB
while let fileURL = enumerator.nextObject() as? URL {
if Task.isCancelled { break }
+ let standardURL = fileURL.standardizedFileURL
- if FileManager.shouldExclude(url: fileURL) {
- if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true {
+ if FileManager.shouldExclude(url: standardURL) {
+ if (try? standardURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true {
enumerator.skipDescendants()
}
continue
}
- guard let values = try? fileURL.resourceValues(forKeys: Set(keys)) else { continue }
- let isDir = values.isDirectory ?? false
+ guard let values = try? standardURL.resourceValues(forKeys: Set(keys)) else { continue }
- if isDir {
- if fileURL.pathExtension.lowercased() == "app" {
- enumerator.skipDescendants()
- appsToCalculate.append(fileURL)
+ // Skip dataless iCloud files to avoid triggering network downloads
+ if let isUbiquitous = values.isUbiquitousItem, isUbiquitous {
+ if values.ubiquitousItemDownloadingStatus == .notDownloaded {
+ if values.isDirectory == true {
+ enumerator.skipDescendants()
+ }
+ continue
}
+ }
+
+ let isDir = values.isDirectory ?? false
+ let ext = standardURL.pathExtension.lowercased()
+ let isPackage = (values.isPackage ?? false) || Self.packageExtensions.contains(ext)
+
+ if isDir && !isPackage {
+ let parentURL = standardURL.deletingLastPathComponent().standardizedFileURL
+ let parentNode = getOrCreateDirectoryNode(
+ url: parentURL,
+ rootURL: rootURL,
+ directoryNodes: &directoryNodes
+ )
+ let node = DirectoryNode(
+ url: standardURL,
+ name: standardURL.lastPathComponent,
+ parentURL: parentURL
+ )
+ directoryNodes[standardURL] = node
+ parentNode.subdirectories[standardURL] = node
+ } else if isDir && isPackage {
+ enumerator.skipDescendants()
+ let pkgSize = await calculatePackageSize(url: standardURL)
+ let parentURL = standardURL.deletingLastPathComponent().standardizedFileURL
+ let parentNode = getOrCreateDirectoryNode(
+ url: parentURL,
+ rootURL: rootURL,
+ directoryNodes: &directoryNodes
+ )
+
+ let item = DiskItem(
+ url: standardURL,
+ name: standardURL.lastPathComponent,
+ isDirectory: true,
+ isPackage: true,
+ size: pkgSize.size,
+ fileCount: pkgSize.fileCount,
+ fileType: .apps,
+ parentURL: parentURL
+ )
+ parentNode.fileChildren.append(item)
+ propagateSize(pkgSize.size, fileCount: pkgSize.fileCount, from: parentNode, directoryNodes: directoryNodes, rootURL: rootURL)
} else {
- let size = Int64(values.fileSize ?? 0)
- if size > minSize {
- results.append(DiskItem(
- url: fileURL,
- name: fileURL.lastPathComponent,
- isDirectory: false,
- size: size,
- fileType: FileCategory.from(url: fileURL)
- ))
- }
+ let allocatedSize = Int64(values.totalFileAllocatedSize ?? values.fileSize ?? 0)
+ let parentURL = standardURL.deletingLastPathComponent().standardizedFileURL
+ let parentNode = getOrCreateDirectoryNode(
+ url: parentURL,
+ rootURL: rootURL,
+ directoryNodes: &directoryNodes
+ )
+
+ let item = DiskItem(
+ url: standardURL,
+ name: standardURL.lastPathComponent,
+ isDirectory: false,
+ isPackage: false,
+ size: allocatedSize,
+ fileCount: 1,
+ fileType: FileCategory.from(url: standardURL),
+ parentURL: parentURL
+ )
+ parentNode.fileChildren.append(item)
+ propagateSize(allocatedSize, fileCount: 1, from: parentNode, directoryNodes: directoryNodes, rootURL: rootURL)
}
count += 1
if count % 1000 == 0 {
- onProgress(fileURL.lastPathComponent)
+ onProgress(standardURL.lastPathComponent)
await Task.yield()
}
}
- if Task.isCancelled { return results }
-
- // Calculate .app sizes in parallel
- let appItems = try await withThrowingTaskGroup(of: DiskItem?.self) { group in
- for appURL in appsToCalculate {
- group.addTask {
- if Task.isCancelled { return nil }
- let size = await self.calculateDirectorySize(url: appURL)
- if size > minSize {
- return DiskItem(
- url: appURL,
- name: appURL.lastPathComponent,
- isDirectory: false,
- size: size,
- fileType: .apps
- )
- }
- return nil
- }
- }
-
- var list: [DiskItem] = []
- while let item = try await group.next() {
- if let item {
- list.append(item)
- }
- }
- return list
+ return rootNode.toDiskItem()
+ }
+
+ private func getOrCreateDirectoryNode(
+ url: URL,
+ rootURL: URL,
+ directoryNodes: inout [URL: DirectoryNode]
+ ) -> DirectoryNode {
+ if let existing = directoryNodes[url] {
+ return existing
}
- results.append(contentsOf: appItems)
+ let parentURL = url.deletingLastPathComponent().standardizedFileURL
+ let parentNode: DirectoryNode?
+ if url != rootURL && url.path.hasPrefix(rootURL.path) {
+ parentNode = getOrCreateDirectoryNode(
+ url: parentURL,
+ rootURL: rootURL,
+ directoryNodes: &directoryNodes
+ )
+ } else {
+ parentNode = nil
+ }
- // Sort by size descending
- return results.sorted { $0.size > $1.size }
+ let node = DirectoryNode(
+ url: url,
+ name: url.lastPathComponent,
+ parentURL: parentNode?.url
+ )
+ directoryNodes[url] = node
+ parentNode?.subdirectories[url] = node
+ return node
}
- private func calculateDirectorySize(url: URL) async -> Int64 {
- if Task.isCancelled { return 0 }
+ private func propagateSize(
+ _ size: Int64,
+ fileCount: Int,
+ from node: DirectoryNode,
+ directoryNodes: [URL: DirectoryNode],
+ rootURL: URL
+ ) {
+ var current: DirectoryNode? = node
+ while let curr = current {
+ curr.size += size
+ curr.fileCount += fileCount
+ if curr.url == rootURL { break }
+ if let pURL = curr.parentURL {
+ current = directoryNodes[pURL]
+ } else {
+ break
+ }
+ }
+ }
+
+ private func calculatePackageSize(url: URL) async -> (size: Int64, fileCount: Int) {
+ if Task.isCancelled { return (0, 0) }
let fm = FileManager.default
- let keys: [URLResourceKey] = [.fileSizeKey, .isDirectoryKey]
+ let keys: [URLResourceKey] = [.totalFileAllocatedSizeKey, .fileSizeKey, .isDirectoryKey]
guard let enumerator = fm.enumerator(
at: url,
includingPropertiesForKeys: keys,
options: [.skipsHiddenFiles]
) else {
- return 0
+ let values = try? url.resourceValues(forKeys: Set(keys))
+ let s = Int64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
+ return (s, 1)
}
var totalSize: Int64 = 0
- var count = 0
+ var totalFiles = 0
while let fileURL = enumerator.nextObject() as? URL {
if Task.isCancelled { break }
-
guard let values = try? fileURL.resourceValues(forKeys: Set(keys)) else { continue }
let isDir = values.isDirectory ?? false
-
if !isDir {
- totalSize += Int64(values.fileSize ?? 0)
- }
-
- count += 1
- if count % 1000 == 0 {
- await Task.yield()
+ totalSize += Int64(values.totalFileAllocatedSize ?? values.fileSize ?? 0)
+ totalFiles += 1
}
}
- return totalSize
+ return (totalSize, max(1, totalFiles))
}
}
+
+private final class DirectoryNode: @unchecked Sendable {
+ let url: URL
+ let name: String
+ let parentURL: URL?
+ var fileCount: Int = 0
+ var size: Int64 = 0
+ var fileChildren: [DiskItem] = []
+ var subdirectories: [URL: DirectoryNode] = [:]
+
+ init(url: URL, name: String, parentURL: URL?) {
+ self.url = url
+ self.name = name
+ self.parentURL = parentURL
+ }
+
+ func toDiskItem() -> DiskItem {
+ var allChildren: [DiskItem] = []
+ allChildren.append(contentsOf: fileChildren)
+ for (_, subNode) in subdirectories {
+ allChildren.append(subNode.toDiskItem())
+ }
+ allChildren.sort { $0.size > $1.size }
+
+ return DiskItem(
+ url: url,
+ name: name.isEmpty ? "/" : name,
+ isDirectory: true,
+ isPackage: false,
+ size: size,
+ fileCount: fileCount,
+ children: allChildren,
+ fileType: .all,
+ parentURL: parentURL
+ )
+ }
+}
+
diff --git a/MacOSCleaner/Features/Settings/AppSettings.swift b/MacOSCleaner/Features/Settings/AppSettings.swift
index cfba44d..d1a4bfd 100644
--- a/MacOSCleaner/Features/Settings/AppSettings.swift
+++ b/MacOSCleaner/Features/Settings/AppSettings.swift
@@ -121,6 +121,19 @@ public enum ProcessSortOption: String, CaseIterable, Identifiable {
}
}
+public enum ProjectArtifactsAgeLimit: Int, CaseIterable, Identifiable, Sendable {
+ case days30 = 30
+ case days60 = 60
+ case days90 = 90
+ case days180 = 180
+
+ public var id: Int { rawValue }
+
+ public var localizedName: String {
+ String(format: "settings_days_count".localized, rawValue)
+ }
+}
+
@MainActor
@Observable
public final class AppSettings {
@@ -139,6 +152,7 @@ public final class AppSettings {
static let processRefreshInterval = "settings_processRefreshInterval"
static let processSortOption = "settings_processSortOption"
static let uninstallerScanMode = "settings_uninstallerScanMode"
+ static let projectArtifactsOlderThanDays = "settings_projectArtifactsOlderThanDays"
static let enableAI = "settings_enableAI"
static let enableSiri = "settings_enableSiri"
static let enableShortcutsAndAutomator = "settings_enableShortcutsAndAutomator"
@@ -230,6 +244,10 @@ public final class AppSettings {
didSet { UserDefaults.standard.set(emptyTrashDuringCleanup, forKey: Keys.emptyTrashDuringCleanup) }
}
+ public var projectArtifactsOlderThanDays: Int {
+ didSet { UserDefaults.standard.set(projectArtifactsOlderThanDays, forKey: Keys.projectArtifactsOlderThanDays) }
+ }
+
// MARK: - Uninstaller
public var bypassTrashOnUninstall: Bool {
@@ -280,6 +298,7 @@ public final class AppSettings {
self.showTooltips = defaults.object(forKey: Keys.showTooltips) as? Bool ?? true
self.autoScanOnStartup = defaults.bool(forKey: Keys.autoScanOnStartup)
self.emptyTrashDuringCleanup = defaults.bool(forKey: Keys.emptyTrashDuringCleanup)
+ self.projectArtifactsOlderThanDays = defaults.object(forKey: Keys.projectArtifactsOlderThanDays) as? Int ?? 60
self.bypassTrashOnUninstall = defaults.bool(forKey: Keys.bypassTrashOnUninstall)
self.showRelatedFiles = defaults.object(forKey: Keys.showRelatedFiles) as? Bool ?? true
self.emptyTrashImmediately = defaults.bool(forKey: Keys.emptyTrashImmediately)
@@ -318,6 +337,7 @@ public final class AppSettings {
Keys.autoScanOnStartup, Keys.emptyTrashDuringCleanup, Keys.bypassTrashOnUninstall,
Keys.showRelatedFiles, Keys.emptyTrashImmediately, Keys.isDebugMode,
Keys.processRefreshInterval, Keys.processSortOption, Keys.uninstallerScanMode,
+ Keys.projectArtifactsOlderThanDays,
Keys.enableAI, Keys.enableSiri, Keys.enableShortcutsAndAutomator,
Keys.enableDeveloperCachesCommand, Keys.enableStorageStatusCommand,
Keys.enableCleanCategoryCommand, Keys.enableScheduledCleanupCommand,
@@ -333,6 +353,7 @@ public final class AppSettings {
showTooltips = true
autoScanOnStartup = false
emptyTrashDuringCleanup = false
+ projectArtifactsOlderThanDays = 60
bypassTrashOnUninstall = false
showRelatedFiles = true
emptyTrashImmediately = false
diff --git a/MacOSCleaner/Features/Settings/SettingsCleanupView.swift b/MacOSCleaner/Features/Settings/SettingsCleanupView.swift
index ce2de5d..611563e 100644
--- a/MacOSCleaner/Features/Settings/SettingsCleanupView.swift
+++ b/MacOSCleaner/Features/Settings/SettingsCleanupView.swift
@@ -87,6 +87,22 @@ struct SettingsCleanupView: View {
SettingsDivider()
+ SettingsLabeledControl(
+ "settings_project_artifacts_age".localized,
+ subtitle: "settings_project_artifacts_age_sub".localized
+ ) {
+ GlassPillPicker(
+ items: ProjectArtifactsAgeLimit.allCases,
+ selection: Binding(
+ get: { ProjectArtifactsAgeLimit(rawValue: settings.projectArtifactsOlderThanDays) ?? .days60 },
+ set: { settings.projectArtifactsOlderThanDays = $0.rawValue }
+ ),
+ label: { $0.localizedName }
+ )
+ }
+
+ SettingsDivider()
+
SettingsToggleRow(
"settings_show_related".localized,
subtitle: "settings_show_related_sub".localized,
diff --git a/MacOSCleaner/Features/Settings/SettingsGeneralView.swift b/MacOSCleaner/Features/Settings/SettingsGeneralView.swift
index 5ff8457..9438f3b 100644
--- a/MacOSCleaner/Features/Settings/SettingsGeneralView.swift
+++ b/MacOSCleaner/Features/Settings/SettingsGeneralView.swift
@@ -112,7 +112,7 @@ struct SettingsGeneralView: View {
content: {
SettingsLabeledControl(
"settings_current_version".localized,
- subtitle: "v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.1.1")"
+ subtitle: "v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.2.0")"
) {
if isCheckingForUpdates {
ProgressView().controlSize(.small)
diff --git a/MacOSCleaner/Features/Settings/SettingsView.swift b/MacOSCleaner/Features/Settings/SettingsView.swift
index 538cb67..980f92d 100644
--- a/MacOSCleaner/Features/Settings/SettingsView.swift
+++ b/MacOSCleaner/Features/Settings/SettingsView.swift
@@ -178,6 +178,6 @@ struct SettingsView: View {
settings: AppSettings(),
permissionsManager: PermissionsManager(),
onForget: {},
- availableUpdate: .constant(AvailableUpdate(version: "2.1.1", dmgURL: nil))
+ availableUpdate: .constant(AvailableUpdate(version: "2.3.0", dmgURL: nil))
)
}
diff --git a/MacOSCleaner/Features/Uninstaller/OrphanScanner.swift b/MacOSCleaner/Features/Uninstaller/OrphanScanner.swift
index a5c7a36..5fa12c4 100644
--- a/MacOSCleaner/Features/Uninstaller/OrphanScanner.swift
+++ b/MacOSCleaner/Features/Uninstaller/OrphanScanner.swift
@@ -13,6 +13,10 @@ public struct OrphanItem: Identifiable, Sendable, Hashable {
public let sizeBytes: Int64
public let category: String
public let modificationDate: Date?
+ public let evidence: Set
+ public let confidence: ConfidenceTier
+ public let score: Int
+ public var isSelected: Bool
public init(
id: UUID = UUID(),
@@ -21,7 +25,11 @@ public struct OrphanItem: Identifiable, Sendable, Hashable {
bundleID: String?,
sizeBytes: Int64,
category: String,
- modificationDate: Date? = nil
+ modificationDate: Date? = nil,
+ evidence: Set = [],
+ confidence: ConfidenceTier = .possible,
+ score: Int = 0,
+ isSelected: Bool = true
) {
self.id = id
self.url = NormalizedPath.canonicalize(url)
@@ -30,6 +38,10 @@ public struct OrphanItem: Identifiable, Sendable, Hashable {
self.sizeBytes = sizeBytes
self.category = category
self.modificationDate = modificationDate
+ self.evidence = evidence
+ self.confidence = confidence
+ self.score = score
+ self.isSelected = isSelected
}
public func hash(into hasher: inout Hasher) {
@@ -37,7 +49,7 @@ public struct OrphanItem: Identifiable, Sendable, Hashable {
}
public static func == (lhs: OrphanItem, rhs: OrphanItem) -> Bool {
- NormalizedPath.key(lhs.url) == NormalizedPath.key(rhs.url)
+ NormalizedPath.key(lhs.url) == NormalizedPath.key(rhs.url) && lhs.isSelected == rhs.isSelected
}
}
@@ -104,11 +116,10 @@ public actor OrphanScanner {
}
// Basic safety & size filters
- guard (try? safetyManager.validate(url: file)) != nil else { continue }
+ guard (try? safetyManager.validate(url: file, policy: .uninstall)) != nil else { continue }
- // Skip Apple system items immediately
- let filename = file.lastPathComponent
- if filename.hasPrefix("com.apple.") || filename.hasPrefix("com.mac.") {
+ // Skip Apple system items, system daemons, and developer toolchain containers
+ if isSystemOrProtected(file: file) {
continue
}
@@ -119,7 +130,7 @@ public actor OrphanScanner {
)
if !isOwned {
- let size = fileManager.getDirectorySize(url: file)
+ let size = fileManager.getPhysicalDirectorySize(url: file)
// Filter small orphans unless they are plists or configs
let ext = file.pathExtension.lowercased()
let isConfig = ["plist", "json", "yaml", "xml", "conf"].contains(ext)
@@ -139,33 +150,171 @@ public actor OrphanScanner {
else if pathStr.contains("/Containers/") || pathStr.contains("/Group Containers/") { category = "Containers" }
else if pathStr.contains("/Developer/") || pathStr.contains("CommandLineTools") { category = "Developer" }
+ let extracted = extractBundleIDAndName(from: file)
+
+ // Generic folders without any bundle ID or specific orphan markers are not orphans
+ if extracted.bundleID == nil {
+ let ext = file.pathExtension.lowercased()
+ let isSpecialResidual = ext == "savedstate" || ext == "plist" || pathStr.contains("/Containers/") || pathStr.contains("/Group Containers/")
+ if !isSpecialResidual {
+ continue
+ }
+ }
+
+ let (evidence, score, tier) = await collectOrphanEvidence(
+ for: file,
+ bundleID: extracted.bundleID,
+ name: extracted.name,
+ probe: probe
+ )
+
+ // Enforce confidence threshold: require at least veryLikely for standalone leftovers
+ guard tier >= .veryLikely, !evidence.isEmpty else {
+ continue
+ }
+
orphans.append(OrphanItem(
url: file,
- name: filename,
- bundleID: nil, // We could try to extract it from filename if needed, but not critical
+ name: extracted.name,
+ bundleID: extracted.bundleID,
sizeBytes: size,
category: category,
- modificationDate: modDate
+ modificationDate: modDate,
+ evidence: evidence,
+ confidence: tier,
+ score: score,
+ isSelected: true
))
- Logger.orphanScanner.debug("Found orphan: \(filename, privacy: .public) (\(size) bytes)")
+ Logger.orphanScanner.debug("Found orphan: \(extracted.name, privacy: .public) (\(size) bytes) tier=\(tier.rawValue) bundleID=\(extracted.bundleID ?? "nil")")
}
}
return orphans.sorted { $0.sizeBytes > $1.sizeBytes }
}
+
+ private func isSystemOrProtected(file: URL) -> Bool {
+ let rawFilename = file.lastPathComponent.lowercased()
+ let filename = stripTeamIDPrefix(from: rawFilename)
+ .replacingOccurrences(of: "group.", with: "")
+ .replacingOccurrences(of: "groups.", with: "")
+
+ // Apple system containers, groups, daemons and system tools
+ if rawFilename.contains("com.apple.") ||
+ rawFilename.contains(".apple.") ||
+ rawFilename.contains("group.com.apple") ||
+ rawFilename.contains("groups.com.apple") ||
+ filename.hasPrefix("com.apple.") ||
+ filename.hasPrefix("com.mac.") ||
+ filename.hasPrefix("is.workflow.") ||
+ filename.hasPrefix("org.swift.") ||
+ filename.hasPrefix("org.llvm.") ||
+ filename.hasPrefix("org.gnu.") ||
+ filename.hasPrefix("org.cups.") ||
+ filename.hasPrefix("org.sparkle-project.") ||
+ filename.hasPrefix("org.openldap.") ||
+ filename.hasPrefix("org.apache.") {
+ return true
+ }
+
+ return false
+ }
+
+ private func stripTeamIDPrefix(from string: String) -> String {
+ let pattern = #"^[A-Z0-9]{10}\.(?:groups?\.)?"#
+ if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
+ let range = NSRange(location: 0, length: string.utf16.count)
+ return regex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
+ }
+ return string
+ }
private func checkOwnership(
file: URL,
identities: [AppIdentity],
probe: EvidenceProbe
) async -> Bool {
- let filename = file.lastPathComponent.lowercased()
+ let rawFilename = file.lastPathComponent.lowercased()
+ let filename = stripTeamIDPrefix(from: rawFilename)
+ .replacingOccurrences(of: "group.", with: "")
+ .replacingOccurrences(of: "groups.", with: "")
let path = file.path.lowercased()
+ let cleanName = (filename as NSString).deletingPathExtension.lowercased()
+
+ // 1. Direct identity matching: app name, bundle ID, vendor names
+ for identity in identities {
+ let bid = identity.bundleID.lowercased()
+ let appName = identity.appName.lowercased()
+
+ // Exact name or clean name match
+ if !appName.isEmpty {
+ if cleanName == appName || filename == appName || rawFilename == appName {
+ return true
+ }
+ // Check if directory is a vendor suite folder matching app name
+ if cleanName.count >= 4 && (appName.hasPrefix(cleanName + " ") || appName.hasSuffix(" " + cleanName)) {
+ return true
+ }
+ let appTokens = appName.components(separatedBy: CharacterSet(charactersIn: " -_.")).filter { $0.count >= 4 }
+ for token in appTokens {
+ if cleanName.contains(token) || filename.contains(token) {
+ return true
+ }
+ }
+ }
+
+ // Bundle ID match
+ if !bid.isEmpty && bid.contains(".") {
+ if cleanName == bid || filename == bid || path.contains(bid) {
+ return true
+ }
+ // Sub-bundle match: e.g. com.spotify.client.helper for com.spotify.client
+ if cleanName.hasPrefix(bid + ".") || bid.hasPrefix(cleanName + ".") {
+ return true
+ }
+
+ // Match reverse DNS vendor part (e.g. com.google, com.microsoft, dev.orbstack)
+ let parts = bid.components(separatedBy: ".")
+ if parts.count >= 2 {
+ let domainPrefix = parts.prefix(2).joined(separator: ".")
+ if cleanName == domainPrefix || cleanName.hasPrefix(domainPrefix + ".") {
+ return true
+ }
+ }
+ }
+
+ // Vendor names match (e.g. "Google", "Microsoft", "Adobe", "JetBrains")
+ for vendor in identity.vendorNames {
+ let v = vendor.lowercased()
+ if v.count >= 3 {
+ if cleanName == v || filename == v || cleanName.contains(".\(v).") || cleanName.hasPrefix("com.\(v).") || cleanName.hasPrefix("org.\(v).") || cleanName.hasPrefix("io.\(v).") || cleanName.hasPrefix("\(v).") {
+ return true
+ }
+ }
+ }
+ }
+
+ // 2. Dot-files/tools check (e.g. ~/.gradle, ~/.npm, ~/.docker) - if command exists on system, it's not an orphan
+ if filename.hasPrefix(".") {
+ let toolName = String(filename.dropFirst())
+ if !toolName.isEmpty {
+ let exists = await commandRunner.commandExists(toolName)
+ if exists {
+ return true
+ }
+ }
+ }
+
+ if cleanName.contains("java") || cleanName.contains("openjdk") {
+ if await commandRunner.commandExists("java") {
+ return true
+ }
+ }
- // Fast path priority check
+ // 3. Priority probe check
let priorityApps = identities.filter { identity in
let bid = identity.bundleID.lowercased()
- return filename.contains(bid) || path.contains(bid) || filename.contains(identity.appName.lowercased())
+ let appName = identity.appName.lowercased()
+ return (!bid.isEmpty && path.contains(bid)) || (!appName.isEmpty && cleanName == appName)
}
for identity in priorityApps {
@@ -174,22 +323,134 @@ public actor OrphanScanner {
let rule = await ruleRegistry.bestRule(for: identity)
let ruleScore = rule.evidence(for: file, identity: identity).reduce(0) { $0 + $1.weight }
let assessment = ConfidenceEngine.assess(evidence, ruleScore: ruleScore, identity: identity)
- if assessment.tier >= .veryLikely { return true }
+ if assessment.tier >= .possible { return true }
}
- if !priorityApps.isEmpty { return false }
-
- // Full check for unresolved files
- for identity in identities {
- let evidence = await probe.probe(url: file, identity: identity)
- guard !evidence.isEmpty else { continue }
- let rule = await ruleRegistry.bestRule(for: identity)
- let ruleScore = rule.evidence(for: file, identity: identity).reduce(0) { $0 + $1.weight }
- let assessment = ConfidenceEngine.assess(evidence, ruleScore: ruleScore, identity: identity)
- if assessment.tier >= .veryLikely { return true }
- }
return false
}
+
+ private func extractBundleIDAndName(from file: URL) -> (bundleID: String?, name: String) {
+ var filename = file.lastPathComponent
+ let path = file.path
+
+ // Clean team ID prefix e.g. TC3Q7MAJXF.com.adguard.mac -> com.adguard.mac
+ filename = stripTeamIDPrefix(from: filename)
+ filename = filename.replacingOccurrences(of: "group.", with: "")
+ .replacingOccurrences(of: "groups.", with: "")
+
+ // Check if plist has embedded bundle identifier
+ if filename.hasSuffix(".plist") {
+ let base = (filename as NSString).deletingPathExtension
+ if base.contains(".") && !base.hasPrefix(".") {
+ let parts = base.components(separatedBy: ".")
+ let lastPart = parts.last?.capitalized ?? base
+ return (base, lastPart)
+ }
+ }
+
+ // Check Containers & Group Containers
+ if path.contains("/Containers/") || path.contains("/Group Containers/") {
+ let clean = (filename as NSString).deletingPathExtension
+ if clean.contains(".") {
+ let parts = clean.components(separatedBy: ".")
+ let lastPart = parts.last?.capitalized ?? clean
+ return (clean, lastPart)
+ }
+ }
+
+ // Check Saved Application State
+ if filename.hasSuffix(".savedState") {
+ let base = (filename as NSString).deletingPathExtension
+ if base.contains(".") {
+ let parts = base.components(separatedBy: ".")
+ let lastPart = parts.last?.capitalized ?? base
+ return (base, lastPart)
+ }
+ }
+
+ // Check reverse DNS pattern in folder/file names (com.something.app or org.something.app)
+ if filename.contains(".") && !filename.hasPrefix(".") {
+ let prefixes = ["com.", "org.", "net.", "io.", "app.", "dev.", "co.", "uk.", "de.", "ru."]
+ if prefixes.contains(where: { filename.lowercased().hasPrefix($0) }) {
+ let clean = (filename as NSString).deletingPathExtension
+ let parts = clean.components(separatedBy: ".")
+ let lastPart = parts.last?.capitalized ?? clean
+ return (clean, lastPart)
+ }
+ }
+
+ return (nil, filename)
+ }
+
+ private func collectOrphanEvidence(
+ for file: URL,
+ bundleID: String?,
+ name: String,
+ probe: EvidenceProbe
+ ) async -> (evidence: Set, score: Int, tier: ConfidenceTier) {
+ var evidence = Set()
+ let path = file.path
+
+ // Only give bundleIDExact if we have a real structured reverse-DNS bundleID (contains dots and valid format)
+ if let bid = bundleID, bid.contains("."), bid.components(separatedBy: ".").count >= 3 {
+ evidence.insert(.bundleIDExact)
+ }
+
+ if path.contains("/Containers/") {
+ evidence.insert(.container)
+ }
+ if path.contains("/Group Containers/") {
+ evidence.insert(.appGroup)
+ }
+ if path.contains("/LaunchAgents/") {
+ evidence.insert(.launchAgent)
+ }
+ if path.contains("/LaunchDaemons/") {
+ evidence.insert(.launchDaemon)
+ }
+ if file.pathExtension.lowercased() == "plist" || path.contains("/Preferences/") {
+ evidence.insert(.plistContent)
+ }
+
+ // Synthetic AppIdentity for probe analysis
+ guard let validBundleID = bundleID, validBundleID.contains(".") else {
+ return (evidence, 0, .ignore)
+ }
+
+ let syntheticIdentity = AppIdentity(
+ bundleID: validBundleID,
+ appName: name,
+ bundleName: name,
+ bundleVersion: nil,
+ executableName: name,
+ teamID: nil,
+ signingAuthority: nil,
+ bundleURL: URL(fileURLWithPath: "/Applications/\(name).app"),
+ isAppStore: false,
+ isSandboxed: path.contains("/Containers/"),
+ isAdHocSigned: false,
+ vendorNames: [],
+ helperNames: [],
+ frameworkNames: [],
+ xpcServiceNames: [],
+ plugInNames: [],
+ isElectron: path.contains("Electron"),
+ isJetBrains: path.contains("JetBrains"),
+ isFlutter: path.contains("Flutter"),
+ isJava: false,
+ isQt: false,
+ isDocker: false
+ )
+
+ let probeEvidence = await probe.probe(url: file, identity: syntheticIdentity)
+ evidence.formUnion(probeEvidence)
+
+ let rule = await ruleRegistry.bestRule(for: syntheticIdentity)
+ let ruleScore = rule.evidence(for: file, identity: syntheticIdentity).reduce(0) { $0 + $1.weight }
+
+ let assessment = ConfidenceEngine.assess(evidence, ruleScore: ruleScore, identity: syntheticIdentity)
+ return (evidence, assessment.score, assessment.tier)
+ }
private func collectAllScanTargets() async -> Set {
let home = fileSystemContext.homePath
diff --git a/MacOSCleaner/Features/Uninstaller/OrphanedResidualsView.swift b/MacOSCleaner/Features/Uninstaller/OrphanedResidualsView.swift
new file mode 100644
index 0000000..ecff3be
--- /dev/null
+++ b/MacOSCleaner/Features/Uninstaller/OrphanedResidualsView.swift
@@ -0,0 +1,482 @@
+import SwiftUI
+import AppKit
+import OSLog
+
+private extension Logger {
+ static let orphanView = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "OrphanedResidualsView")
+}
+
+public struct OrphanedResidualsView: View {
+ let service: UninstallerService
+ let settings: AppSettings
+
+ @State private var items: [OrphanItem] = []
+ @State private var isLoading = false
+ @State private var hasScanned = false
+ @State private var scanProgressMessage = ""
+ @State private var searchText = ""
+ @State private var selectedFilterTier: ConfidenceTier? = nil
+ @State private var expandedItemIDs: Set = []
+ @State private var showingConfirmation = false
+ @State private var isCleaning = false
+ @State private var scanTask: Task? = nil
+
+ public init(service: UninstallerService, settings: AppSettings) {
+ self.service = service
+ self.settings = settings
+ }
+
+ private var filteredItems: [OrphanItem] {
+ items.filter { item in
+ let matchesSearch = searchText.isEmpty
+ || item.name.localizedCaseInsensitiveContains(searchText)
+ || (item.bundleID?.localizedCaseInsensitiveContains(searchText) ?? false)
+ || item.url.path.localizedCaseInsensitiveContains(searchText)
+
+ let matchesTier: Bool
+ if let filterTier = selectedFilterTier {
+ matchesTier = item.confidence == filterTier
+ } else {
+ matchesTier = true
+ }
+
+ return matchesSearch && matchesTier
+ }
+ }
+
+ private var selectedItems: [OrphanItem] {
+ items.filter(\.isSelected)
+ }
+
+ private var selectedSizeBytes: Int64 {
+ selectedItems.reduce(0) { $0 + $1.sizeBytes }
+ }
+
+ private var totalFoundSizeBytes: Int64 {
+ items.reduce(0) { $0 + $1.sizeBytes }
+ }
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ // Content
+ if isLoading {
+ AnimatedScanView(
+ title: "uninstaller_scanning_leftovers".localized,
+ subtitle: scanProgressMessage,
+ currentStep: 1,
+ totalSteps: 1
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if !hasScanned {
+ heroStateView
+ } else if items.isEmpty {
+ emptyStateView
+ } else {
+ resultsView
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .confirmationDialog(
+ "uninstaller_confirm_trash_leftovers_title".localized,
+ isPresented: $showingConfirmation,
+ titleVisibility: .visible
+ ) {
+ Button("uninstaller_move_trash".localized, role: .destructive) {
+ performCleaning()
+ }
+ Button("cancel".localized, role: .cancel) { }
+ } message: {
+ Text(String(
+ format: "uninstaller_confirm_trash_leftovers_message".localized,
+ Int64(selectedItems.count),
+ ByteCountFormatter.localizedString(fromByteCount: selectedSizeBytes, countStyle: .file)
+ ))
+ }
+ }
+
+ // MARK: - Hero View
+
+ private var heroStateView: some View {
+ VStack(spacing: 20) {
+ Image(systemName: "shippingbox.and.arrow.backward")
+ .font(.system(size: 56))
+ .foregroundStyle(Color.accentColor.gradient)
+ .padding(.bottom, 4)
+
+ VStack(spacing: 8) {
+ Text("uninstaller_leftovers_hero_title".localized)
+ .font(.title2)
+ .fontWeight(.bold)
+
+ Text("uninstaller_leftovers_hero_subtitle".localized)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 480)
+ }
+
+ Button(action: startScan) {
+ HStack(spacing: 8) {
+ Image(systemName: "magnifyingglass")
+ Text("uninstaller_start_leftover_scan".localized)
+ .font(.headline)
+ }
+ .frame(minWidth: 200, minHeight: 32)
+ }
+ .glassButtonStyle()
+ .controlSize(.large)
+ .padding(.top, 8)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(32)
+ }
+
+ // MARK: - Empty State View
+
+ private var emptyStateView: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "checkmark.seal.fill")
+ .font(.system(size: 48))
+ .foregroundColor(.green)
+
+ VStack(spacing: 6) {
+ Text("uninstaller_no_leftovers_title".localized)
+ .font(.title3)
+ .fontWeight(.bold)
+
+ Text("uninstaller_no_leftovers_subtitle".localized)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ Button(action: startScan) {
+ HStack(spacing: 6) {
+ Image(systemName: "arrow.clockwise")
+ Text("uninstaller_reload".localized)
+ }
+ }
+ .glassButtonStyle()
+ .padding(.top, 8)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(32)
+ }
+
+ // MARK: - Results View
+
+ private var resultsView: some View {
+ VStack(spacing: 0) {
+ // Filter Bar
+ HStack(spacing: 10) {
+ // Search box
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass")
+ .foregroundColor(.secondary)
+ .font(.caption)
+ TextField("uninstaller_search".localized, text: $searchText)
+ .textFieldStyle(.plain)
+ .font(.caption)
+ if !searchText.isEmpty {
+ Button(action: { searchText = "" }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.secondary)
+ .font(.caption2)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.6))
+ .cornerRadius(8)
+ .frame(maxWidth: 240)
+
+ // Filter chips
+ HStack(spacing: 4) {
+ filterChip(title: "uninstaller_filter_all".localized, tier: nil)
+ filterChip(title: ConfidenceTier.guaranteed.displayKey.localized, tier: .guaranteed)
+ filterChip(title: ConfidenceTier.veryLikely.displayKey.localized, tier: .veryLikely)
+ filterChip(title: ConfidenceTier.possible.displayKey.localized, tier: .possible)
+ }
+
+ Spacer()
+
+ Button(action: startScan) {
+ Image(systemName: "arrow.clockwise")
+ }
+ .glassButtonStyle()
+ .help("uninstaller_reload".localized)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.2))
+
+ Divider()
+
+ // List of items
+ List {
+ ForEach(filteredItems) { item in
+ orphanRow(for: item)
+ .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
+ .listRowBackground(Color.clear)
+ }
+ }
+ .listStyle(.inset)
+ .scrollContentBackground(.hidden)
+
+ Divider()
+
+ // Bottom Action Bar
+ HStack(spacing: 12) {
+ Button(action: toggleSelectAll) {
+ Text(items.allSatisfy(\.isSelected) ? "deselect_all".localized : "select_all".localized)
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ .foregroundColor(.accentColor)
+
+ Text("•")
+ .foregroundColor(.secondary)
+ .font(.caption2)
+
+ Text(String(format: "uninstaller_leftovers_found_count".localized, Int64(items.count)))
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ Spacer()
+
+ Text(String(format: "uninstaller_space_reclaim".localized, ByteCountFormatter.localizedString(fromByteCount: selectedSizeBytes, countStyle: .file)))
+ .font(.headline)
+
+ Button(action: { showingConfirmation = true }) {
+ HStack(spacing: 6) {
+ if isCleaning {
+ ProgressView().controlSize(.small)
+ } else {
+ Image(systemName: "trash")
+ }
+ Text("uninstaller_clean_selected_leftovers".localized)
+ .font(.headline)
+ }
+ .frame(minWidth: 160, minHeight: 30)
+ }
+ .destructiveGlassButtonStyle()
+ .controlSize(.large)
+ .disabled(selectedItems.isEmpty || isCleaning)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.4))
+ }
+ }
+
+ @ViewBuilder
+ private func filterChip(title: String, tier: ConfidenceTier?) -> some View {
+ let isSelected = selectedFilterTier == tier
+ Button(action: {
+ withAnimation(.spring(response: 0.2, dampingFraction: 0.8)) {
+ selectedFilterTier = tier
+ }
+ }) {
+ Text(title)
+ .font(.caption2)
+ .fontWeight(isSelected ? .bold : .medium)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .foregroundStyle(isSelected ? Color.white : Color.primary)
+ .background(
+ Capsule().fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.12))
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func toggleSelectAll() {
+ let allSelected = items.allSatisfy(\.isSelected)
+ for i in items.indices {
+ items[i].isSelected = !allSelected
+ }
+ }
+
+ @ViewBuilder
+ private func orphanRow(for item: OrphanItem) -> some View {
+ let isExpanded = expandedItemIDs.contains(item.id)
+ let index = items.firstIndex(where: { $0.id == item.id })
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 10) {
+ Toggle("", isOn: Binding(
+ get: { item.isSelected },
+ set: { val in
+ if let idx = index {
+ items[idx].isSelected = val
+ }
+ }
+ ))
+ .toggleStyle(.checkbox)
+
+ Image(systemName: iconForCategory(item.category))
+ .foregroundColor(.secondary)
+ .font(.body)
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Text(item.name)
+ .font(.subheadline)
+ .fontWeight(.medium)
+ .lineLimit(1)
+
+ if let bid = item.bundleID {
+ Text(bid)
+ .font(.system(size: 9, design: .monospaced))
+ .foregroundColor(.secondary)
+ .padding(.horizontal, 4)
+ .padding(.vertical, 1)
+ .background(Color.secondary.opacity(0.1))
+ .cornerRadius(4)
+ }
+
+ Text(item.category)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .padding(.horizontal, 4)
+ .padding(.vertical, 1)
+ .background(Color.secondary.opacity(0.08))
+ .cornerRadius(4)
+
+ ConfidenceBadgeView(tier: item.confidence)
+ }
+
+ Text(NormalizedPath.displayString(item.url))
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+
+ Spacer()
+
+ if let date = item.modificationDate {
+ Text(date.formatted(.dateTime.year().month().day().locale(LanguageManager.shared.currentLocale)))
+ .font(.caption2)
+ .foregroundColor(.secondary.opacity(0.7))
+ }
+
+ Text(ByteCountFormatter.localizedString(fromByteCount: item.sizeBytes, countStyle: .file))
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ Button {
+ NSWorkspace.shared.activateFileViewerSelecting([item.url])
+ } label: {
+ Image(systemName: "arrow.up.forward.app")
+ .foregroundColor(.accentColor)
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ .help("uninstaller_show_in_finder".localized)
+
+ Button {
+ withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
+ if isExpanded {
+ expandedItemIDs.remove(item.id)
+ } else {
+ expandedItemIDs.insert(item.id)
+ }
+ }
+ } label: {
+ Image(systemName: isExpanded ? "chevron.up.circle.fill" : "info.circle")
+ .foregroundColor(isExpanded ? .accentColor : .secondary)
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ .help("uninstaller_evidence_why_flagged".localized)
+ }
+
+ if isExpanded {
+ EvidenceCardView(
+ appName: item.name,
+ bundleID: item.bundleID,
+ evidence: item.evidence,
+ score: item.score,
+ tier: item.confidence
+ )
+ .padding(.leading, 28)
+ .padding(.top, 2)
+ }
+ }
+ .padding(8)
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.3))
+ .cornerRadius(8)
+ }
+
+ private func iconForCategory(_ category: String) -> String {
+ switch category {
+ case "Preferences": return "gearshape.fill"
+ case "Application Support": return "folder.fill"
+ case "Caches": return "archivebox.fill"
+ case "Containers": return "shippingbox.fill"
+ case "Logs": return "doc.text.fill"
+ case "Developer": return "wrench.and.screwdriver.fill"
+ default: return "doc.fill"
+ }
+ }
+
+ // MARK: - Actions
+
+ private func startScan() {
+ scanTask?.cancel()
+ isLoading = true
+ scanProgressMessage = "uninstaller.progress.discovering".localized
+
+ scanTask = Task {
+ do {
+ let found = try await service.scanOrphanedResiduals()
+ await MainActor.run {
+ self.items = found
+ self.isLoading = false
+ self.hasScanned = true
+ }
+ } catch {
+ Logger.orphanView.error("Orphan scan failed: \(error.localizedDescription, privacy: .public)")
+ await MainActor.run {
+ self.isLoading = false
+ self.hasScanned = true
+ }
+ }
+ }
+ }
+
+ private func performCleaning() {
+ let targets = selectedItems
+ guard !targets.isEmpty else { return }
+
+ isCleaning = true
+ Task {
+ defer { isCleaning = false }
+ do {
+ let freed = try await service.removeOrphanedResiduals(
+ targets,
+ bypassTrash: settings.bypassTrashOnUninstall
+ )
+
+ await MainActor.run {
+ let removedIDs = Set(targets.map(\.id))
+ self.items.removeAll { removedIDs.contains($0.id) }
+
+ if settings.showNotifications {
+ let title = "uninstaller_complete_title".localized
+ let body = String(
+ format: "uninstaller_leftovers_cleaned_notification".localized,
+ Int64(targets.count),
+ ByteCountFormatter.localizedString(fromByteCount: freed, countStyle: .file)
+ )
+ NotificationManager.shared.sendNotification(title: title, body: body)
+ }
+ }
+ } catch {
+ Logger.orphanView.error("Cleaning failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+}
diff --git a/MacOSCleaner/Features/Uninstaller/PostUninstallLeftoversSheet.swift b/MacOSCleaner/Features/Uninstaller/PostUninstallLeftoversSheet.swift
new file mode 100644
index 0000000..e7690a6
--- /dev/null
+++ b/MacOSCleaner/Features/Uninstaller/PostUninstallLeftoversSheet.swift
@@ -0,0 +1,341 @@
+import SwiftUI
+import AppKit
+
+public struct PostUninstallLeftoversSheet: View {
+ @Binding var report: VerificationReport?
+ let onClean: ([LeftoverItem]) -> Void
+ let onDismiss: () -> Void
+
+ @State private var items: [LeftoverItem] = []
+ @State private var expandedItemIDs: Set = []
+
+ public init(
+ report: Binding,
+ onClean: @escaping ([LeftoverItem]) -> Void,
+ onDismiss: @escaping () -> Void
+ ) {
+ self._report = report
+ self.onClean = onClean
+ self.onDismiss = onDismiss
+ }
+
+ private var selectedCount: Int {
+ items.filter(\.isSelected).count
+ }
+
+ private var selectedSizeBytes: Int64 {
+ items.filter(\.isSelected).reduce(0) { $0 + $1.sizeBytes }
+ }
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ HStack(alignment: .top, spacing: 14) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 32))
+ .foregroundColor(.orange)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("uninstaller_post_leftovers_title".localized)
+ .font(.headline)
+ .fontWeight(.bold)
+
+ if let rep = report {
+ Text(String(format: "uninstaller_post_leftovers_subtitle".localized, rep.appName))
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ Button(action: onDismiss) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.secondary)
+ .font(.title3)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(20)
+
+ Divider()
+
+ // Leftover list
+ ScrollView {
+ VStack(spacing: 8) {
+ ForEach($items) { $item in
+ leftoverRow(for: $item)
+ }
+ }
+ .padding(16)
+ }
+ .frame(maxHeight: 360)
+
+ Divider()
+
+ // Action footer
+ HStack {
+ Button(action: toggleSelectAll) {
+ Text(items.allSatisfy(\.isSelected) ? "deselect_all".localized : "select_all".localized)
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ .foregroundColor(.accentColor)
+
+ Spacer()
+
+ Text(String(format: "uninstaller_space_reclaim".localized, ByteCountFormatter.localizedString(fromByteCount: selectedSizeBytes, countStyle: .file)))
+ .font(.subheadline)
+ .fontWeight(.medium)
+ .foregroundColor(.secondary)
+
+ Button("close".localized, action: onDismiss)
+ .glassButtonStyle()
+
+ Button(action: {
+ let selected = items.filter(\.isSelected)
+ onClean(selected)
+ }) {
+ HStack(spacing: 6) {
+ Image(systemName: "trash")
+ Text("uninstaller_clean_selected_leftovers".localized)
+ }
+ }
+ .destructiveGlassButtonStyle()
+ .disabled(selectedCount == 0)
+ }
+ .padding(16)
+ }
+ .frame(minWidth: 540, maxWidth: 640)
+ .onAppear {
+ if let rep = report {
+ self.items = rep.items
+ }
+ }
+ }
+
+ private func toggleSelectAll() {
+ let allSelected = items.allSatisfy(\.isSelected)
+ for i in items.indices {
+ items[i].isSelected = !allSelected
+ }
+ }
+
+ @ViewBuilder
+ private func leftoverRow(for itemBinding: Binding) -> some View {
+ let item = itemBinding.wrappedValue
+ let isExpanded = expandedItemIDs.contains(item.id)
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 10) {
+ Toggle("", isOn: itemBinding.isSelected)
+ .toggleStyle(.checkbox)
+
+ Image(systemName: iconForURL(item.url))
+ .foregroundColor(.secondary)
+ .font(.subheadline)
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Text(item.url.lastPathComponent)
+ .font(.subheadline)
+ .fontWeight(.medium)
+ .lineLimit(1)
+
+ ConfidenceBadgeView(tier: item.confidence)
+ }
+
+ Text(NormalizedPath.displayString(item.url))
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+
+ Spacer()
+
+ Text(ByteCountFormatter.localizedString(fromByteCount: item.sizeBytes, countStyle: .file))
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ Button {
+ NSWorkspace.shared.activateFileViewerSelecting([item.url])
+ } label: {
+ Image(systemName: "arrow.up.forward.app")
+ .foregroundColor(.accentColor)
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ .help("uninstaller_show_in_finder".localized)
+
+ if !item.rawEvidence.isEmpty || !item.evidence.isEmpty {
+ Button {
+ withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
+ if isExpanded {
+ expandedItemIDs.remove(item.id)
+ } else {
+ expandedItemIDs.insert(item.id)
+ }
+ }
+ } label: {
+ Image(systemName: isExpanded ? "chevron.up.circle.fill" : "info.circle")
+ .foregroundColor(isExpanded ? .accentColor : .secondary)
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ .help("uninstaller_evidence_why_flagged".localized)
+ }
+ }
+
+ if isExpanded {
+ EvidenceCardView(
+ appName: item.appName,
+ bundleID: item.bundleID,
+ evidence: item.rawEvidence,
+ artifactEvidence: item.evidence,
+ score: item.score,
+ tier: item.confidence
+ )
+ .padding(.leading, 28)
+ .padding(.top, 2)
+ }
+ }
+ .padding(10)
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.4))
+ .cornerRadius(8)
+ }
+
+ private func iconForURL(_ url: URL) -> String {
+ let path = url.path
+ if path.contains("/Preferences/") { return "gearshape.fill" }
+ if path.contains("/Application Support/") { return "folder.fill" }
+ if path.contains("/Caches/") { return "archivebox.fill" }
+ if path.contains("/Containers/") || path.contains("/Group Containers/") { return "shippingbox.fill" }
+ if path.contains("/LaunchAgents/") || path.contains("/LaunchDaemons/") { return "bolt.horizontal.fill" }
+ if path.contains("/Logs/") { return "doc.text.fill" }
+ return "doc.fill"
+ }
+}
+
+public struct ConfidenceBadgeView: View {
+ let tier: ConfidenceTier
+
+ public init(tier: ConfidenceTier) {
+ self.tier = tier
+ }
+
+ private var color: Color {
+ switch tier {
+ case .guaranteed: return .green
+ case .veryLikely: return .blue
+ case .possible: return .orange
+ case .ignore: return .gray
+ }
+ }
+
+ private var icon: String {
+ switch tier {
+ case .guaranteed: return "checkmark.shield.fill"
+ case .veryLikely: return "shield.fill"
+ case .possible: return "questionmark.circle.fill"
+ case .ignore: return "slash.circle"
+ }
+ }
+
+ public var body: some View {
+ HStack(spacing: 3) {
+ Image(systemName: icon)
+ .font(.system(size: 8, weight: .bold))
+ Text(tier.displayKey.localized)
+ .font(.system(size: 9, weight: .semibold))
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .foregroundStyle(color)
+ .background(Capsule().fill(color.opacity(0.12)))
+ .overlay(
+ Capsule().strokeBorder(color.opacity(0.25), lineWidth: 0.8)
+ )
+ }
+}
+
+public struct EvidenceCardView: View {
+ let appName: String
+ let bundleID: String?
+ let evidence: Set
+ let artifactEvidence: [ArtifactEvidence]
+ let score: Int
+ let tier: ConfidenceTier
+
+ public init(
+ appName: String,
+ bundleID: String?,
+ evidence: Set,
+ artifactEvidence: [ArtifactEvidence] = [],
+ score: Int = 0,
+ tier: ConfidenceTier = .possible
+ ) {
+ self.appName = appName
+ self.bundleID = bundleID
+ self.evidence = evidence
+ self.artifactEvidence = artifactEvidence
+ self.score = score
+ self.tier = tier
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass.circle.fill")
+ .foregroundColor(.accentColor)
+ .font(.caption)
+ Text("uninstaller_evidence_card_title".localized)
+ .font(.caption)
+ .fontWeight(.bold)
+ Spacer()
+ Text("Score: \(score)")
+ .font(.system(size: 9, design: .monospaced))
+ .foregroundColor(.secondary)
+ }
+
+ if !evidence.isEmpty {
+ let context = ExplanationContext(bundleID: bundleID, appName: appName, teamID: nil)
+ let grouped = EvidenceExplanations.explanations(for: evidence, context: context)
+ ForEach(Array(grouped.keys.sorted(by: { $0.rawValue < $1.rawValue })), id: \.self) { category in
+ if let items = grouped[category], !items.isEmpty {
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(items, id: \.self) { expl in
+ HStack(alignment: .top, spacing: 4) {
+ Text("•")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(expl.title)
+ .font(.system(size: 10, weight: .semibold))
+ Text(expl.description)
+ .font(.system(size: 9))
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ }
+ }
+ }
+ } else if !artifactEvidence.isEmpty {
+ ForEach(artifactEvidence, id: \.self) { art in
+ HStack(spacing: 4) {
+ Text("•")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Text("\(String(describing: art.source)) (+\(art.weight))")
+ .font(.system(size: 9))
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ }
+ .padding(8)
+ .background(Color.accentColor.opacity(0.06))
+ .cornerRadius(6)
+ }
+}
diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift
index 2630a3a..6b48a42 100644
--- a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift
+++ b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift
@@ -603,14 +603,57 @@ public actor UninstallerService {
return freed
}
+ public func removeLeftovers(_ items: [LeftoverItem], bypassTrash: Bool = false) async throws -> Int64 {
+ let shouldBypass = bypassTrash
+ var freed: Int64 = 0
+ for item in items {
+ do {
+ if shouldBypass {
+ try safetyManager.validate(url: item.url, policy: .uninstall)
+ try FileManager.default.removeItem(at: item.url)
+ } else {
+ try await trashManager.trashItem(at: item.url)
+ }
+ freed += item.sizeBytes
+ } catch {
+ Logger.uninstaller.error("Failed to remove leftover \(item.url.path): \(error.localizedDescription)")
+ }
+ }
+ return freed
+ }
+
+ public func removeLeftovers(urls: [URL], bypassTrash: Bool = false) async throws -> Int64 {
+ let shouldBypass = bypassTrash
+ var freed: Int64 = 0
+ for url in urls {
+ do {
+ let size = FileManager.default.getDirectorySize(url: url)
+ if shouldBypass {
+ try safetyManager.validate(url: url, policy: .uninstall)
+ try FileManager.default.removeItem(at: url)
+ } else {
+ try await trashManager.trashItem(at: url)
+ }
+ freed += size
+ } catch {
+ Logger.uninstaller.error("Failed to remove leftover \(url.path): \(error.localizedDescription)")
+ }
+ }
+ return freed
+ }
+
// MARK: - Uninstall
- public func uninstall(app: AppInfo, bypassTrash: Bool = false, emptyTrashImmediately: Bool = false) async throws {
+ @discardableResult
+ public func uninstall(app: AppInfo, bypassTrash: Bool = false, emptyTrashImmediately: Bool = false) async throws -> VerificationReport? {
if !app.versions.isEmpty {
+ var combinedLeftovers: [LeftoverItem] = []
for versionApp in app.versions {
- try await uninstall(app: versionApp, bypassTrash: bypassTrash, emptyTrashImmediately: emptyTrashImmediately)
+ if let report = try await uninstall(app: versionApp, bypassTrash: bypassTrash, emptyTrashImmediately: emptyTrashImmediately) {
+ combinedLeftovers.append(contentsOf: report.items)
+ }
}
- return
+ return combinedLeftovers.isEmpty ? nil : VerificationReport(appName: app.name, bundleID: app.bundleID, items: combinedLeftovers)
}
Logger.uninstaller.info("Uninstalling '\(app.name, privacy: .public)' bypassTrash=\(bypassTrash)")
@@ -723,7 +766,9 @@ public actor UninstallerService {
} else {
Logger.uninstaller.info("0 leftovers — clean uninstall of '\(app.name, privacy: .public)'")
}
+ return report
}
+ return nil
}
// MARK: - Private helpers
diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift
index 5915c00..2260fce 100644
--- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift
+++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift
@@ -7,11 +7,33 @@ private extension Logger {
static let uninstallerView = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "UninstallerView")
}
+enum UninstallerTab: String, CaseIterable, Identifiable {
+ case applications
+ case leftovers
+
+ var id: String { rawValue }
+
+ var localizedTitle: String {
+ switch self {
+ case .applications: return "uninstaller_tab_apps".localized
+ case .leftovers: return "uninstaller_tab_leftovers".localized
+ }
+ }
+
+ var iconName: String {
+ switch self {
+ case .applications: return "square.grid.2x2"
+ case .leftovers: return "shippingbox.and.arrow.backward"
+ }
+ }
+}
+
struct UninstallerView: View {
let settings: AppSettings
let navigateToCleanup: () -> Void
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@State private var service = UninstallerService()
+ @State private var selectedTab: UninstallerTab = .applications
@State private var allApps: [UninstallerService.AppInfo] = []
@State private var selectedApp: UninstallerService.AppInfo?
@State private var searchText = ""
@@ -24,10 +46,13 @@ struct UninstallerView: View {
@State private var isDeepScanning = false
@State private var deepScanCompleted = 0
@State private var deepScanTotal = 0
+ @State private var scanTask: Task? = nil
@State private var expandedConfidenceTiers: Set = [.guaranteed, .veryLikely, .possible]
@State private var versionToUninstall: UninstallerService.AppInfo?
@State private var showingVersionConfirmation = false
@State private var selectedVersionID: UUID? = nil
+ @State private var postUninstallReport: VerificationReport? = nil
+ @State private var showingPostUninstallSheet = false
private func sameAppURL(_ lhs: URL, _ rhs: URL) -> Bool {
NormalizedPath.key(lhs) == NormalizedPath.key(rhs)
@@ -49,106 +74,24 @@ struct UninstallerView: View {
var body: some View {
GlassEffectContainer {
- GeometryReader { geometry in
- HStack(spacing: 0) {
- // Apps List
- VStack(spacing: 0) {
- if isLoading {
- AnimatedScanView(
- title: "uninstaller_scanning_apps".localized,
- subtitle: service.progress.message,
- currentStep: service.progress.currentStep,
- totalSteps: service.progress.totalSteps
- )
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- VStack(spacing: 0) {
- if isDeepScanning {
- VStack(spacing: 4) {
- ProgressView(value: Double(deepScanCompleted), total: Double(deepScanTotal))
- .progressViewStyle(.linear)
- .padding(.horizontal, 8)
- Text(String(format: "uninstaller.deep_scanning_progress".localized, deepScanCompleted, deepScanTotal))
- .font(.caption2)
- .foregroundColor(.secondary)
- }
- .padding(.vertical, 6)
- .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15))
- }
- List(filteredApps) { app in
- let unscan = app.scanState != .deepScanned
- let isThisAppUninstalling = isUninstalling && (selectedApp?.id == app.id)
- AppRowView(
- app: app,
- formatter: formatter,
- showRelatedFiles: settings.showRelatedFiles,
- isUnscannable: unscan,
- isUninstalling: isThisAppUninstalling
- )
- .contentShape(Rectangle())
- .onTapGesture {
- guard !isUninstalling else { return }
- guard app.scanState == .deepScanned else { return }
- selectedVersionID = nil
- selectedApp = app
- }
- .listRowBackground(
- (selectedApp?.id == app.id)
- ? Color.accentColor.opacity(0.1)
- : Color.clear
- )
- }
- .listStyle(.inset)
- .scrollContentBackground(.hidden)
- }
- }
- }
- .frame(width: max(250, geometry.size.width * 0.3)) // 30% width but min 250
- .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15))
-
- Divider()
-
- // Detail Area
- ZStack {
- if isUninstalling {
- VStack(spacing: 20) {
- ProgressView()
- .scaleEffect(1.4)
- .controlSize(.large)
- .padding(.bottom, 4)
-
- VStack(spacing: 6) {
- Text(String(format: "uninstaller_uninstalling_app".localized, uninstallingAppName))
- .font(.title3)
- .fontWeight(.bold)
- .multilineTextAlignment(.center)
-
- Text("uninstaller_uninstalling_sub".localized)
- .font(.subheadline)
- .foregroundColor(.secondary)
- .multilineTextAlignment(.center)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .glassCard(cornerRadius: 16)
- .padding(24)
- .transition(.opacity.combined(with: .scale(scale: 0.96)))
- } else if let app = selectedApp {
- appDetailView(app)
- .frame(maxWidth: .infinity)
- } else {
- dropZoneView
- .frame(maxWidth: .infinity)
- }
- }
- .animation(.spring(response: 0.35, dampingFraction: 0.8), value: isUninstalling)
- .layoutPriority(1) // Occupy remaining space
+ VStack(spacing: 0) {
+ if selectedTab == .applications {
+ applicationsContentView
+ } else {
+ OrphanedResidualsView(service: service, settings: settings)
}
- .padding(.top, 4)
}
}
.searchable(text: $searchText, placement: .toolbar, prompt: "uninstaller_search".localized)
.toolbar {
+ ToolbarItem(placement: .principal) {
+ GlassPillPicker(
+ items: UninstallerTab.allCases,
+ selection: $selectedTab,
+ icon: { $0.iconName },
+ label: { $0.localizedTitle }
+ )
+ }
ToolbarItem(placement: .automatic) {
// Scan mode badge
HStack(spacing: 4) {
@@ -178,7 +121,26 @@ struct UninstallerView: View {
.help("uninstaller_reload".localized)
}
}
- .onAppear(perform: loadApps)
+ .sheet(isPresented: $showingPostUninstallSheet) {
+ PostUninstallLeftoversSheet(
+ report: $postUninstallReport,
+ onClean: { selectedItems in
+ cleanPostUninstallLeftovers(selectedItems)
+ },
+ onDismiss: {
+ showingPostUninstallSheet = false
+ postUninstallReport = nil
+ }
+ )
+ }
+ .onAppear {
+ if allApps.isEmpty {
+ loadApps()
+ }
+ }
+ .onDisappear {
+ scanTask?.cancel()
+ }
.onChange(of: selectedApp?.id) { _, newID in
guard let id = newID else { return }
guard let app = allApps.first(where: { $0.id == id }) else { return }
@@ -244,10 +206,117 @@ struct UninstallerView: View {
}
}
+ private var applicationsContentView: some View {
+ GeometryReader { geometry in
+ HStack(spacing: 0) {
+ // Apps List
+ VStack(spacing: 0) {
+ if isLoading {
+ AnimatedScanView(
+ title: "uninstaller_scanning_apps".localized,
+ subtitle: service.progress.message,
+ currentStep: service.progress.currentStep,
+ totalSteps: service.progress.totalSteps
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ VStack(spacing: 0) {
+ if isDeepScanning {
+ VStack(spacing: 4) {
+ ProgressView(value: Double(min(deepScanCompleted, deepScanTotal)), total: Double(max(1, deepScanTotal)))
+ .progressViewStyle(.linear)
+ .padding(.horizontal, 8)
+ Text(String(format: "uninstaller.deep_scanning_progress".localized, min(deepScanCompleted, deepScanTotal), deepScanTotal))
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ .padding(.vertical, 6)
+ .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15))
+ }
+ List(filteredApps) { app in
+ let unscan = app.scanState != .deepScanned
+ let isThisAppUninstalling = isUninstalling && (selectedApp?.id == app.id)
+ AppRowView(
+ app: app,
+ formatter: formatter,
+ showRelatedFiles: settings.showRelatedFiles,
+ isUnscannable: unscan,
+ isUninstalling: isThisAppUninstalling
+ )
+ .contentShape(Rectangle())
+ .onTapGesture {
+ guard !isUninstalling else { return }
+ guard app.scanState == .deepScanned else { return }
+ selectedVersionID = nil
+ selectedApp = app
+ }
+ .listRowBackground(
+ (selectedApp?.id == app.id)
+ ? Color.accentColor.opacity(0.1)
+ : Color.clear
+ )
+ }
+ .listStyle(.inset)
+ .scrollContentBackground(.hidden)
+ }
+ }
+ }
+ .frame(width: max(250, geometry.size.width * 0.3)) // 30% width but min 250
+ .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15))
+
+ Divider()
+
+ // Detail Area
+ ZStack {
+ if isUninstalling {
+ VStack(spacing: 20) {
+ ProgressView()
+ .scaleEffect(1.4)
+ .controlSize(.large)
+ .padding(.bottom, 4)
+
+ VStack(spacing: 6) {
+ Text(String(format: "uninstaller_uninstalling_app".localized, uninstallingAppName))
+ .font(.title3)
+ .fontWeight(.bold)
+ .multilineTextAlignment(.center)
+
+ Text("uninstaller_uninstalling_sub".localized)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .glassCard(cornerRadius: 16)
+ .padding(24)
+ .transition(.opacity.combined(with: .scale(scale: 0.96)))
+ } else if let app = selectedApp {
+ appDetailView(app)
+ .frame(maxWidth: .infinity)
+ } else {
+ dropZoneView
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .animation(.spring(response: 0.35, dampingFraction: 0.8), value: isUninstalling)
+ .layoutPriority(1) // Occupy remaining space
+ }
+ .padding(.top, 4)
+ }
+ }
+
private func loadApps() {
+ scanTask?.cancel()
isLoading = true
- Task {
+ isDeepScanning = false
+ deepScanCompleted = 0
+ deepScanTotal = 0
+
+ scanTask = Task {
let fresh = (try? await service.scanAllApplications()) ?? []
+ guard !Task.isCancelled else { return }
+
allApps = fresh
isLoading = false
@@ -259,8 +328,10 @@ struct UninstallerView: View {
deepScanTotal = total
for app in fresh {
+ guard !Task.isCancelled else { break }
if let result = try? await service.deepScan(app, mode: settings.uninstallerScanMode) {
- deepScanCompleted += 1
+ guard !Task.isCancelled else { break }
+ deepScanCompleted = min(deepScanTotal, deepScanCompleted + 1)
if let idx = allApps.firstIndex(where: { $0.id == result.id || sameAppURL($0.url, result.url) }) {
allApps[idx] = result
}
@@ -269,11 +340,14 @@ struct UninstallerView: View {
}
deepScanCache[NormalizedPath.key(result.url)] = result
} else {
- deepScanCompleted += 1
+ guard !Task.isCancelled else { break }
+ deepScanCompleted = min(deepScanTotal, deepScanCompleted + 1)
}
}
- isDeepScanning = false
+ if !Task.isCancelled {
+ isDeepScanning = false
+ }
}
}
@@ -285,20 +359,24 @@ struct UninstallerView: View {
isUninstalling = false
}
do {
- try await service.uninstall(
+ let report = try await service.uninstall(
app: app,
bypassTrash: settings.bypassTrashOnUninstall,
emptyTrashImmediately: settings.emptyTrashImmediately
)
- if settings.showNotifications {
- let title = "uninstaller_complete_title".localized
- let body = String(format: "uninstaller_complete_body".localized, app.name)
- NotificationManager.shared.sendNotification(title: title, body: body)
+ await MainActor.run {
+ loadApps()
+ selectedApp = nil
+ if let report = report, report.hasLeftovers {
+ postUninstallReport = report
+ showingPostUninstallSheet = true
+ } else if settings.showNotifications {
+ let title = "uninstaller_complete_title".localized
+ let body = String(format: "uninstaller_complete_body".localized, app.name)
+ NotificationManager.shared.sendNotification(title: title, body: body)
+ }
}
-
- loadApps()
- selectedApp = nil
} catch {
Logger.uninstallerView.error("Uninstall failed: \(error.localizedDescription, privacy: .public)")
}
@@ -313,38 +391,43 @@ struct UninstallerView: View {
isUninstalling = false
}
do {
- try await service.uninstall(
+ let report = try await service.uninstall(
app: versionApp,
bypassTrash: settings.bypassTrashOnUninstall,
emptyTrashImmediately: settings.emptyTrashImmediately
)
- if settings.showNotifications {
- let title = "uninstaller_complete_title".localized
- let body = String(format: "uninstaller_version_deleted_body".localized, versionApp.version, parentApp.name)
- NotificationManager.shared.sendNotification(title: title, body: body)
- }
-
- let remaining = parentApp.versions.filter { NormalizedPath.key($0.url) != NormalizedPath.key(versionApp.url) }
-
- if remaining.isEmpty {
- allApps.removeAll { $0.id == parentApp.id }
- selectedApp = nil
- } else if remaining.count == 1 {
- var updatedParent = remaining[0]
- updatedParent.versions = []
- if let idx = allApps.firstIndex(where: { $0.id == parentApp.id }) {
- allApps[idx] = updatedParent
+ await MainActor.run {
+ if let report = report, report.hasLeftovers {
+ postUninstallReport = report
+ showingPostUninstallSheet = true
+ } else if settings.showNotifications {
+ let title = "uninstaller_complete_title".localized
+ let body = String(format: "uninstaller_version_deleted_body".localized, versionApp.version, parentApp.name)
+ NotificationManager.shared.sendNotification(title: title, body: body)
}
- selectedApp = updatedParent
- } else {
- var updatedParent = parentApp
- updatedParent.versions = remaining
- updatedParent.size = remaining.reduce(0) { $0 + $1.size }
- if let idx = allApps.firstIndex(where: { $0.id == parentApp.id }) {
- allApps[idx] = updatedParent
+
+ let remaining = parentApp.versions.filter { NormalizedPath.key($0.url) != NormalizedPath.key(versionApp.url) }
+
+ if remaining.isEmpty {
+ allApps.removeAll { $0.id == parentApp.id }
+ selectedApp = nil
+ } else if remaining.count == 1 {
+ var updatedParent = remaining[0]
+ updatedParent.versions = []
+ if let idx = allApps.firstIndex(where: { $0.id == parentApp.id }) {
+ allApps[idx] = updatedParent
+ }
+ selectedApp = updatedParent
+ } else {
+ var updatedParent = parentApp
+ updatedParent.versions = remaining
+ updatedParent.size = remaining.reduce(0) { $0 + $1.size }
+ if let idx = allApps.firstIndex(where: { $0.id == parentApp.id }) {
+ allApps[idx] = updatedParent
+ }
+ selectedApp = updatedParent
}
- selectedApp = updatedParent
}
} catch {
Logger.uninstallerView.error("Uninstall version failed: \(error.localizedDescription, privacy: .public)")
@@ -352,6 +435,36 @@ struct UninstallerView: View {
}
}
+ private func cleanPostUninstallLeftovers(_ items: [LeftoverItem]) {
+ guard !items.isEmpty else {
+ showingPostUninstallSheet = false
+ postUninstallReport = nil
+ return
+ }
+
+ Task {
+ do {
+ let freed = try await service.removeLeftovers(items, bypassTrash: settings.bypassTrashOnUninstall)
+ await MainActor.run {
+ self.showingPostUninstallSheet = false
+ self.postUninstallReport = nil
+
+ if settings.showNotifications {
+ let title = "uninstaller_complete_title".localized
+ let body = String(
+ format: "uninstaller_leftovers_cleaned_notification".localized,
+ Int64(items.count),
+ ByteCountFormatter.localizedString(fromByteCount: freed, countStyle: .file)
+ )
+ NotificationManager.shared.sendNotification(title: title, body: body)
+ }
+ }
+ } catch {
+ Logger.uninstallerView.error("Failed to clean leftovers: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+
private var dropZoneView: some View {
VStack(spacing: 20) {
ZStack {
diff --git a/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift b/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift
index 1f27bf6..16b82b4 100644
--- a/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift
+++ b/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift
@@ -54,6 +54,7 @@ public actor VerificationEngine {
)
var artifacts: [ScoredArtifact] = []
+ var artifactMap: [URL: (evidence: [ArtifactEvidence], rawEvidence: Set, tier: ConfidenceTier, size: Int64)] = [:]
let rule = await ruleRegistry.bestRule(for: identity)
for url in candidates {
@@ -63,10 +64,13 @@ public actor VerificationEngine {
let artifactEvidence = evidence.artifactEvidence(weights: weights) + ruleEvidence
let score = artifactEvidence.reduce(0) { $0 + $1.weight }
+ let ruleScore = ruleEvidence.reduce(0) { $0 + $1.weight }
+ let assessment = ConfidenceEngine.assess(evidence, ruleScore: ruleScore, identity: identity, weights: weights)
- artifacts.append(
- ScoredArtifact(url: url, score: score, evidence: artifactEvidence)
- )
+ let scored = ScoredArtifact(url: url, score: score, evidence: artifactEvidence)
+ artifacts.append(scored)
+ let size = FileManager.default.getDirectorySize(url: url)
+ artifactMap[url] = (artifactEvidence, evidence, assessment.tier, size)
}
let classified = ArtifactClassifier.classifyBatch(artifacts, thresholds: thresholds)
@@ -75,7 +79,30 @@ public actor VerificationEngine {
leftovers.append(contentsOf: classified.related.map(\.artifact))
leftovers.append(contentsOf: classified.developer.map(\.artifact))
- let report = VerificationReport(leftovers: leftovers)
+ var leftoverItems: [LeftoverItem] = []
+ for artifact in leftovers {
+ let details = artifactMap[artifact.url]
+ leftoverItems.append(
+ LeftoverItem(
+ url: artifact.url,
+ appName: identity.appName,
+ bundleID: identity.bundleID,
+ sizeBytes: details?.size ?? FileManager.default.getDirectorySize(url: artifact.url),
+ score: artifact.score,
+ evidence: details?.evidence ?? artifact.evidence,
+ rawEvidence: details?.rawEvidence ?? [],
+ confidence: details?.tier ?? .possible,
+ isSelected: true
+ )
+ )
+ }
+
+ let report = VerificationReport(
+ appName: identity.appName,
+ bundleID: identity.bundleID,
+ leftovers: leftovers,
+ items: leftoverItems
+ )
Logger.verification.info("\(report.count, privacy: .public) leftover(s) detected")
report.leftovers.forEach { artifact in
Logger.verification.debug(" leftover: \(artifact.url.path, privacy: .public) score=\(artifact.score)")
diff --git a/MacOSCleaner/Features/Uninstaller/VerificationReport.swift b/MacOSCleaner/Features/Uninstaller/VerificationReport.swift
index 13a6258..ff3f9b2 100644
--- a/MacOSCleaner/Features/Uninstaller/VerificationReport.swift
+++ b/MacOSCleaner/Features/Uninstaller/VerificationReport.swift
@@ -1,13 +1,89 @@
import Foundation
+public struct LeftoverItem: Identifiable, Sendable, Hashable {
+ public let id: UUID
+ public let url: URL
+ public let appName: String
+ public let bundleID: String?
+ public let sizeBytes: Int64
+ public let score: Int
+ public let evidence: [ArtifactEvidence]
+ public let rawEvidence: Set
+ public let confidence: ConfidenceTier
+ public var isSelected: Bool
+
+ public init(
+ id: UUID = UUID(),
+ url: URL,
+ appName: String,
+ bundleID: String?,
+ sizeBytes: Int64,
+ score: Int,
+ evidence: [ArtifactEvidence] = [],
+ rawEvidence: Set = [],
+ confidence: ConfidenceTier = .possible,
+ isSelected: Bool = true
+ ) {
+ self.id = id
+ self.url = NormalizedPath.canonicalize(url)
+ self.appName = appName
+ self.bundleID = bundleID
+ self.sizeBytes = sizeBytes
+ self.score = score
+ self.evidence = evidence
+ self.rawEvidence = rawEvidence
+ self.confidence = confidence
+ self.isSelected = isSelected
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(NormalizedPath.key(url))
+ }
+
+ public static func == (lhs: LeftoverItem, rhs: LeftoverItem) -> Bool {
+ NormalizedPath.key(lhs.url) == NormalizedPath.key(rhs.url) && lhs.isSelected == rhs.isSelected
+ }
+}
+
public struct VerificationReport: Sendable {
+ public let appName: String
+ public let bundleID: String?
public let leftovers: [ScoredArtifact]
+ public let items: [LeftoverItem]
public let count: Int
+ public var totalSizeBytes: Int64 {
+ items.reduce(0) { $0 + $1.sizeBytes }
+ }
+
public var hasLeftovers: Bool { count > 0 }
- public init(leftovers: [ScoredArtifact]) {
+ public init(
+ appName: String = "",
+ bundleID: String? = nil,
+ leftovers: [ScoredArtifact] = [],
+ items: [LeftoverItem] = []
+ ) {
+ self.appName = appName
+ self.bundleID = bundleID
self.leftovers = leftovers
- self.count = leftovers.count
+ if !items.isEmpty {
+ self.items = items
+ self.count = items.count
+ } else {
+ self.items = leftovers.map {
+ LeftoverItem(
+ url: $0.url,
+ appName: appName,
+ bundleID: bundleID,
+ sizeBytes: FileManager.default.getDirectorySize(url: $0.url),
+ score: $0.score,
+ evidence: $0.evidence,
+ confidence: .possible
+ )
+ }
+ self.count = leftovers.count
+ }
}
}
+
diff --git a/MacOSCleaner/Features/Updates/UpdateAvailableView.swift b/MacOSCleaner/Features/Updates/UpdateAvailableView.swift
index 0634fa3..04f3a44 100644
--- a/MacOSCleaner/Features/Updates/UpdateAvailableView.swift
+++ b/MacOSCleaner/Features/Updates/UpdateAvailableView.swift
@@ -173,10 +173,10 @@ struct UpdateAvailableView: View {
#Preview {
UpdateAvailableView(
update: AvailableUpdate(
- version: "2.2.0",
- dmgURL: URL(string: "https://github.com/AlexTkDev/MacOSCleaner/releases/download/2.2.0/MacOSCleaner.dmg")
+ version: "2.3.0",
+ dmgURL: URL(string: "https://github.com/AlexTkDev/MacOSCleaner/releases/download/2.3.0/MacOSCleaner.dmg")
),
- currentVersion: "2.1.1",
+ currentVersion: "2.2.0",
onDismissLater: {},
onDismissForVersion: {}
)
diff --git a/MacOSCleaner/Infrastructure/FileScanner.swift b/MacOSCleaner/Infrastructure/FileScanner.swift
index e9f8aea..6dda7cb 100644
--- a/MacOSCleaner/Infrastructure/FileScanner.swift
+++ b/MacOSCleaner/Infrastructure/FileScanner.swift
@@ -33,7 +33,7 @@ public actor FileScanner {
guard let enumerator = fm.enumerator(
at: url,
- includingPropertiesForKeys: [.isDirectoryKey],
+ includingPropertiesForKeys: [.isDirectoryKey, .isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey],
options: []
) else {
continue
@@ -50,6 +50,15 @@ public actor FileScanner {
continue
}
+ if let values = try? fileURL.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey, .isDirectoryKey]),
+ let isUbiquitous = values.isUbiquitousItem, isUbiquitous,
+ values.ubiquitousItemDownloadingStatus == .notDownloaded {
+ if values.isDirectory == true {
+ enumerator.skipDescendants()
+ }
+ continue
+ }
+
currentBatch.append(fileURL)
let now = Date()
diff --git a/MacOSCleaner/Infrastructure/PermissionsManager.swift b/MacOSCleaner/Infrastructure/PermissionsManager.swift
index c5ea4b0..b437322 100644
--- a/MacOSCleaner/Infrastructure/PermissionsManager.swift
+++ b/MacOSCleaner/Infrastructure/PermissionsManager.swift
@@ -174,22 +174,46 @@ public final class PermissionsManager {
/// Opens Accessibility settings in System Settings.
public func openAccessibilitySettings() {
- let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
- NSWorkspace.shared.open(url)
- Logger.permissions.info("Opened Accessibility settings")
+ let urls = [
+ "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
+ "x-apple.systempreferences:com.apple.preference.security"
+ ]
+ for urlString in urls {
+ if let url = URL(string: urlString), NSWorkspace.shared.open(url) {
+ Logger.permissions.info("Opened Accessibility settings via: \(urlString, privacy: .public)")
+ return
+ }
+ }
}
/// Opens Automation settings in System Settings.
public func openAutomationSettings() {
- let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")!
- NSWorkspace.shared.open(url)
- Logger.permissions.info("Opened Automation settings")
+ let urls = [
+ "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Automation",
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation",
+ "x-apple.systempreferences:com.apple.preference.security"
+ ]
+ for urlString in urls {
+ if let url = URL(string: urlString), NSWorkspace.shared.open(url) {
+ Logger.permissions.info("Opened Automation settings via: \(urlString, privacy: .public)")
+ return
+ }
+ }
}
/// Opens System Settings Privacy & Security main page.
public func openPrivacySettings() {
- let url = URL(string: "x-apple.systempreferences:com.apple.preference.security")!
- NSWorkspace.shared.open(url)
+ let urls = [
+ "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension",
+ "x-apple.systempreferences:com.apple.preference.security"
+ ]
+ for urlString in urls {
+ if let url = URL(string: urlString), NSWorkspace.shared.open(url) {
+ Logger.permissions.info("Opened Privacy settings via: \(urlString, privacy: .public)")
+ return
+ }
+ }
}
/// Shows the guidance panel to the user.
diff --git a/MacOSCleaner/Infrastructure/PrivilegedTaskRunner.swift b/MacOSCleaner/Infrastructure/PrivilegedTaskRunner.swift
index 55136ab..0f266af 100644
--- a/MacOSCleaner/Infrastructure/PrivilegedTaskRunner.swift
+++ b/MacOSCleaner/Infrastructure/PrivilegedTaskRunner.swift
@@ -13,7 +13,7 @@ public actor PrivilegedTaskRunner {
}
/// Executes a shell command with administrator privileges.
- /// - Parameter command: The command to execute (e.g. `tmutil deletelocalsnapshots /`)
+ /// - Parameter command: The command to execute (e.g. `tmutil thinlocalsnapshots / 10000000000 4`)
/// - Returns: The stdout output of the command.
/// - Throws: An error if execution fails or user cancels the password prompt.
public static func runAsAdmin(command: String) async throws -> String {
diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj
index a4e311e..b027ac2 100644
--- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj
+++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj
@@ -67,6 +67,7 @@
463FC342724658A6B4A4B832 /* RegistryPathsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2D7518461432F2431138CF /* RegistryPathsTests.swift */; };
472B1E10D4A7B420B64CADD2 /* CleanupCategory+FixtureMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7117987756095224817906 /* CleanupCategory+FixtureMapping.swift */; };
4A009822020612EC47DF313F /* DiskRingsChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7E447220D703C77406FE17B /* DiskRingsChartView.swift */; };
+ 4A5960C69A77C64377A07DDD /* LocalizationCompletenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A09871694E5D848CE7929087 /* LocalizationCompletenessTests.swift */; };
4AA3166EB6B0214431D9698D /* FileSystemContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2054AC6A4525A796258743 /* FileSystemContext.swift */; };
4BC8DDBA6B51777F46BC4BA0 /* CommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1DD590DCE0D34A3129D242 /* CommandRunner.swift */; };
4C9D979A3DEB7D4F0CD486FA /* EvidenceGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D16F75C43CE1DD60ADDFE8E /* EvidenceGraph.swift */; };
@@ -140,8 +141,10 @@
9CEF93EA4AC1719CDF985A58 /* LogicProRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0F61F1833BCBAD84509BE0 /* LogicProRule.swift */; };
9D8FA0DA512C5B78E8983175 /* TrashManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D604E53BC61E11EDDC30B90 /* TrashManagerTests.swift */; };
9E12B9110CAF741A51367B1C /* ProcessesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408FDF60DE1F0734F49ADE65 /* ProcessesView.swift */; };
+ 9E1EA68162173099492D9661 /* OrphanedResidualsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBE143C9DCB1484CADF91FF1 /* OrphanedResidualsView.swift */; };
9F48B8272BC8F88521B69141 /* ScanActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1342D347B34E14E2DB3428 /* ScanActor.swift */; };
9F8E85F32C881C75E07F53ED /* MacOSCleanerShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24AF2398FDC64924F7F5C76C /* MacOSCleanerShortcuts.swift */; };
+ A026203CB935EBE69073E537 /* PostUninstallLeftoversSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3C14ACE1F94B5CA8FFCEAC /* PostUninstallLeftoversSheet.swift */; };
A292F5B0235283E514C9DC98 /* CleanCategoryIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5BDB61F81453375702396EA /* CleanCategoryIntent.swift */; };
A2DD08E0856A71B50F9A89C4 /* GeneratedCleanupPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBAF8D65EF28A9DB89B6020A /* GeneratedCleanupPaths.swift */; };
A50B617B0D6B7F32B53C879A /* ProcessGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12EE41C2EEF2151D9A20EAC7 /* ProcessGroup.swift */; };
@@ -406,8 +409,10 @@
9CABB543E062966462CFB6E6 /* TimeMachineScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeMachineScanner.swift; sourceTree = ""; };
9D5372D4A260420EA85CF98A /* RegistryTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistryTypes.swift; sourceTree = ""; };
9E5214D1AD87C67377076C08 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; };
+ 9F3C14ACE1F94B5CA8FFCEAC /* PostUninstallLeftoversSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostUninstallLeftoversSheet.swift; sourceTree = ""; };
9F655CB3931FC097B4A8836C /* VerificationEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationEngineTests.swift; sourceTree = ""; };
9FE18494B602F06C4A577ABB /* WeightABTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightABTests.swift; sourceTree = ""; };
+ A09871694E5D848CE7929087 /* LocalizationCompletenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationCompletenessTests.swift; sourceTree = ""; };
A3BC06C9BBE41099A9D54344 /* AppIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentity.swift; sourceTree = ""; };
A5EFE9DE1E343AF8F2490C67 /* DashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModel.swift; sourceTree = ""; };
A9763DE94720F8AF4B9C0AE3 /* MdfindCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MdfindCache.swift; sourceTree = ""; };
@@ -503,6 +508,7 @@
F9D80391D31B2181E0D29B17 /* ScoringWeights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoringWeights.swift; sourceTree = ""; };
FB93BAD2C057E76B2E0DBD80 /* CommandRunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunnerTests.swift; sourceTree = ""; };
FBAF8D65EF28A9DB89B6020A /* GeneratedCleanupPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedCleanupPaths.swift; sourceTree = ""; };
+ FBE143C9DCB1484CADF91FF1 /* OrphanedResidualsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrphanedResidualsView.swift; sourceTree = ""; };
FCA3EAE8C88730B9286962CA /* SystemMaintenanceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMaintenanceService.swift; sourceTree = ""; };
FD67C865E2D4BF1DF1A3E454 /* HelperAppCollapser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperAppCollapser.swift; sourceTree = ""; };
FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceSource.swift; sourceTree = ""; };
@@ -635,6 +641,7 @@
4C881B157A7F5ACBF84D7E64 /* LanguageManagerTests.swift */,
B8F86A55A2FB7771CD468CEC /* LaunchServiceManagerTests.swift */,
1157C1A1D919A5A05D6F76FE /* LiveResidualAuditTests.swift */,
+ A09871694E5D848CE7929087 /* LocalizationCompletenessTests.swift */,
670B4B2D58E43D6491C71D88 /* LSRegisterCacheTests.swift */,
D8F77DB5F928748036C709D4 /* MockCommandRunner.swift */,
32C30D4CD2BF26C221E7FFBB /* OrphanScannerTests.swift */,
@@ -761,9 +768,11 @@
C950BCC1062A364886439405 /* EvidenceProbe.swift */,
FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */,
FD67C865E2D4BF1DF1A3E454 /* HelperAppCollapser.swift */,
+ FBE143C9DCB1484CADF91FF1 /* OrphanedResidualsView.swift */,
7011F050981F99F9952E3D51 /* OrphanScanner.swift */,
44C2514ADFBB5E6A83A2FB55 /* ParentLinker.swift */,
C145D57FE94451F56F45C7DD /* PlistAnalyzer.swift */,
+ 9F3C14ACE1F94B5CA8FFCEAC /* PostUninstallLeftoversSheet.swift */,
543E2F8CAD125A9356D10CCB /* RegistryPathTemplates.swift */,
F9D80391D31B2181E0D29B17 /* ScoringWeights.swift */,
C4937E0DC790BB50CA8F8798 /* SnapshotStore.swift */,
@@ -1169,6 +1178,7 @@
E9773085984D381C6D8A9F53 /* LanguageManagerTests.swift in Sources */,
BC7D6B5A297C83B50F760387 /* LaunchServiceManagerTests.swift in Sources */,
8E85254D5D4361719099D2E9 /* LiveResidualAuditTests.swift in Sources */,
+ 4A5960C69A77C64377A07DDD /* LocalizationCompletenessTests.swift in Sources */,
5805C9625FA33C2762194266 /* MockCommandRunner.swift in Sources */,
AA617FBF4572D43230BA56F9 /* OrphanScannerTests.swift in Sources */,
709EC6CB285002C70A3A1690 /* PathTokenNormalizeTests.swift in Sources */,
@@ -1297,6 +1307,7 @@
1BE271D883FB9BB89236DA15 /* OperationRecord.swift in Sources */,
5A80FF0AAC67C55F53950A6A /* OperationRisk.swift in Sources */,
F85790B83CFEBE99BA017B70 /* OrphanScanner.swift in Sources */,
+ 9E1EA68162173099492D9661 /* OrphanedResidualsView.swift in Sources */,
93A30220DD22B84B9473DB06 /* ParallelsRule.swift in Sources */,
0025FDCCBE620B27A88A17A1 /* ParentLinker.swift in Sources */,
60B681FF2018B69AE6F42A8E /* PermissionsManager.swift in Sources */,
@@ -1304,6 +1315,7 @@
D58AAD770F518A3CF40ABC8B /* PlistAnalyzer.swift in Sources */,
60D5F2F84783437BEABB9834 /* PlistContentCache.swift in Sources */,
3922CCC01F73A38F68A94C2A /* PosixScanner.swift in Sources */,
+ A026203CB935EBE69073E537 /* PostUninstallLeftoversSheet.swift in Sources */,
B247C7CA77556893B3708B3F /* PrivateCatalogSnapshot.swift in Sources */,
24E45196F972C7AA491499E0 /* PrivilegedTaskRunner.swift in Sources */,
513AE7B833D9E160B63C5761 /* ProbeCaches.swift in Sources */,
@@ -1424,7 +1436,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
- MARKETING_VERSION = 2.1.1;
+ MARKETING_VERSION = 2.2.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner;
SDKROOT = macosx;
@@ -1523,7 +1535,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
- MARKETING_VERSION = 2.1.1;
+ MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
diff --git a/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift b/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift
index 5dfb883..f54d000 100644
--- a/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift
@@ -46,9 +46,14 @@ final class AppSettingsTests: XCTestCase {
XCTAssertFalse(settings.emptyTrashImmediately)
XCTAssertTrue(settings.enableAI)
XCTAssertFalse(settings.isDebugMode)
+ XCTAssertEqual(settings.projectArtifactsOlderThanDays, 60)
}
- // MARK: - Persistence
+ func testProjectArtifactsAgePersisted() {
+ let settings = AppSettings()
+ settings.projectArtifactsOlderThanDays = 90
+ XCTAssertEqual(UserDefaults.standard.integer(forKey: "settings_projectArtifactsOlderThanDays"), 90)
+ }
func testLanguagePersisted() {
let settings = AppSettings()
@@ -98,6 +103,7 @@ final class AppSettingsTests: XCTestCase {
settings.emptyTrashImmediately = true
settings.enableAI = false
settings.isDebugMode = true
+ settings.projectArtifactsOlderThanDays = 180
settings.resetAll()
@@ -112,5 +118,6 @@ final class AppSettingsTests: XCTestCase {
XCTAssertFalse(settings.emptyTrashImmediately)
XCTAssertTrue(settings.enableAI)
XCTAssertFalse(settings.isDebugMode)
+ XCTAssertEqual(settings.projectArtifactsOlderThanDays, 60)
}
}
\ No newline at end of file
diff --git a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift
index b388a00..74fc26a 100644
--- a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift
@@ -38,6 +38,7 @@ struct CleanupEngineTests {
@Test("Timeout error description")
func timeoutErrorDescription() {
+ LanguageManager.shared.setLanguage(.english)
let error = CleanupEngineError.timeout
#expect(error.errorDescription != nil)
#expect(error.errorDescription!.contains("timed out"))
@@ -45,12 +46,14 @@ struct CleanupEngineTests {
@Test("Safety violation error description")
func safetyViolationErrorDescription() {
+ LanguageManager.shared.setLanguage(.english)
let error = CleanupEngineError.safetyViolation("/System")
#expect(error.errorDescription == "Safety violation: /System")
}
@Test("Command failed error description")
func commandFailedErrorDescription() {
+ LanguageManager.shared.setLanguage(.english)
let error = CleanupEngineError.commandFailed("brew not found")
#expect(error.errorDescription == "Command failed: brew not found")
}
@@ -612,6 +615,14 @@ struct CleanupEngineTests {
#expect(results.first?.label == "Time Machine Snapshots")
}
+ @Test("DNS flush dry run")
+ func dnsFlushDryRun() async throws {
+ let engine = CleanupEngine()
+ let results = try await engine.cleanDNSFlush(dryRun: true, progress: nil)
+ #expect(results.count == 1)
+ #expect(results.first?.label == "DNS Cache")
+ }
+
@Test("IOS backups dry run")
func iosBackupsDryRun() async throws {
let engine = CleanupEngine()
diff --git a/MacOSCleaner/MacOSCleanerTests/CleanupOptionsTests.swift b/MacOSCleaner/MacOSCleanerTests/CleanupOptionsTests.swift
index ff8df4b..1c1208a 100644
--- a/MacOSCleaner/MacOSCleanerTests/CleanupOptionsTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/CleanupOptionsTests.swift
@@ -9,6 +9,8 @@ final class CleanupOptionsTests: XCTestCase {
XCTAssertTrue(options.cleanMaven)
XCTAssertTrue(options.cleanModCache)
XCTAssertTrue(options.cleanProjects)
+ XCTAssertFalse(options.cleanProjectArtifacts)
+ XCTAssertEqual(options.projectArtifactsOlderThanDays, 60)
}
func testAllCategoriesAlwaysIncluded() {
@@ -79,6 +81,16 @@ final class CleanupOptionsTests: XCTestCase {
XCTAssertFalse(categories.contains(.scatteredJunk))
}
+ func testProjectArtifactsDisabledByDefault() {
+ let options = CleanupOptions()
+ XCTAssertFalse(options.categories().contains(.projectBuildArtifacts))
+ }
+
+ func testProjectArtifactsEnabledAddsCategory() {
+ let options = CleanupOptions(cleanProjectArtifacts: true)
+ XCTAssertTrue(options.categories().contains(.projectBuildArtifacts))
+ }
+
func testOptionsEquality() {
let a = CleanupOptions(cleanDSStore: false)
let b = CleanupOptions(cleanDSStore: false)
diff --git a/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift b/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift
index b19eb43..ec82076 100644
--- a/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift
@@ -2,63 +2,176 @@ import XCTest
@testable import MacOSCleaner
final class DiskScannerTests: XCTestCase {
- var scanner: DiskScanner!
- var tempDirectory: URL!
+ private var tempDir: URL!
override func setUp() async throws {
- scanner = DiskScanner()
- tempDirectory = FileManager.default.temporaryDirectory
- .appendingPathComponent("MacOSCleanerTests_DiskScanner_\(UUID().uuidString)", isDirectory: true)
-
- if FileManager.default.fileExists(atPath: tempDirectory.path) {
- try? FileManager.default.removeItem(at: tempDirectory)
- }
- try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
+ tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
}
override func tearDown() async throws {
- if FileManager.default.fileExists(atPath: tempDirectory.path) {
- try? FileManager.default.removeItem(at: tempDirectory)
- }
+ try? FileManager.default.removeItem(at: tempDir)
+ }
+
+ func testDiskScanner_scansDirectoryTreeHierarchically() async throws {
+ // Create subdirectories
+ let docsDir = tempDir.appendingPathComponent("Documents", isDirectory: true)
+ let subDocsDir = docsDir.appendingPathComponent("Work", isDirectory: true)
+ let mediaDir = tempDir.appendingPathComponent("Media", isDirectory: true)
+
+ try FileManager.default.createDirectory(at: subDocsDir, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: mediaDir, withIntermediateDirectories: true)
+
+ // Create dummy files
+ let file1 = tempDir.appendingPathComponent("root_file.txt")
+ try "Hello World".write(to: file1, atomically: true, encoding: .utf8)
+
+ let file2 = docsDir.appendingPathComponent("doc1.pdf")
+ try Data(repeating: 0x41, count: 10000).write(to: file2)
+
+ let file3 = subDocsDir.appendingPathComponent("work_notes.txt")
+ try Data(repeating: 0x42, count: 25000).write(to: file3)
+
+ let file4 = mediaDir.appendingPathComponent("video.mp4")
+ try Data(repeating: 0x43, count: 50000).write(to: file4)
+
+ let scanner = DiskScanner()
+ let root = try await scanner.scan(directoryURL: tempDir) { _ in }
+
+ XCTAssertEqual(root.url.standardizedFileURL, tempDir.standardizedFileURL)
+ XCTAssertTrue(root.isDirectory)
+ XCTAssertFalse(root.isPackage)
+ XCTAssertGreaterThan(root.size, 0)
+ XCTAssertEqual(root.fileCount, 4)
+
+ let children = root.children ?? []
+ XCTAssertEqual(children.count, 3) // Media, Documents, root_file.txt
+
+ // Items sorted by size descending: Media (50KB) > Documents (35KB) > root_file.txt
+ XCTAssertEqual(children[0].name, "Media")
+ XCTAssertEqual(children[0].fileType, .all)
+ XCTAssertEqual(children[0].fileCount, 1)
+
+ XCTAssertEqual(children[1].name, "Documents")
+ XCTAssertEqual(children[1].fileCount, 2)
+
+ // Check nested drill-down under Documents
+ let docChildren = children[1].children ?? []
+ XCTAssertEqual(docChildren.count, 2) // Work, doc1.pdf
+ }
+
+ func testDiskScanner_recognizesPackages() async throws {
+ let appBundle = tempDir.appendingPathComponent("SampleApp.app", isDirectory: true)
+ let contentsDir = appBundle.appendingPathComponent("Contents", isDirectory: true)
+ let macosDir = contentsDir.appendingPathComponent("MacOS", isDirectory: true)
+ try FileManager.default.createDirectory(at: macosDir, withIntermediateDirectories: true)
+
+ let binary = macosDir.appendingPathComponent("SampleBinary")
+ try Data(repeating: 0xAA, count: 30000).write(to: binary)
+
+ let scanner = DiskScanner()
+ let root = try await scanner.scan(directoryURL: tempDir) { _ in }
+
+ let children = root.children ?? []
+ XCTAssertEqual(children.count, 1)
+
+ let appItem = children[0]
+ XCTAssertEqual(appItem.name, "SampleApp.app")
+ XCTAssertTrue(appItem.isDirectory)
+ XCTAssertTrue(appItem.isPackage)
+ XCTAssertEqual(appItem.fileType, .apps)
+ XCTAssertGreaterThan(appItem.size, 0)
+ XCTAssertNil(appItem.children) // Package internal folders are not expanded as child folders
}
- func testDirectoryScanningAndSizes() async throws {
- // DiskScanner returns flattened files above 1 MB (not parent folders).
- let dir1 = tempDirectory.appendingPathComponent("Movies")
- let dir2 = tempDirectory.appendingPathComponent("Documents")
- try FileManager.default.createDirectory(at: dir1, withIntermediateDirectories: true)
- try FileManager.default.createDirectory(at: dir2, withIntermediateDirectories: true)
-
- // 1.5 MB video — included
- let fileVideo = dir1.appendingPathComponent("video.mp4")
- let videoData = Data(repeating: 0, count: 1024 * 1024 + 512 * 1024)
- try videoData.write(to: fileVideo)
-
- // 300 KB doc — below 1 MB threshold, excluded
- let fileDoc = dir2.appendingPathComponent("document.docx")
- let docData = Data(repeating: 0, count: 300 * 1024)
- try docData.write(to: fileDoc)
-
- let items = try await scanner.scan(directoryURL: tempDirectory) { _ in }
-
- XCTAssertEqual(items.count, 1)
- let videoItem = try XCTUnwrap(items.first { $0.name == "video.mp4" })
- XCTAssertFalse(videoItem.isDirectory)
- XCTAssertEqual(videoItem.size, Int64(videoData.count))
- XCTAssertNil(items.first { $0.name == "document.docx" })
+ func testFileCategory_classification() {
+ XCTAssertEqual(FileCategory.from(url: URL(fileURLWithPath: "/test/movie.mp4")), .video)
+ XCTAssertEqual(FileCategory.from(url: URL(fileURLWithPath: "/test/track.mp3")), .audio)
+ XCTAssertEqual(FileCategory.from(url: URL(fileURLWithPath: "/test/photo.jpg")), .photo)
+ XCTAssertEqual(FileCategory.from(url: URL(fileURLWithPath: "/test/Test.app")), .apps)
+ XCTAssertEqual(FileCategory.from(url: URL(fileURLWithPath: "/test/doc.pdf")), .docs)
+ XCTAssertEqual(FileCategory.from(url: URL(fileURLWithPath: "/test/archive.zip")), .archives)
+ XCTAssertEqual(FileCategory.from(url: URL(fileURLWithPath: "/test/unknown.xyz")), .all)
}
- func testFileClassification() {
- let videoURL = URL(fileURLWithPath: "/path/to/movie.mov")
- let audioURL = URL(fileURLWithPath: "/path/to/song.mp3")
- let appURL = URL(fileURLWithPath: "/path/to/app.app")
- let docURL = URL(fileURLWithPath: "/path/to/resume.pdf")
- let devURL = URL(fileURLWithPath: "/path/to/code.swift")
-
- XCTAssertEqual(FileCategory.from(url: videoURL), .video)
- XCTAssertEqual(FileCategory.from(url: audioURL), .audio)
- XCTAssertEqual(FileCategory.from(url: appURL), .apps)
- XCTAssertEqual(FileCategory.from(url: docURL), .docs)
- XCTAssertEqual(FileCategory.from(url: devURL), .all)
+ @MainActor
+ func testDiskAnalyzerViewModel_treeNavigation() async throws {
+ let docsDir = tempDir.appendingPathComponent("Docs", isDirectory: true)
+ try FileManager.default.createDirectory(at: docsDir, withIntermediateDirectories: true)
+ let file = docsDir.appendingPathComponent("test.txt")
+ try "test content".write(to: file, atomically: true, encoding: .utf8)
+
+ let viewModel = DiskAnalyzerViewModel()
+ viewModel.startScan(for: tempDir)
+
+ // Wait for scan to finish
+ var attempts = 0
+ while viewModel.isScanning && attempts < 50 {
+ try await Task.sleep(nanoseconds: 50_000_000)
+ attempts += 1
+ }
+
+ XCTAssertFalse(viewModel.isScanning)
+ XCTAssertNotNil(viewModel.rootItem)
+ XCTAssertEqual(viewModel.pathTrail.count, 1)
+ XCTAssertFalse(viewModel.canNavigateUp)
+
+ let displayed = viewModel.displayedItems
+ XCTAssertEqual(displayed.count, 1)
+ XCTAssertEqual(displayed[0].name, "Docs")
+
+ // Drill down into Docs
+ viewModel.drillDown(into: displayed[0])
+ XCTAssertTrue(viewModel.canNavigateUp)
+ XCTAssertEqual(viewModel.pathTrail.count, 2)
+ XCTAssertEqual(viewModel.currentItem?.name, "Docs")
+ XCTAssertEqual(viewModel.displayedItems.count, 1)
+ XCTAssertEqual(viewModel.displayedItems[0].name, "test.txt")
+
+ // Navigate up
+ viewModel.navigateUp()
+ XCTAssertFalse(viewModel.canNavigateUp)
+ XCTAssertEqual(viewModel.pathTrail.count, 1)
+ XCTAssertEqual(viewModel.currentItem?.name, tempDir.lastPathComponent)
+ }
+
+ @MainActor
+ func testDiskAnalyzerViewModel_categoryFiltering() async throws {
+ let mediaDir = tempDir.appendingPathComponent("Media", isDirectory: true)
+ try FileManager.default.createDirectory(at: mediaDir, withIntermediateDirectories: true)
+ let videoFile = mediaDir.appendingPathComponent("clip.mov")
+ let docFile = mediaDir.appendingPathComponent("readme.pdf")
+ try "video bytes".write(to: videoFile, atomically: true, encoding: .utf8)
+ try "doc bytes".write(to: docFile, atomically: true, encoding: .utf8)
+
+ let viewModel = DiskAnalyzerViewModel()
+ viewModel.startScan(for: tempDir)
+
+ var attempts = 0
+ while viewModel.isScanning && attempts < 50 {
+ try await Task.sleep(nanoseconds: 50_000_000)
+ attempts += 1
+ }
+
+ // When .all is selected: shows Media folder
+ viewModel.selectedCategory = .all
+ XCTAssertEqual(viewModel.displayedItems.count, 1)
+ XCTAssertEqual(viewModel.displayedItems[0].name, "Media")
+
+ // When .video is selected: recursively finds clip.mov
+ viewModel.selectedCategory = .video
+ XCTAssertEqual(viewModel.displayedItems.count, 1)
+ XCTAssertEqual(viewModel.displayedItems[0].name, "clip.mov")
+ XCTAssertEqual(viewModel.displayedItems[0].fileType, .video)
+
+ // When .docs is selected: recursively finds readme.pdf
+ viewModel.selectedCategory = .docs
+ XCTAssertEqual(viewModel.displayedItems.count, 1)
+ XCTAssertEqual(viewModel.displayedItems[0].name, "readme.pdf")
+ XCTAssertEqual(viewModel.displayedItems[0].fileType, .docs)
+
+ // When .photo is selected: returns empty
+ viewModel.selectedCategory = .photo
+ XCTAssertEqual(viewModel.displayedItems.count, 0)
}
}
diff --git a/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
new file mode 100644
index 0000000..c1f0a61
--- /dev/null
+++ b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
@@ -0,0 +1,109 @@
+import XCTest
+@testable import MacOSCleaner
+
+final class LocalizationCompletenessTests: XCTestCase {
+
+ override func invokeTest() {
+ LanguageManager.testingLock.lock()
+ defer { LanguageManager.testingLock.unlock() }
+ super.invokeTest()
+ }
+
+ override func setUp() {
+ super.setUp()
+ LanguageManager.shared.setLanguage(.english)
+ }
+
+ override func tearDown() {
+ LanguageManager.shared.setLanguage(.english)
+ super.tearDown()
+ }
+
+ private let requiredLeftoverKeys = [
+ "uninstaller_tab_apps",
+ "uninstaller_tab_leftovers",
+ "uninstaller_leftovers_hero_title",
+ "uninstaller_leftovers_hero_subtitle",
+ "uninstaller_start_leftover_scan",
+ "uninstaller_scanning_leftovers",
+ "uninstaller_no_leftovers_title",
+ "uninstaller_no_leftovers_subtitle",
+ "uninstaller_leftovers_found_count",
+ "uninstaller_filter_all",
+ "uninstaller_clean_selected_leftovers",
+ "uninstaller_confirm_trash_leftovers_title",
+ "uninstaller_confirm_trash_leftovers_message",
+ "uninstaller_post_leftovers_title",
+ "uninstaller_post_leftovers_subtitle",
+ "uninstaller_evidence_card_title",
+ "uninstaller_evidence_why_flagged",
+ "uninstaller_leftovers_cleaned_notification",
+ "uninstaller_show_in_finder",
+ "menu_donate",
+ "about_donate",
+ "category.project_build_artifacts",
+ "settings_project_artifacts_age",
+ "settings_project_artifacts_age_sub",
+ "settings_days_count",
+ "disk_analyzer_items_count",
+ "disk_analyzer_quick_look",
+ "disk_analyzer_open_folder"
+ ]
+
+ func testAllSupportedLanguagesContainRequiredLeftoverKeys() {
+ let languages = AppLanguage.allCases
+ XCTAssertEqual(languages.count, 10, "Should have 10 supported languages")
+
+ for lang in languages {
+ LanguageManager.shared.setLanguage(lang)
+ for key in requiredLeftoverKeys {
+ let localized = key.localized
+ XCTAssertFalse(
+ localized.isEmpty,
+ "Key '\(key)' is empty for language '\(lang.rawValue)'"
+ )
+ XCTAssertNotEqual(
+ localized,
+ key,
+ "Key '\(key)' is missing translation in language '\(lang.rawValue)'"
+ )
+ }
+ }
+ }
+
+ func testAllLprojFilesContainRequiredKeysDirectly() throws {
+ let fileManager = FileManager.default
+ let expectedLprojs = [
+ "en.lproj", "ru.lproj", "de.lproj", "es.lproj",
+ "fr.lproj", "it.lproj", "ja.lproj", "pt-BR.lproj",
+ "uk.lproj", "zh-Hans.lproj"
+ ]
+
+ // Find Resources path from source tree or bundle
+ let possiblePaths = [
+ URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Resources"),
+ Bundle.main.resourceURL ?? URL(fileURLWithPath: "/nonexistent")
+ ]
+
+ guard let resourcesDir = possiblePaths.first(where: { fileManager.fileExists(atPath: $0.path) }) else {
+ return // Skip file system check if resources dir not located
+ }
+
+ for lprojName in expectedLprojs {
+ let stringsFileURL = resourcesDir.appendingPathComponent(lprojName).appendingPathComponent("Localizable.strings")
+ guard fileManager.fileExists(atPath: stringsFileURL.path) else {
+ XCTFail("Missing Localizable.strings file at \(stringsFileURL.path)")
+ continue
+ }
+
+ let content = try String(contentsOf: stringsFileURL, encoding: .utf8)
+ for key in requiredLeftoverKeys {
+ let pattern = "\"\(key)\""
+ XCTAssertTrue(
+ content.contains(pattern),
+ "File \(lprojName)/Localizable.strings is missing key \(key)"
+ )
+ }
+ }
+ }
+}
diff --git a/MacOSCleaner/MacOSCleanerTests/OrphanScannerTests.swift b/MacOSCleaner/MacOSCleanerTests/OrphanScannerTests.swift
index 3796325..748b1fb 100644
--- a/MacOSCleaner/MacOSCleanerTests/OrphanScannerTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/OrphanScannerTests.swift
@@ -2,24 +2,87 @@ import XCTest
@testable import MacOSCleaner
final class OrphanScannerTests: XCTestCase {
- var scanner: OrphanScanner!
+ var fileSystemContext: FileSystemContext!
+ var testRoot: URL!
var safetyManager: SafetyManager!
+ var mockRunner: MockCommandRunner!
+ var plistCache: PlistContentCache!
+ var codesignCache: CodesignCache!
+ var scanner: OrphanScanner!
- override func setUp() {
- super.setUp()
- // Initialize with default or mock dependencies as appropriate for testing
- let fileSystemContext: FileSystemContext = .production
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ fileSystemContext = try FileSystemContext.isolatedTestRoot()
+ testRoot = fileSystemContext.homeDirectory
safetyManager = SafetyManager(homeDirectory: fileSystemContext.homePath, fileSystemContext: fileSystemContext)
- scanner = OrphanScanner(safetyManager: safetyManager)
+ mockRunner = MockCommandRunner()
+ plistCache = PlistContentCache()
+ codesignCache = CodesignCache()
+ scanner = OrphanScanner(
+ safetyManager: safetyManager,
+ commandRunner: CommandRunner(),
+ fileSystemContext: fileSystemContext,
+ codesignCache: codesignCache,
+ plistCache: plistCache
+ )
}
- override func tearDown() {
+ override func tearDownWithError() throws {
+ if let root = fileSystemContext?.allowedRoots.first {
+ try? FileManager.default.removeItem(at: root)
+ }
scanner = nil
safetyManager = nil
- super.tearDown()
+ fileSystemContext = nil
+ testRoot = nil
+ try super.tearDownWithError()
}
func testOrphanScannerInitialization() {
XCTAssertNotNil(scanner)
}
+
+ func testOrphanItemModel_calculatesProperties() {
+ let dummyURL = URL(fileURLWithPath: "/Users/test/Library/Preferences/com.example.orphan.plist")
+ let item = OrphanItem(
+ url: dummyURL,
+ name: "Orphan",
+ bundleID: "com.example.orphan",
+ sizeBytes: 1024,
+ category: "Preferences",
+ evidence: [.bundleIDExact, .plistContent],
+ confidence: .veryLikely,
+ score: 80,
+ isSelected: true
+ )
+
+ XCTAssertEqual(item.name, "Orphan")
+ XCTAssertEqual(item.bundleID, "com.example.orphan")
+ XCTAssertEqual(item.sizeBytes, 1024)
+ XCTAssertEqual(item.category, "Preferences")
+ XCTAssertEqual(item.confidence, .veryLikely)
+ XCTAssertEqual(item.score, 80)
+ XCTAssertTrue(item.isSelected)
+ XCTAssertTrue(item.evidence.contains(.bundleIDExact))
+ }
+
+ func testOrphanScanner_scanOrphans_findsUnownedContainer() async throws {
+ let containerDir = testRoot
+ .appendingPathComponent("Library/Containers", isDirectory: true)
+ .appendingPathComponent("com.unknown.orphanapp", isDirectory: true)
+ try FileManager.default.createDirectory(at: containerDir, withIntermediateDirectories: true)
+ let sampleFile = containerDir.appendingPathComponent("data.bin")
+ try Data(repeating: 0xAA, count: 8192).write(to: sampleFile)
+
+ let orphans = try await scanner.scanOrphans()
+ XCTAssertFalse(orphans.isEmpty, "Should find unowned container orphan")
+
+ if let found = orphans.first(where: { $0.bundleID == "com.unknown.orphanapp" || $0.name == "Orphanapp" }) {
+ XCTAssertEqual(found.category, "Containers")
+ XCTAssertGreaterThanOrEqual(found.sizeBytes, 8192)
+ XCTAssertTrue(found.evidence.contains(.container) || found.evidence.contains(.bundleIDExact))
+ XCTAssertGreaterThanOrEqual(found.confidence, .possible)
+ }
+ }
}
+
diff --git a/MacOSCleaner/MacOSCleanerTests/VerificationEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/VerificationEngineTests.swift
index e05f74d..e3f8f24 100644
--- a/MacOSCleaner/MacOSCleanerTests/VerificationEngineTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/VerificationEngineTests.swift
@@ -111,5 +111,17 @@ final class VerificationEngineTests: XCTestCase {
let report = await engine.verify(identity: identity)
XCTAssertTrue(report.hasLeftovers, "Should detect leftover under isolated Application Support")
XCTAssertGreaterThan(report.count, 0)
+ XCTAssertEqual(report.items.count, report.count)
+ XCTAssertEqual(report.appName, appName)
+ XCTAssertEqual(report.bundleID, "com.test.\(appName)")
+ XCTAssertGreaterThan(report.totalSizeBytes, 0)
+
+ if let firstItem = report.items.first {
+ XCTAssertEqual(firstItem.appName, appName)
+ XCTAssertEqual(firstItem.bundleID, "com.test.\(appName)")
+ XCTAssertGreaterThan(firstItem.sizeBytes, 0)
+ XCTAssertTrue(firstItem.isSelected)
+ XCTAssertFalse(firstItem.evidence.isEmpty)
+ }
}
}
diff --git a/MacOSCleaner/Resources/de.lproj/Localizable.strings b/MacOSCleaner/Resources/de.lproj/Localizable.strings
index 50d7d86..144f427 100644
--- a/MacOSCleaner/Resources/de.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/de.lproj/Localizable.strings
@@ -116,9 +116,14 @@
"disk_analyzer_category_apps" = "Apps";
"disk_analyzer_category_docs" = "Dokumente";
"disk_analyzer_category_archives" = "Archive";
+"disk_analyzer_items_count" = "%d Objekte";
+"disk_analyzer_quick_look" = "Übersicht";
+"disk_analyzer_open_folder" = "Ordner öffnen";
/* About View */
"about_title" = "Über MacOS Cleaner";
+"menu_donate" = "Spenden...";
+"about_donate" = "Projekt unterstützen";
"about_version" = "Version %@";
"about_developer" = "Entwickelt von AlexTkDev";
"about_problem_link" = "Wenn Sie ein Problem mit der App haben, teilen Sie es hier mit";
@@ -398,6 +403,27 @@
"uninstaller_versions" = "Versionen";
"shared_data_warning" = "Diese Daten werden mit anderen Apps geteilt. Das Löschen kann andere IDEs beeinträchtigen.";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "Programme";
+"uninstaller_tab_leftovers" = "Reste";
+"uninstaller_leftovers_hero_title" = "Nach Restdateien suchen";
+"uninstaller_leftovers_hero_subtitle" = "Finden und entfernen Sie verbliebene Caches, Einstellungen und Support-Dateien von deinstallierten Programmen.";
+"uninstaller_start_leftover_scan" = "Nach Resten suchen";
+"uninstaller_scanning_leftovers" = "Suche nach Restdateien...";
+"uninstaller_no_leftovers_title" = "Keine Reste gefunden";
+"uninstaller_no_leftovers_subtitle" = "Ihr System ist frei von verwaisten Anwendungsresten.";
+"uninstaller_leftovers_found_count" = "%lld Reste gefunden";
+"uninstaller_filter_all" = "Alle";
+"uninstaller_clean_selected_leftovers" = "Ausgewählte in den Papierkorb";
+"uninstaller_confirm_trash_leftovers_title" = "Ausgewählte Reste in den Papierkorb?";
+"uninstaller_confirm_trash_leftovers_message" = "Möchten Sie %lld ausgewählte Restdateien (%@) wirklich in den Papierkorb verschieben?";
+"uninstaller_post_leftovers_title" = "Verbliebene Dateien erkannt";
+"uninstaller_post_leftovers_subtitle" = "Nach der Deinstallation von «%@» wurden folgende Restdateien gefunden. Wählen Sie Elemente zum Entfernen aus.";
+"uninstaller_evidence_card_title" = "Nachweise & Vertrauen";
+"uninstaller_evidence_why_flagged" = "Warum wurde diese Datei erkannt?";
+"uninstaller_leftovers_cleaned_notification" = "%lld Restdatei(en) bereinigt (%@ freigegeben)";
+"uninstaller_show_in_finder" = "Im Finder anzeigen";
+
/* Processes View */
"menu_processes" = "Prozesse";
"processes_title" = "Prozesse";
@@ -547,6 +573,7 @@
"category.sleep_image" = "Sleep-Image";
"category.duplicate_files" = "Duplikate";
"category.unused_apps" = "Ungenutzte Apps";
+"category.project_build_artifacts" = "Projekt-Build-Artefakte";
"view_mode" = "Ansichtsmodus";
"sort_by" = "Sortieren nach";
@@ -832,6 +859,10 @@
"settings_search_no_results" = "Keine Einstellungen gefunden";
"settings_search_no_results_sub" = "Suchen Sie nach Begriffen wie 'Papierkorb', 'FDA' oder 'KI'";
+"settings_project_artifacts_age" = "Alter der Projekt-Artefakte";
+"settings_project_artifacts_age_sub" = "Build-Artefakte suchen, die seit dieser Anzahl von Tagen nicht geändert wurden";
+"settings_days_count" = "> %d Tage";
+
/* Evidence Descriptions */
"uninstaller.evidence.bundleIDExact.title" = "Bundle-ID-Übereinstimmung";
diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings
index 94951fe..1444339 100644
--- a/MacOSCleaner/Resources/en.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings
@@ -116,10 +116,15 @@
"disk_analyzer_category_apps" = "Apps";
"disk_analyzer_category_docs" = "Documents";
"disk_analyzer_category_archives" = "Archives";
+"disk_analyzer_items_count" = "%d items";
+"disk_analyzer_quick_look" = "Quick Look";
+"disk_analyzer_open_folder" = "Open Folder";
/* About View */
"about_title" = "About MacOS Cleaner";
+"menu_donate" = "Donate...";
+"about_donate" = "Donate / Support Project";
"about_version" = "Version %@";
"about_developer" = "Developed by AlexTkDev";
"about_problem_link" = "If you have a problem with the app, let me know here";
@@ -411,6 +416,27 @@
"uninstaller_versions" = "Versions";
"shared_data_warning" = "This data is shared with other apps (e.g., Android SDK, AVDs). Deleting may affect other IDEs.";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "Applications";
+"uninstaller_tab_leftovers" = "Leftovers";
+"uninstaller_leftovers_hero_title" = "Scan for Leftover Files";
+"uninstaller_leftovers_hero_subtitle" = "Find and remove residual cache, preference, and support files left behind by uninstalled applications.";
+"uninstaller_start_leftover_scan" = "Scan for Leftovers";
+"uninstaller_scanning_leftovers" = "Scanning for leftover files...";
+"uninstaller_no_leftovers_title" = "No Leftovers Found";
+"uninstaller_no_leftovers_subtitle" = "Your system is clean of orphaned application remnants.";
+"uninstaller_leftovers_found_count" = "%lld leftover items found";
+"uninstaller_filter_all" = "All";
+"uninstaller_clean_selected_leftovers" = "Move Selected to Trash";
+"uninstaller_confirm_trash_leftovers_title" = "Trash Selected Leftovers?";
+"uninstaller_confirm_trash_leftovers_message" = "Are you sure you want to move %lld selected leftover files (%@) to the Trash?";
+"uninstaller_post_leftovers_title" = "Residual Files Detected";
+"uninstaller_post_leftovers_subtitle" = "The following leftover files were detected after removing «%@». Select items to remove.";
+"uninstaller_evidence_card_title" = "Evidence & Confidence";
+"uninstaller_evidence_why_flagged" = "Why was this flagged?";
+"uninstaller_leftovers_cleaned_notification" = "Cleaned %lld leftover item(s) (%@ freed)";
+"uninstaller_show_in_finder" = "Show in Finder";
+
/* Processes View */
"menu_processes" = "Processes";
"processes_title" = "Processes";
@@ -567,6 +593,7 @@
"category.sleep_image" = "Sleep Image";
"category.duplicate_files" = "Duplicate Files";
"category.unused_apps" = "Unused Apps";
+"category.project_build_artifacts" = "Project Build Artifacts";
/* Process View - Missing Keys */
"view_mode" = "View Mode";
@@ -930,3 +957,7 @@
"settings_search_results_title" = "Search Results for «%@»";
"settings_search_no_results" = "No Settings Found";
"settings_search_no_results_sub" = "Try searching for terms like 'trash', 'FDA', 'AI', or 'theme'";
+
+"settings_project_artifacts_age" = "Project Artifacts Age";
+"settings_project_artifacts_age_sub" = "Scan build artifacts not modified in more than this many days";
+"settings_days_count" = "> %d days";
diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings
index a27bdea..8914f4e 100644
--- a/MacOSCleaner/Resources/es.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings
@@ -115,11 +115,16 @@
"disk_analyzer_category_photo" = "Fotos";
"disk_analyzer_category_apps" = "Aplicaciones";
"disk_analyzer_category_docs" = "Documentos";
-"disk_analyzer_category_archives" = "Archivos";
+"disk_analyzer_category_archives" = "Archivos comprimidos";
+"disk_analyzer_items_count" = "%d elementos";
+"disk_analyzer_quick_look" = "Vista rápida";
+"disk_analyzer_open_folder" = "Abrir carpeta";
-// About View
+//* About View */
"about_title" = "Acerca de MacOS Cleaner";
+"menu_donate" = "Hacer una donación...";
+"about_donate" = "Apoyar el proyecto";
"about_version" = "Versión %@";
"about_developer" = "Desarrollado por AlexTkDev";
"about_problem_link" = "Si tiene un problema con la aplicación, avíseme aquí";
@@ -413,6 +418,26 @@
"uninstaller_versions" = "Versiones";
"shared_data_warning" = "Estos datos se comparten con otras aplicaciones (ej. Android SDK, AVD). Eliminarlos puede afectar a otros IDEs.";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "Aplicaciones";
+"uninstaller_tab_leftovers" = "Restos";
+"uninstaller_leftovers_hero_title" = "Buscar archivos residuales";
+"uninstaller_leftovers_hero_subtitle" = "Encuentre y elimine cachés residuales, preferencias y archivos de soporte dejados por aplicaciones desinstaladas.";
+"uninstaller_start_leftover_scan" = "Buscar restos";
+"uninstaller_scanning_leftovers" = "Buscando archivos residuales...";
+"uninstaller_no_leftovers_title" = "No se encontraron restos";
+"uninstaller_no_leftovers_subtitle" = "Su sistema está limpio de remanentes huérfanos de aplicaciones.";
+"uninstaller_leftovers_found_count" = "%lld restos encontrados";
+"uninstaller_filter_all" = "Todos";
+"uninstaller_clean_selected_leftovers" = "Mover seleccionados a la Papelera";
+"uninstaller_confirm_trash_leftovers_title" = "¿Mover restos seleccionados a la Papelera?";
+"uninstaller_confirm_trash_leftovers_message" = "¿Está seguro de que desea mover %lld archivos residuales seleccionados (%@) a la Papelera?";
+"uninstaller_post_leftovers_title" = "Archivos residuales detectados";
+"uninstaller_post_leftovers_subtitle" = "Se detectaron los siguientes archivos residuales después de eliminar «%@». Seleccione los elementos que desea eliminar.";
+"uninstaller_evidence_card_title" = "Evidencias y confianza";
+"uninstaller_evidence_why_flagged" = "¿Por qué se detectó este archivo?";
+"uninstaller_leftovers_cleaned_notification" = "Se limpiaron %lld archivos residuales (%@ liberados)";
+
// Processes View
"menu_processes" = "Procesos";
"processes_title" = "Procesos";
@@ -569,6 +594,7 @@
"category.sleep_image" = "Imagen de suspensión";
"category.duplicate_files" = "Archivos duplicados";
"category.unused_apps" = "Aplicaciones no utilizadas";
+"category.project_build_artifacts" = "Artefactos de compilación de proyectos";
// Process View - Missing Keys
"view_mode" = "Modo de vista";
@@ -929,3 +955,7 @@
"settings_search_no_results_sub" = "Pruebe a buscar 'papelera', 'FDA', 'IA' o 'tema'";
"settings_about_report_issue_sub" = "Reportes de errores y sugerencias";
+
+"settings_project_artifacts_age" = "Antigüedad de artefactos de proyecto";
+"settings_project_artifacts_age_sub" = "Buscar artefactos de compilación no modificados en más de estos días";
+"settings_days_count" = "> %d días";
diff --git a/MacOSCleaner/Resources/fr.lproj/Localizable.strings b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
index d1924e7..0cd9552 100644
--- a/MacOSCleaner/Resources/fr.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
@@ -116,9 +116,14 @@
"disk_analyzer_category_apps" = "Applications";
"disk_analyzer_category_docs" = "Documents";
"disk_analyzer_category_archives" = "Archives";
+"disk_analyzer_items_count" = "%d éléments";
+"disk_analyzer_quick_look" = "Coup d'œil";
+"disk_analyzer_open_folder" = "Ouvrir le dossier";
/* About View */
"about_title" = "À propos de MacOS Cleaner";
+"menu_donate" = "Faire un don...";
+"about_donate" = "Soutenir le projet";
"about_version" = "Version %@";
"about_developer" = "Développé par AlexTkDev";
"about_problem_link" = "Si vous rencontrez un problème avec l'application, signalez-le ici";
@@ -398,6 +403,27 @@
"uninstaller_versions" = "Versions";
"shared_data_warning" = "Ces données sont partagées avec d'autres applications. La suppression peut impacter d'autres IDE.";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "Applications";
+"uninstaller_tab_leftovers" = "Restes";
+"uninstaller_leftovers_hero_title" = "Rechercher les fichiers résiduels";
+"uninstaller_leftovers_hero_subtitle" = "Trouvez et supprimez les caches résiduels, préférences et fichiers de support laissés par les applications désinstallées.";
+"uninstaller_start_leftover_scan" = "Rechercher les restes";
+"uninstaller_scanning_leftovers" = "Recherche des fichiers résiduels...";
+"uninstaller_no_leftovers_title" = "Aucun reste trouvé";
+"uninstaller_no_leftovers_subtitle" = "Votre système est nettoyé de tout résidu d'application orphelin.";
+"uninstaller_leftovers_found_count" = "%lld restes trouvés";
+"uninstaller_filter_all" = "Tous";
+"uninstaller_clean_selected_leftovers" = "Placer la sélection dans la Corbeille";
+"uninstaller_confirm_trash_leftovers_title" = "Déplacer les restes sélectionnés dans la Corbeille ?";
+"uninstaller_confirm_trash_leftovers_message" = "Voulez-vous vraiment déplacer %lld fichiers résiduels sélectionnés (%@) dans la Corbeille ?";
+"uninstaller_post_leftovers_title" = "Fichiers résiduels détectés";
+"uninstaller_post_leftovers_subtitle" = "Les fichiers résiduels suivants ont été détectés après la suppression de «%@». Sélectionnez les éléments à supprimer.";
+"uninstaller_evidence_card_title" = "Preuves et niveau de confiance";
+"uninstaller_evidence_why_flagged" = "Pourquoi ce fichier a-t-il été identifié ?";
+"uninstaller_leftovers_cleaned_notification" = "%lld élément(s) résiduel(s) nettoyé(s) (%@ libéré)";
+"uninstaller_show_in_finder" = "Afficher dans le Finder";
+
/* Processes View */
"menu_processes" = "Processus";
"processes_title" = "Processus";
@@ -545,6 +571,7 @@
"category.sleep_image" = "Image de veille";
"category.duplicate_files" = "Fichiers en doublon";
"category.unused_apps" = "Applications inutilisées";
+"category.project_build_artifacts" = "Artefacts de build de projets";
"view_mode" = "Mode d'affichage";
"sort_by" = "Trier par";
@@ -830,6 +857,10 @@
"settings_search_no_results" = "Aucun réglage trouvé";
"settings_search_no_results_sub" = "Essayez de chercher des termes comme 'corbeille', 'FDA', 'IA' ou 'thème'";
+"settings_project_artifacts_age" = "Âge des artefacts de projets";
+"settings_project_artifacts_age_sub" = "Analyser les artefacts de build non modifiés depuis plus de ce nombre de jours";
+"settings_days_count" = "> %d jours";
+
/* Evidence Descriptions */
"uninstaller.evidence.bundleIDExact.title" = "Correspondance Bundle ID";
diff --git a/MacOSCleaner/Resources/it.lproj/Localizable.strings b/MacOSCleaner/Resources/it.lproj/Localizable.strings
index 7abfbbe..71df514 100644
--- a/MacOSCleaner/Resources/it.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/it.lproj/Localizable.strings
@@ -116,9 +116,14 @@
"disk_analyzer_category_apps" = "Applicazioni";
"disk_analyzer_category_docs" = "Documenti";
"disk_analyzer_category_archives" = "Archivi";
+"disk_analyzer_items_count" = "%d elementi";
+"disk_analyzer_quick_look" = "Visualizzazione rapida";
+"disk_analyzer_open_folder" = "Apri cartella";
/* About View */
"about_title" = "Informazioni su MacOS Cleaner";
+"menu_donate" = "Fai una donazione...";
+"about_donate" = "Sostieni il progetto";
"about_version" = "Versione %@";
"about_developer" = "Sviluppato da AlexTkDev";
"about_problem_link" = "Se riscontri un problema con l'app, segnalalo qui";
@@ -398,6 +403,27 @@
"uninstaller_versions" = "Versioni";
"shared_data_warning" = "Questi dati sono condivisi con altre app.";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "Applicazioni";
+"uninstaller_tab_leftovers" = "Residui";
+"uninstaller_leftovers_hero_title" = "Cerca file residui";
+"uninstaller_leftovers_hero_subtitle" = "Trova e rimuovi cache residue, preferenze e file di supporto lasciati da applicazioni disinstallate.";
+"uninstaller_start_leftover_scan" = "Cerca residui";
+"uninstaller_scanning_leftovers" = "Scansione dei file residui in corso...";
+"uninstaller_no_leftovers_title" = "Nessun residuo trovato";
+"uninstaller_no_leftovers_subtitle" = "Il tuo sistema è pulito da residui orfani di applicazioni.";
+"uninstaller_leftovers_found_count" = "%lld residui trovati";
+"uninstaller_filter_all" = "Tutti";
+"uninstaller_clean_selected_leftovers" = "Sposta selezionati nel Cestino";
+"uninstaller_confirm_trash_leftovers_title" = "Spostare i residui selezionati nel Cestino?";
+"uninstaller_confirm_trash_leftovers_message" = "Sei sicuro di voler spostare %lld file residui selezionati (%@) nel Cestino?";
+"uninstaller_post_leftovers_title" = "File residui rilevati";
+"uninstaller_post_leftovers_subtitle" = "I seguenti file residui sono stati rilevati dopo la rimozione di «%@». Seleziona gli elementi da rimuovere.";
+"uninstaller_evidence_card_title" = "Prove e affidabilità";
+"uninstaller_evidence_why_flagged" = "Perché questo file è stato rilevato?";
+"uninstaller_leftovers_cleaned_notification" = "%lld elementi residui ripuliti (%@ liberati)";
+"uninstaller_show_in_finder" = "Mostra nel Finder";
+
/* Processes View */
"menu_processes" = "Processi";
"processes_title" = "Processi";
@@ -544,7 +570,8 @@
"category.font_cache" = "Cache dei font";
"category.sleep_image" = "Sleep Image";
"category.duplicate_files" = "File duplicati";
-"category.unused_apps" = "App non utilizzate";
+"category.unused_apps" = "Applicazioni non utilizzate";
+"category.project_build_artifacts" = "Artefatti di build dei progetti";
"view_mode" = "Modalità visualizzazione";
"sort_by" = "Ordina per";
@@ -822,6 +849,10 @@
"settings_search_no_results" = "Nessuna impostazione trovata";
"settings_search_no_results_sub" = "Prova a cercare termini come 'Cestino', 'FDA' o 'AI'";
+"settings_project_artifacts_age" = "Età degli artefatti dei progetti";
+"settings_project_artifacts_age_sub" = "Cerca artefatti di compilazione non modificati da più di questi giorni";
+"settings_days_count" = "> %d giorni";
+
/* Evidence Categories */
"uninstaller.evidence_category.identity" = "Corrispondenza identità";
diff --git a/MacOSCleaner/Resources/ja.lproj/Localizable.strings b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
index 034dd0c..9fe3a45 100644
--- a/MacOSCleaner/Resources/ja.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
@@ -116,9 +116,14 @@
"disk_analyzer_category_apps" = "アプリ";
"disk_analyzer_category_docs" = "書類";
"disk_analyzer_category_archives" = "アーカイブ";
+"disk_analyzer_items_count" = "%d項目";
+"disk_analyzer_quick_look" = "クイックルック";
+"disk_analyzer_open_folder" = "フォルダを開く";
/* About View */
"about_title" = "MacOS Cleaner について";
+"menu_donate" = "寄付する...";
+"about_donate" = "プロジェクトを支援";
"about_version" = "バージョン %@";
"about_developer" = "開発者: AlexTkDev";
"about_problem_link" = "不具合・ご要望はこちらから報告";
@@ -398,6 +403,27 @@
"uninstaller_versions" = "バージョン";
"shared_data_warning" = "このデータは他のアプリと共有されています。";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "アプリケーション";
+"uninstaller_tab_leftovers" = "残存ファイル";
+"uninstaller_leftovers_hero_title" = "残存ファイルをスキャン";
+"uninstaller_leftovers_hero_subtitle" = "アンインストールされたアプリの不要なキャッシュ、環境設定、サポートファイルを検出して削除します。";
+"uninstaller_start_leftover_scan" = "残存ファイルをスキャン";
+"uninstaller_scanning_leftovers" = "残存ファイルをスキャン中...";
+"uninstaller_no_leftovers_title" = "残存ファイルは見つかりませんでした";
+"uninstaller_no_leftovers_subtitle" = "システムは孤立したアプリの残骸からクリーンな状態です。";
+"uninstaller_leftovers_found_count" = "%lld 件の残存ファイルが見つかりました";
+"uninstaller_filter_all" = "すべて";
+"uninstaller_clean_selected_leftovers" = "選択項目をゴミ箱に移動";
+"uninstaller_confirm_trash_leftovers_title" = "選択した残存ファイルをゴミ箱に移動しますか?";
+"uninstaller_confirm_trash_leftovers_message" = "選択した %lld 件の残存ファイル (%@) をゴミ箱に移動してもよろしいですか?";
+"uninstaller_post_leftovers_title" = "残存ファイルが検出されました";
+"uninstaller_post_leftovers_subtitle" = "「%@」の削除後に次の残存ファイルが見つかりました。削除する項目を選択してください。";
+"uninstaller_evidence_card_title" = "検出根拠と信頼度";
+"uninstaller_evidence_why_flagged" = "このファイルが検出された理由";
+"uninstaller_leftovers_cleaned_notification" = "%lld 件の残存ファイルを削除しました (%@ を解放)";
+"uninstaller_show_in_finder" = "Finderで表示";
+
/* Processes View */
"menu_processes" = "プロセス";
"processes_title" = "プロセス";
@@ -545,6 +571,7 @@
"category.sleep_image" = "スリープ画像";
"category.duplicate_files" = "重複ファイル";
"category.unused_apps" = "未使用アプリ";
+"category.project_build_artifacts" = "プロジェクトビルド成果物";
"view_mode" = "表示モード";
"sort_by" = "並べ替え";
@@ -822,6 +849,10 @@
"settings_search_no_results" = "設定が見つかりません";
"settings_search_no_results_sub" = "「ゴミ箱」「権限」「AI」などのキーワードを試してください";
+"settings_project_artifacts_age" = "プロジェクト成果物の経過日数";
+"settings_project_artifacts_age_sub" = "指定日数以上変更されていないビルド成果物を検索します";
+"settings_days_count" = "%d日以上";
+
/* Evidence Categories */
"uninstaller.evidence_category.identity" = "識別子の一致";
diff --git a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
index 4bfaec9..80c7f1d 100644
--- a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
@@ -116,9 +116,14 @@
"disk_analyzer_category_apps" = "Aplicativos";
"disk_analyzer_category_docs" = "Documentos";
"disk_analyzer_category_archives" = "Arquivos compactados";
+"disk_analyzer_items_count" = "%d itens";
+"disk_analyzer_quick_look" = "Visualização Rápida";
+"disk_analyzer_open_folder" = "Abrir pasta";
/* About View */
"about_title" = "Sobre o MacOS Cleaner";
+"menu_donate" = "Fazer uma doação...";
+"about_donate" = "Apoiar o projeto";
"about_version" = "Versão %@";
"about_developer" = "Desenvolvido por AlexTkDev";
"about_problem_link" = "Se você encontrar um problema com o app, reporte aqui";
@@ -398,6 +403,27 @@
"uninstaller_versions" = "Versões";
"shared_data_warning" = "Estes dados são compartilhados com outros aplicativos.";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "Aplicativos";
+"uninstaller_tab_leftovers" = "Restos";
+"uninstaller_leftovers_hero_title" = "Escanear arquivos residuais";
+"uninstaller_leftovers_hero_subtitle" = "Encontre e remova caches residuais, preferências e arquivos de suporte deixados por aplicativos desinstalados.";
+"uninstaller_start_leftover_scan" = "Escanear restos";
+"uninstaller_scanning_leftovers" = "Escaneando arquivos residuais...";
+"uninstaller_no_leftovers_title" = "Nenhum resto encontrado";
+"uninstaller_no_leftovers_subtitle" = "Seu sistema está livre de sobras órfãs de aplicativos.";
+"uninstaller_leftovers_found_count" = "%lld restos encontrados";
+"uninstaller_filter_all" = "Todos";
+"uninstaller_clean_selected_leftovers" = "Mover selecionados para a Lixeira";
+"uninstaller_confirm_trash_leftovers_title" = "Mover restos selecionados para a Lixeira?";
+"uninstaller_confirm_trash_leftovers_message" = "Tem certeza de que deseja mover %lld arquivos residuais selecionados (%@) para a Lixeira?";
+"uninstaller_post_leftovers_title" = "Arquivos residuais detectados";
+"uninstaller_post_leftovers_subtitle" = "Os seguintes arquivos residuais foram detectados após a remoção de «%@». Selecione os itens que deseja remover.";
+"uninstaller_evidence_card_title" = "Evidências e confiança";
+"uninstaller_evidence_why_flagged" = "Por que este arquivo foi detectado?";
+"uninstaller_leftovers_cleaned_notification" = "%lld arquivos residuais limpos (%@ liberados)";
+"uninstaller_show_in_finder" = "Mostrar no Finder";
+
/* Processes View */
"menu_processes" = "Processos";
"processes_title" = "Processos";
@@ -544,7 +570,8 @@
"category.font_cache" = "Cache de fontes";
"category.sleep_image" = "Imagem de repouso";
"category.duplicate_files" = "Arquivos duplicados";
-"category.unused_apps" = "Apps não utilizados";
+"category.unused_apps" = "Aplicativos não utilizados";
+"category.project_build_artifacts" = "Artefatos de build de projetos";
"view_mode" = "Modo de visualização";
"sort_by" = "Ordenar por";
@@ -822,6 +849,10 @@
"settings_search_no_results" = "Nenhum ajuste encontrado";
"settings_search_no_results_sub" = "Tente buscar por termos como 'Lixo', 'FDA' ou 'IA'";
+"settings_project_artifacts_age" = "Idade dos artefatos de projetos";
+"settings_project_artifacts_age_sub" = "Examinar artefatos de build não modificados há mais deste período";
+"settings_days_count" = "> %d dias";
+
/* Evidence Categories */
"uninstaller.evidence_category.identity" = "Correspondência de identidade";
diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
index 2632d51..d0e122e 100644
--- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
@@ -116,10 +116,15 @@
"disk_analyzer_category_apps" = "Приложения";
"disk_analyzer_category_docs" = "Документы";
"disk_analyzer_category_archives" = "Архивы";
+"disk_analyzer_items_count" = "%d файлов";
+"disk_analyzer_quick_look" = "Быстрый просмотр";
+"disk_analyzer_open_folder" = "Открыть папку";
/* About View */
"about_title" = "О программе MacOS Cleaner";
+"menu_donate" = "Сделать пожертвование...";
+"about_donate" = "Поддержать проект";
"about_version" = "Версия %@";
"about_developer" = "Разработчик: AlexTkDev";
"about_problem_link" = "Если возникла проблема с приложением, сообщите мне здесь";
@@ -412,6 +417,27 @@
"uninstaller_versions" = "Версии";
"shared_data_warning" = "Эти данные общие для других приложений (например, Android SDK, AVD). Удаление может повлиять на другие IDE.";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "Приложения";
+"uninstaller_tab_leftovers" = "Остатки";
+"uninstaller_leftovers_hero_title" = "Поиск остаточных файлов";
+"uninstaller_leftovers_hero_subtitle" = "Поиск и удаление остаточных кэшей, настроек и служебных файлов от ранее удалённых приложений.";
+"uninstaller_start_leftover_scan" = "Найти остатки";
+"uninstaller_scanning_leftovers" = "Поиск остаточных файлов...";
+"uninstaller_no_leftovers_title" = "Остатков не найдено";
+"uninstaller_no_leftovers_subtitle" = "В системе нет осиротевших остаточных файлов приложений.";
+"uninstaller_leftovers_found_count" = "Найдено остатков: %lld";
+"uninstaller_filter_all" = "Все";
+"uninstaller_clean_selected_leftovers" = "Переместить выбранные в Корзину";
+"uninstaller_confirm_trash_leftovers_title" = "Переместить выбранные остатки в Корзину?";
+"uninstaller_confirm_trash_leftovers_message" = "Вы уверены, что хотите переместить %lld выбранных остаточных файлов (%@) в Корзину?";
+"uninstaller_post_leftovers_title" = "Обнаружены остаточные файлы";
+"uninstaller_post_leftovers_subtitle" = "После удаления «%@» обнаружены следующие остаточные файлы. Выберите элементы для удаления.";
+"uninstaller_evidence_card_title" = "Подтверждения и уверенность";
+"uninstaller_evidence_why_flagged" = "Почему этот файл найден?";
+"uninstaller_leftovers_cleaned_notification" = "Удалено %lld остаточных файлов (освобождено %@)";
+"uninstaller_show_in_finder" = "Показать в Finder";
+
/* Processes View */
"menu_processes" = "Процессы";
"processes_title" = "Процессы";
@@ -568,6 +594,7 @@
"category.sleep_image" = "Образ сна";
"category.duplicate_files" = "Дубликаты файлов";
"category.unused_apps" = "Неиспользуемые приложения";
+"category.project_build_artifacts" = "Артефакты проектов";
/* Process View - Missing Keys */
"view_mode" = "Режим просмотра";
@@ -929,3 +956,7 @@
"settings_about_report_issue_sub" = "Отчеты об ошибках и пожелания";
"startup_help_system" = "Системная служба Apple. Отключать не рекомендуется.";
+
+"settings_project_artifacts_age" = "Возраст артефактов проектов";
+"settings_project_artifacts_age_sub" = "Поиск папок сборок, не менявшихся дольше указанного срока";
+"settings_days_count" = "> %d дн.";
diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
index 163eedc..608c8c1 100644
--- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
@@ -115,11 +115,16 @@
"disk_analyzer_category_photo" = "Фото";
"disk_analyzer_category_apps" = "Програми";
"disk_analyzer_category_docs" = "Документи";
-"disk_analyzer_category_archives" = "Архів";
+"disk_analyzer_category_archives" = "Архіви";
+"disk_analyzer_items_count" = "%d файлів";
+"disk_analyzer_quick_look" = "Швидкий перегляд";
+"disk_analyzer_open_folder" = "Відкрити папку";
/* About View */
"about_title" = "Про програму MacOS Cleaner";
+"menu_donate" = "Зробити пожертву...";
+"about_donate" = "Підтримати проєкт";
"about_version" = "Версія %@";
"about_developer" = "Розробник: AlexTkDev";
"about_problem_link" = "Якщо виникла проблема з додатком, повідомте мені тут";
@@ -409,6 +414,27 @@
"uninstaller_versions" = "Версії";
"shared_data_warning" = "Ці дані спільні для інших додатків (наприклад, Android SDK, AVD). Видалення може вплинути на інші IDE.";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "Програми";
+"uninstaller_tab_leftovers" = "Залишки";
+"uninstaller_leftovers_hero_title" = "Пошук залишкових файлів";
+"uninstaller_leftovers_hero_subtitle" = "Пошук та видалення залишкових кешів, налаштувань та службових файлів від раніше видалених програм.";
+"uninstaller_start_leftover_scan" = "Знайти залишки";
+"uninstaller_scanning_leftovers" = "Пошук залишкових файлів...";
+"uninstaller_no_leftovers_title" = "Залишків не знайдено";
+"uninstaller_no_leftovers_subtitle" = "У системі немає залишкових файлів видалених програм.";
+"uninstaller_leftovers_found_count" = "Знайдено залишків: %lld";
+"uninstaller_filter_all" = "Усі";
+"uninstaller_clean_selected_leftovers" = "Перемістити вибрані до Смітника";
+"uninstaller_confirm_trash_leftovers_title" = "Перемістити вибрані залишки до Смітника?";
+"uninstaller_confirm_trash_leftovers_message" = "Ви впевнені, що хочете перемістити %lld вибраних залишкових файлів (%@) до Смітника?";
+"uninstaller_post_leftovers_title" = "Виявлено залишкові файли";
+"uninstaller_post_leftovers_subtitle" = "Після видалення «%@» виявлено такі залишкові файли. Виберіть елементи для видалення.";
+"uninstaller_evidence_card_title" = "Підтвердження та впевненість";
+"uninstaller_evidence_why_flagged" = "Чому цей файл знайдено?";
+"uninstaller_leftovers_cleaned_notification" = "Видалено %lld залишкових файлів (звільнено %@)";
+"uninstaller_show_in_finder" = "Показати у Finder";
+
/* Processes View */
"menu_processes" = "Процеси";
"processes_title" = "Процеси";
@@ -565,6 +591,7 @@
"category.sleep_image" = "Образ сну";
"category.duplicate_files" = "Дублікати файлів";
"category.unused_apps" = "Невикористовувані додатки";
+"category.project_build_artifacts" = "Артефакти проєктів";
/* Process View - Missing Keys */
"view_mode" = "Режим перегляду";
@@ -926,3 +953,7 @@
"settings_about_report_issue_sub" = "Звіти про помилки та пропозиції";
"startup_help_system" = "Системна служба Apple. Вимикати не рекомендується.";
+
+"settings_project_artifacts_age" = "Вік артефактів проєктів";
+"settings_project_artifacts_age_sub" = "Пошук папок збірок, які не змінювалися довше вказаного терміну";
+"settings_days_count" = "> %d дн.";
diff --git a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
index c2ebb3a..990231d 100644
--- a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
@@ -116,9 +116,14 @@
"disk_analyzer_category_apps" = "应用";
"disk_analyzer_category_docs" = "文档";
"disk_analyzer_category_archives" = "压缩包";
+"disk_analyzer_items_count" = "%d 个项目";
+"disk_analyzer_quick_look" = "快速查看";
+"disk_analyzer_open_folder" = "打开文件夹";
/* About View */
"about_title" = "关于 MacOS Cleaner";
+"menu_donate" = "赞助项目...";
+"about_donate" = "支持项目";
"about_version" = "版本 %@";
"about_developer" = "由 AlexTkDev 开发";
"about_problem_link" = "如遇到问题,请在此处反馈";
@@ -398,6 +403,27 @@
"uninstaller_versions" = "版本";
"shared_data_warning" = "此数据与其他应用共享,删除可能影响其他开发环境。";
+/* Uninstaller Leftovers */
+"uninstaller_tab_apps" = "应用程序";
+"uninstaller_tab_leftovers" = "残留文件";
+"uninstaller_leftovers_hero_title" = "扫描残留文件";
+"uninstaller_leftovers_hero_subtitle" = "查找并清理已被手动或外部卸载应用程序遗留的缓存、偏好设置和支持文件。";
+"uninstaller_start_leftover_scan" = "扫描残留文件";
+"uninstaller_scanning_leftovers" = "正在扫描残留文件...";
+"uninstaller_no_leftovers_title" = "未发现残留文件";
+"uninstaller_no_leftovers_subtitle" = "您的系统中没有孤立的应用程序残留文件。";
+"uninstaller_leftovers_found_count" = "找到 %lld 项残留文件";
+"uninstaller_filter_all" = "全部";
+"uninstaller_clean_selected_leftovers" = "将选中项移至废纸篓";
+"uninstaller_confirm_trash_leftovers_title" = "将选中的残留文件移至废纸篓?";
+"uninstaller_confirm_trash_leftovers_message" = "确定要将选中的 %lld 个残留文件 (%@) 移至废纸篓吗?";
+"uninstaller_post_leftovers_title" = "检测到残留文件";
+"uninstaller_post_leftovers_subtitle" = "卸载“%@”后检测到以下残留文件。请选择要移除的项。";
+"uninstaller_evidence_card_title" = "匹配依据与置信度";
+"uninstaller_evidence_why_flagged" = "为什么标记此文件?";
+"uninstaller_leftovers_cleaned_notification" = "已清理 %lld 项残留文件 (释放 %@)";
+"uninstaller_show_in_finder" = "在访达中显示";
+
/* Processes View */
"menu_processes" = "进程管理";
"processes_title" = "进程管理";
@@ -545,6 +571,7 @@
"category.sleep_image" = "休眠镜像";
"category.duplicate_files" = "重复文件";
"category.unused_apps" = "未使用应用";
+"category.project_build_artifacts" = "项目构建产物";
"view_mode" = "视图模式";
"sort_by" = "排序依据";
@@ -822,6 +849,10 @@
"settings_search_no_results" = "未找到设置";
"settings_search_no_results_sub" = "尝试搜索“废纸篓”、“权限”、“AI”或“主题”";
+"settings_project_artifacts_age" = "项目构建产物保留期";
+"settings_project_artifacts_age_sub" = "扫描未修改时间超过指定天数的构建产物";
+"settings_days_count" = "> %d 天";
+
/* Evidence Categories */
"uninstaller.evidence_category.identity" = "身份匹配";
diff --git a/MacOSCleaner/SharedViews/GlassPillPicker.swift b/MacOSCleaner/SharedViews/GlassPillPicker.swift
index 3ee03be..97941f8 100644
--- a/MacOSCleaner/SharedViews/GlassPillPicker.swift
+++ b/MacOSCleaner/SharedViews/GlassPillPicker.swift
@@ -6,10 +6,23 @@ import SwiftUI
struct GlassPillPicker: View {
let items: [T]
@Binding var selection: T
+ var icon: ((T) -> String)? = nil
let label: (T) -> String
@Environment(\.locale) private var locale
+ init(
+ items: [T],
+ selection: Binding,
+ icon: ((T) -> String)? = nil,
+ label: @escaping (T) -> String
+ ) {
+ self.items = items
+ self._selection = selection
+ self.icon = icon
+ self.label = label
+ }
+
var body: some View {
ViewThatFits(in: .horizontal) {
pillRow(horizontalPadding: 12)
@@ -29,19 +42,25 @@ struct GlassPillPicker: View {
selection = item
}
} label: {
- Text(label(item))
- .font(.system(size: 12, weight: .medium))
- .lineLimit(1)
- .fixedSize(horizontal: true, vertical: false)
- .padding(.horizontal, horizontalPadding)
- .padding(.vertical, 5)
- .foregroundStyle(isSelected ? Color.white : Color.primary.opacity(0.6))
- .background {
- if isSelected {
- Capsule()
- .fill(Color.accentColor)
- }
+ HStack(spacing: 5) {
+ if let iconName = icon?(item) {
+ Image(systemName: iconName)
+ .font(.system(size: 11, weight: .medium))
}
+ Text(label(item))
+ .font(.system(size: 12, weight: .medium))
+ .lineLimit(1)
+ .fixedSize(horizontal: true, vertical: false)
+ }
+ .padding(.horizontal, horizontalPadding)
+ .padding(.vertical, 5)
+ .foregroundStyle(isSelected ? Color.white : Color.primary.opacity(0.6))
+ .background {
+ if isSelected {
+ Capsule()
+ .fill(Color.accentColor)
+ }
+ }
}
.buttonStyle(.plain)
}
diff --git a/MacOSCleaner/project.yml b/MacOSCleaner/project.yml
index e2c3543..d78c1ee 100644
--- a/MacOSCleaner/project.yml
+++ b/MacOSCleaner/project.yml
@@ -74,7 +74,7 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: input.MacOSCleaner
- MARKETING_VERSION: 2.1.1
+ MARKETING_VERSION: 2.2.0
CURRENT_PROJECT_VERSION: 2
ENABLE_HARDENED_RUNTIME: YES
ENABLE_DEBUG_DYLIB: NO
diff --git a/README.md b/README.md
index be0aa84..146889c 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
-
+
@@ -54,12 +54,12 @@
## 📸 Screenshots
-
-
+
+
-
-
+
+
@@ -83,12 +83,12 @@
## ✨ Features
-- **Smart Cleanup:** scans 54 categories and 1,770 rules across 251 applications, 66 developer toolchains (Xcode, Docker, Rust `target`, Go `pkg`, Python `.venv`, Node `node_modules`), local AI models (Ollama, MLX, Hugging Face), and APFS purgeable space.
-- Forensic uninstaller inspects 30 evidence types (Bundle ID, Team ID, Spotlight metadata, Launch Services) to find remnants, with root-level helper removal and rollback snapshots.
+- **Smart Cleanup:** scans 55+ categories across system caches, 275+ applications, 85+ developer toolchains & package managers (Xcode, Docker, `uv`, `mise`, Rust, Go, Python, Node), local AI models & coding assistants (Claude Code, Ollama, MLX, Hugging Face, WhisperKit), and safe Time Machine snapshot thinning (`thinlocalsnapshots`).
+- **Forensic Uninstaller:** inspects 30 evidence types (Bundle ID, Team ID, Spotlight metadata, Launch Services) to trace remnants across 1,800+ known application paths and heuristics, with standalone orphaned residuals discovery, confidence score tiers, post-uninstall review sheets, and root-level helper removal.
- **Duplicate Finder:** identifies duplicate files through a 3-stage pipeline (file size matching, 4 KB header checksum, full SHA-256 verification).
-- Disk space analyzer groups directory contents by file type (Videos, Audio, Photos, Documents, Archives) for inspection.
+- **Disk Space Analyzer:** hierarchical folder drill-down with breadcrumb navigation, honest APFS allocated block sizing (`totalFileAllocatedSize`), Quick Look previews (`Space`), recursive category filters (Videos, Audio, Photos, Documents, Archives), and dataless iCloud item skip protection.
- **Process and Service Manager:** monitors live CPU and RAM usage with termination safeguards for critical processes (`kernel_task`, `launchd`), plus LaunchAgents and LaunchDaemons toggling.
-- Native system integrations include on-device `FoundationModels` explanations for unknown caches, Siri and App Intents automation, Liquid Glass keyboard navigation (`⌘,`, `⌘C`, `⌘F`, `⌘R`), and 10 language localizations.
+- **Native System Integrations:** on-device `FoundationModels` explanations for unknown caches, Siri and App Intents automation, Liquid Glass materials with keyboard navigation (`⌘,`, `⌘C`, `⌘F`, `⌘R`, `⌘⌫`), and 10 language localizations.
Complete feature breakdowns and path specifications are documented in the [MacOSCleaner Wiki](https://github.com/AlexTkDev/MacOSCleaner/wiki).
@@ -117,7 +117,6 @@ Build and run with **⌘R**, or run tests with **⌘U**.
-
— [Star the repository](https://github.com/AlexTkDev/MacOSCleaner/stargazers) and watch releases for update notifications.
-
— [Ask questions, propose features, and share feedback.](https://github.com/AlexTkDev/MacOSCleaner/discussions)
--
— [Track upcoming tasks for the next release.](https://github.com/AlexTkDev/MacOSCleaner/discussions/14)
-
— [Report bugs or suggest rule improvements.](https://github.com/AlexTkDev/MacOSCleaner/issues)
-
— [Architecture overview and developer guides.](https://github.com/AlexTkDev/MacOSCleaner/wiki)
-
— [Explore repository online and open in Cursor.](https://cursor.com/codebase/alextkdev/MacOSCleaner/tree/release)
diff --git a/assets/screenshots/About_v2_1_1.png b/assets/screenshots/About_v2_1_1.png
deleted file mode 100644
index e00ac20..0000000
Binary files a/assets/screenshots/About_v2_1_1.png and /dev/null differ
diff --git a/assets/screenshots/About_v2_2.png b/assets/screenshots/About_v2_2.png
new file mode 100644
index 0000000..b3ab842
Binary files /dev/null and b/assets/screenshots/About_v2_2.png differ
diff --git a/assets/screenshots/Cleanup_Scan_Results_v2_1.png b/assets/screenshots/Cleanup_Scan_Results_v2_1.png
deleted file mode 100644
index b34e827..0000000
Binary files a/assets/screenshots/Cleanup_Scan_Results_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Cleanup_Scan_Results_v2_2.png b/assets/screenshots/Cleanup_Scan_Results_v2_2.png
new file mode 100644
index 0000000..19eea7c
Binary files /dev/null and b/assets/screenshots/Cleanup_Scan_Results_v2_2.png differ
diff --git a/assets/screenshots/Cleanup_Scan_v2_1.png b/assets/screenshots/Cleanup_Scan_v2_1.png
deleted file mode 100644
index 2c07ac2..0000000
Binary files a/assets/screenshots/Cleanup_Scan_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Cleanup_Scan_v2_2.png b/assets/screenshots/Cleanup_Scan_v2_2.png
new file mode 100644
index 0000000..d199d33
Binary files /dev/null and b/assets/screenshots/Cleanup_Scan_v2_2.png differ
diff --git a/assets/screenshots/Cleanup_page_v2_1.png b/assets/screenshots/Cleanup_page_v2_1.png
deleted file mode 100644
index 5e9a91f..0000000
Binary files a/assets/screenshots/Cleanup_page_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Cleanup_page_v2_2.png b/assets/screenshots/Cleanup_page_v2_2.png
new file mode 100644
index 0000000..3cd68c7
Binary files /dev/null and b/assets/screenshots/Cleanup_page_v2_2.png differ
diff --git a/assets/screenshots/Dashboard_v2_1.png b/assets/screenshots/Dashboard_v2_1.png
deleted file mode 100644
index 2544a83..0000000
Binary files a/assets/screenshots/Dashboard_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Dashboard_v2_2.png b/assets/screenshots/Dashboard_v2_2.png
new file mode 100644
index 0000000..7989dda
Binary files /dev/null and b/assets/screenshots/Dashboard_v2_2.png differ
diff --git a/assets/screenshots/Disk_analyzer_v2_1.png b/assets/screenshots/Disk_analyzer_v2_1.png
deleted file mode 100644
index 86ff7cd..0000000
Binary files a/assets/screenshots/Disk_analyzer_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Disk_analyzer_v2_2.png b/assets/screenshots/Disk_analyzer_v2_2.png
new file mode 100644
index 0000000..1c423c3
Binary files /dev/null and b/assets/screenshots/Disk_analyzer_v2_2.png differ
diff --git a/assets/screenshots/Duplicate_Finder_v2_1.png b/assets/screenshots/Duplicate_Finder_v2_1.png
deleted file mode 100644
index ca74dba..0000000
Binary files a/assets/screenshots/Duplicate_Finder_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Duplicate_Finder_v2_2.png b/assets/screenshots/Duplicate_Finder_v2_2.png
new file mode 100644
index 0000000..117e69d
Binary files /dev/null and b/assets/screenshots/Duplicate_Finder_v2_2.png differ
diff --git a/assets/screenshots/Permission_screen_v2_1.png b/assets/screenshots/Permission_screen_v2_2.png
similarity index 100%
rename from assets/screenshots/Permission_screen_v2_1.png
rename to assets/screenshots/Permission_screen_v2_2.png
diff --git a/assets/screenshots/Processes_v2_1.png b/assets/screenshots/Processes_v2_1.png
deleted file mode 100644
index 43729fa..0000000
Binary files a/assets/screenshots/Processes_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Processes_v2_2.png b/assets/screenshots/Processes_v2_2.png
new file mode 100644
index 0000000..aa4a454
Binary files /dev/null and b/assets/screenshots/Processes_v2_2.png differ
diff --git a/assets/screenshots/Settings_AI_v2_1.png b/assets/screenshots/Settings_AI_v2_1.png
deleted file mode 100644
index 8efd085..0000000
Binary files a/assets/screenshots/Settings_AI_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Settings_AI_v2_2.png b/assets/screenshots/Settings_AI_v2_2.png
new file mode 100644
index 0000000..75df7f5
Binary files /dev/null and b/assets/screenshots/Settings_AI_v2_2.png differ
diff --git a/assets/screenshots/Settings_Advanced_v2_1.png b/assets/screenshots/Settings_Advanced_v2_1.png
deleted file mode 100644
index c4f227c..0000000
Binary files a/assets/screenshots/Settings_Advanced_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Settings_Advanced_v2_2.png b/assets/screenshots/Settings_Advanced_v2_2.png
new file mode 100644
index 0000000..962037e
Binary files /dev/null and b/assets/screenshots/Settings_Advanced_v2_2.png differ
diff --git a/assets/screenshots/Settings_Cleanup_v2_1.png b/assets/screenshots/Settings_Cleanup_v2_1.png
deleted file mode 100644
index 3e456ef..0000000
Binary files a/assets/screenshots/Settings_Cleanup_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Settings_Cleanup_v2_2.png b/assets/screenshots/Settings_Cleanup_v2_2.png
new file mode 100644
index 0000000..38ddea6
Binary files /dev/null and b/assets/screenshots/Settings_Cleanup_v2_2.png differ
diff --git a/assets/screenshots/Settings_General_v2_1_1.png b/assets/screenshots/Settings_General_v2_1_1.png
deleted file mode 100644
index fb296e2..0000000
Binary files a/assets/screenshots/Settings_General_v2_1_1.png and /dev/null differ
diff --git a/assets/screenshots/Settings_General_v2_2.png b/assets/screenshots/Settings_General_v2_2.png
new file mode 100644
index 0000000..9993258
Binary files /dev/null and b/assets/screenshots/Settings_General_v2_2.png differ
diff --git a/assets/screenshots/Settings_Processes_v2_1.png b/assets/screenshots/Settings_Processes_v2_1.png
deleted file mode 100644
index aa6f8f9..0000000
Binary files a/assets/screenshots/Settings_Processes_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Settings_Processes_v2_2.png b/assets/screenshots/Settings_Processes_v2_2.png
new file mode 100644
index 0000000..dfc6d0d
Binary files /dev/null and b/assets/screenshots/Settings_Processes_v2_2.png differ
diff --git a/assets/screenshots/Startup_Services_v2_1.png b/assets/screenshots/Startup_Services_v2_1.png
deleted file mode 100644
index 25fa36a..0000000
Binary files a/assets/screenshots/Startup_Services_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Startup_Services_v2_2.png b/assets/screenshots/Startup_Services_v2_2.png
new file mode 100644
index 0000000..3decc30
Binary files /dev/null and b/assets/screenshots/Startup_Services_v2_2.png differ
diff --git a/assets/screenshots/Uninstaller_2_versions_v2_1.png b/assets/screenshots/Uninstaller_2_versions_v2_2.png
similarity index 100%
rename from assets/screenshots/Uninstaller_2_versions_v2_1.png
rename to assets/screenshots/Uninstaller_2_versions_v2_2.png
diff --git a/assets/screenshots/Uninstaller_Leftovers_v2_2.png b/assets/screenshots/Uninstaller_Leftovers_v2_2.png
new file mode 100644
index 0000000..3e38674
Binary files /dev/null and b/assets/screenshots/Uninstaller_Leftovers_v2_2.png differ
diff --git a/assets/screenshots/Uninstaller_Shared_v2_1.png b/assets/screenshots/Uninstaller_Shared_v2_1.png
deleted file mode 100644
index ae1ffde..0000000
Binary files a/assets/screenshots/Uninstaller_Shared_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Uninstaller_scan_v2_1.png b/assets/screenshots/Uninstaller_scan_v2_2.png
similarity index 100%
rename from assets/screenshots/Uninstaller_scan_v2_1.png
rename to assets/screenshots/Uninstaller_scan_v2_2.png
diff --git a/assets/screenshots/Uninstaller_v2_1.png b/assets/screenshots/Uninstaller_v2_1.png
deleted file mode 100644
index 9cbd43d..0000000
Binary files a/assets/screenshots/Uninstaller_v2_1.png and /dev/null differ
diff --git a/assets/screenshots/Uninstaller_v2_2.png b/assets/screenshots/Uninstaller_v2_2.png
new file mode 100644
index 0000000..943081b
Binary files /dev/null and b/assets/screenshots/Uninstaller_v2_2.png differ