From cd7311b6bc835c3a34b663add29492c4d3bd089d Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 15:10:45 +0300
Subject: [PATCH 01/10] feature: enhance OrphanScanner with evidence-based
scoring, confidence tiers, and improved identification logic
---
CITATION.cff | 4 +-
MacOSCleaner/Features/About/AboutView.swift | 2 +-
.../Settings/SettingsGeneralView.swift | 2 +-
.../Features/Settings/SettingsView.swift | 2 +-
.../Features/Uninstaller/OrphanScanner.swift | 313 +++++++++++-
.../Uninstaller/OrphanedResidualsView.swift | 482 ++++++++++++++++++
.../PostUninstallLeftoversSheet.swift | 341 +++++++++++++
.../Uninstaller/UninstallerService.swift | 51 +-
.../Uninstaller/UninstallerView.swift | 349 ++++++++-----
.../Uninstaller/VerificationEngine.swift | 35 +-
.../Uninstaller/VerificationReport.swift | 80 ++-
.../Updates/UpdateAvailableView.swift | 6 +-
.../MacOSCleaner.xcodeproj/project.pbxproj | 16 +-
.../LocalizationCompletenessTests.swift | 100 ++++
.../OrphanScannerTests.swift | 79 ++-
.../VerificationEngineTests.swift | 12 +
.../Resources/de.lproj/Localizable.strings | 21 +
.../Resources/en.lproj/Localizable.strings | 21 +
.../Resources/es.lproj/Localizable.strings | 20 +
.../Resources/fr.lproj/Localizable.strings | 21 +
.../Resources/it.lproj/Localizable.strings | 21 +
.../Resources/ja.lproj/Localizable.strings | 21 +
.../Resources/pt-BR.lproj/Localizable.strings | 21 +
.../Resources/ru.lproj/Localizable.strings | 21 +
.../Resources/uk.lproj/Localizable.strings | 21 +
.../zh-Hans.lproj/Localizable.strings | 21 +
.../SharedViews/GlassPillPicker.swift | 43 +-
MacOSCleaner/project.yml | 2 +-
README.md | 2 +-
29 files changed, 1935 insertions(+), 195 deletions(-)
create mode 100644 MacOSCleaner/Features/Uninstaller/OrphanedResidualsView.swift
create mode 100644 MacOSCleaner/Features/Uninstaller/PostUninstallLeftoversSheet.swift
create mode 100644 MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
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/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift
index f6e9158..86a9a1a 100644
--- a/MacOSCleaner/Features/About/AboutView.swift
+++ b/MacOSCleaner/Features/About/AboutView.swift
@@ -169,5 +169,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/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..b072f4c 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 = ""
@@ -28,6 +50,8 @@ struct UninstallerView: View {
@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 +73,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,6 +120,18 @@ struct UninstallerView: View {
.help("uninstaller_reload".localized)
}
}
+ .sheet(isPresented: $showingPostUninstallSheet) {
+ PostUninstallLeftoversSheet(
+ report: $postUninstallReport,
+ onClean: { selectedItems in
+ cleanPostUninstallLeftovers(selectedItems)
+ },
+ onDismiss: {
+ showingPostUninstallSheet = false
+ postUninstallReport = nil
+ }
+ )
+ }
.onAppear(perform: loadApps)
.onChange(of: selectedApp?.id) { _, newID in
guard let id = newID else { return }
@@ -244,6 +198,106 @@ 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(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
+ }
+ .padding(.top, 4)
+ }
+ }
+
private func loadApps() {
isLoading = true
Task {
@@ -285,20 +339,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 +371,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 +415,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/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/LocalizationCompletenessTests.swift b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
new file mode 100644
index 0000000..094c4cb
--- /dev/null
+++ b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
@@ -0,0 +1,100 @@
+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"
+ ]
+
+ 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..058611d 100644
--- a/MacOSCleaner/Resources/de.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/de.lproj/Localizable.strings
@@ -398,6 +398,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";
diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings
index 94951fe..a64b2e2 100644
--- a/MacOSCleaner/Resources/en.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings
@@ -411,6 +411,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";
diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings
index a27bdea..c8b7b73 100644
--- a/MacOSCleaner/Resources/es.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings
@@ -413,6 +413,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";
diff --git a/MacOSCleaner/Resources/fr.lproj/Localizable.strings b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
index d1924e7..852fd3f 100644
--- a/MacOSCleaner/Resources/fr.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
@@ -398,6 +398,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";
diff --git a/MacOSCleaner/Resources/it.lproj/Localizable.strings b/MacOSCleaner/Resources/it.lproj/Localizable.strings
index 7abfbbe..8f7d8a3 100644
--- a/MacOSCleaner/Resources/it.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/it.lproj/Localizable.strings
@@ -398,6 +398,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";
diff --git a/MacOSCleaner/Resources/ja.lproj/Localizable.strings b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
index 034dd0c..9c9144a 100644
--- a/MacOSCleaner/Resources/ja.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
@@ -398,6 +398,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" = "プロセス";
diff --git a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
index 4bfaec9..bbe0277 100644
--- a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
@@ -398,6 +398,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";
diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
index 2632d51..e91fee2 100644
--- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
@@ -412,6 +412,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" = "Процессы";
diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
index 163eedc..f911627 100644
--- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
@@ -409,6 +409,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" = "Процеси";
diff --git a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
index c2ebb3a..9a9af7c 100644
--- a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
@@ -398,6 +398,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" = "进程管理";
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..f1c52e0 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
-
+
From 05d99e1ce76839778c126746cd3c9041ee312244 Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 15:35:43 +0300
Subject: [PATCH 02/10] feat: add donation links to About view, app menu, and
documentation with localized support
---
DONATE.md | 9 +++++++--
MacOSCleaner/App/MacOSCleanerApp.swift | 6 ++++++
MacOSCleaner/Features/About/AboutView.swift | 9 +++++++++
.../LocalizationCompletenessTests.swift | 4 +++-
MacOSCleaner/Resources/de.lproj/Localizable.strings | 2 ++
MacOSCleaner/Resources/en.lproj/Localizable.strings | 2 ++
MacOSCleaner/Resources/es.lproj/Localizable.strings | 4 +++-
MacOSCleaner/Resources/fr.lproj/Localizable.strings | 2 ++
MacOSCleaner/Resources/it.lproj/Localizable.strings | 2 ++
MacOSCleaner/Resources/ja.lproj/Localizable.strings | 2 ++
MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings | 2 ++
MacOSCleaner/Resources/ru.lproj/Localizable.strings | 2 ++
MacOSCleaner/Resources/uk.lproj/Localizable.strings | 2 ++
MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings | 2 ++
14 files changed, 46 insertions(+), 4 deletions(-)
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/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift
index 86a9a1a..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))
diff --git a/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
index 094c4cb..6343615 100644
--- a/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
@@ -38,7 +38,9 @@ final class LocalizationCompletenessTests: XCTestCase {
"uninstaller_evidence_card_title",
"uninstaller_evidence_why_flagged",
"uninstaller_leftovers_cleaned_notification",
- "uninstaller_show_in_finder"
+ "uninstaller_show_in_finder",
+ "menu_donate",
+ "about_donate"
]
func testAllSupportedLanguagesContainRequiredLeftoverKeys() {
diff --git a/MacOSCleaner/Resources/de.lproj/Localizable.strings b/MacOSCleaner/Resources/de.lproj/Localizable.strings
index 058611d..554e00d 100644
--- a/MacOSCleaner/Resources/de.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/de.lproj/Localizable.strings
@@ -119,6 +119,8 @@
/* 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";
diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings
index a64b2e2..8e5d72f 100644
--- a/MacOSCleaner/Resources/en.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings
@@ -120,6 +120,8 @@
/* 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";
diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings
index c8b7b73..b02bc51 100644
--- a/MacOSCleaner/Resources/es.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings
@@ -118,8 +118,10 @@
"disk_analyzer_category_archives" = "Archivos";
-// 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í";
diff --git a/MacOSCleaner/Resources/fr.lproj/Localizable.strings b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
index 852fd3f..a073c80 100644
--- a/MacOSCleaner/Resources/fr.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
@@ -119,6 +119,8 @@
/* 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";
diff --git a/MacOSCleaner/Resources/it.lproj/Localizable.strings b/MacOSCleaner/Resources/it.lproj/Localizable.strings
index 8f7d8a3..cc0cfce 100644
--- a/MacOSCleaner/Resources/it.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/it.lproj/Localizable.strings
@@ -119,6 +119,8 @@
/* 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";
diff --git a/MacOSCleaner/Resources/ja.lproj/Localizable.strings b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
index 9c9144a..a2becb6 100644
--- a/MacOSCleaner/Resources/ja.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
@@ -119,6 +119,8 @@
/* About View */
"about_title" = "MacOS Cleaner について";
+"menu_donate" = "寄付する...";
+"about_donate" = "プロジェクトを支援";
"about_version" = "バージョン %@";
"about_developer" = "開発者: AlexTkDev";
"about_problem_link" = "不具合・ご要望はこちらから報告";
diff --git a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
index bbe0277..de97925 100644
--- a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
@@ -119,6 +119,8 @@
/* 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";
diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
index e91fee2..fe6f48c 100644
--- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
@@ -120,6 +120,8 @@
/* About View */
"about_title" = "О программе MacOS Cleaner";
+"menu_donate" = "Сделать пожертвование...";
+"about_donate" = "Поддержать проект";
"about_version" = "Версия %@";
"about_developer" = "Разработчик: AlexTkDev";
"about_problem_link" = "Если возникла проблема с приложением, сообщите мне здесь";
diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
index f911627..0c3f2c2 100644
--- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
@@ -120,6 +120,8 @@
/* About View */
"about_title" = "Про програму MacOS Cleaner";
+"menu_donate" = "Зробити пожертву...";
+"about_donate" = "Підтримати проєкт";
"about_version" = "Версія %@";
"about_developer" = "Розробник: AlexTkDev";
"about_problem_link" = "Якщо виникла проблема з додатком, повідомте мені тут";
diff --git a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
index 9a9af7c..4148fc4 100644
--- a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
@@ -119,6 +119,8 @@
/* About View */
"about_title" = "关于 MacOS Cleaner";
+"menu_donate" = "赞助项目...";
+"about_donate" = "支持项目";
"about_version" = "版本 %@";
"about_developer" = "由 AlexTkDev 开发";
"about_problem_link" = "如遇到问题,请在此处反馈";
From a25d9692207eeec90b3940d4659731d98148a473 Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 16:05:15 +0300
Subject: [PATCH 03/10] feat: add configurable cleanup for project build
artifacts with age-based filtering
---
.../CleanupCategory+FixtureMapping.swift | 4 +
.../Domains/Cleanup/CleanupCoordinator.swift | 5 +-
.../Domains/Cleanup/CleanupEngine.swift | 113 ++++++++++--------
.../Features/Cleanup/CleanupViewModel.swift | 8 +-
.../Features/Settings/AppSettings.swift | 21 ++++
.../Settings/SettingsCleanupView.swift | 16 +++
.../MacOSCleanerTests/AppSettingsTests.swift | 9 +-
.../CleanupEngineTests.swift | 3 +
.../CleanupOptionsTests.swift | 12 ++
.../LocalizationCompletenessTests.swift | 6 +-
.../Resources/de.lproj/Localizable.strings | 5 +
.../Resources/en.lproj/Localizable.strings | 5 +
.../Resources/es.lproj/Localizable.strings | 5 +
.../Resources/fr.lproj/Localizable.strings | 5 +
.../Resources/it.lproj/Localizable.strings | 7 +-
.../Resources/ja.lproj/Localizable.strings | 5 +
.../Resources/pt-BR.lproj/Localizable.strings | 7 +-
.../Resources/ru.lproj/Localizable.strings | 5 +
.../Resources/uk.lproj/Localizable.strings | 5 +
.../zh-Hans.lproj/Localizable.strings | 5 +
20 files changed, 196 insertions(+), 55 deletions(-)
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..08f2190 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"]
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/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/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..4a232fc 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")
}
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/LocalizationCompletenessTests.swift b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
index 6343615..1f05b3f 100644
--- a/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
@@ -40,7 +40,11 @@ final class LocalizationCompletenessTests: XCTestCase {
"uninstaller_leftovers_cleaned_notification",
"uninstaller_show_in_finder",
"menu_donate",
- "about_donate"
+ "about_donate",
+ "category.project_build_artifacts",
+ "settings_project_artifacts_age",
+ "settings_project_artifacts_age_sub",
+ "settings_days_count"
]
func testAllSupportedLanguagesContainRequiredLeftoverKeys() {
diff --git a/MacOSCleaner/Resources/de.lproj/Localizable.strings b/MacOSCleaner/Resources/de.lproj/Localizable.strings
index 554e00d..66fb0e1 100644
--- a/MacOSCleaner/Resources/de.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/de.lproj/Localizable.strings
@@ -570,6 +570,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";
@@ -855,6 +856,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 8e5d72f..e7b3a06 100644
--- a/MacOSCleaner/Resources/en.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings
@@ -590,6 +590,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";
@@ -953,3 +954,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 b02bc51..dd724f2 100644
--- a/MacOSCleaner/Resources/es.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings
@@ -591,6 +591,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";
@@ -951,3 +952,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 a073c80..417af5a 100644
--- a/MacOSCleaner/Resources/fr.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
@@ -568,6 +568,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";
@@ -853,6 +854,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 cc0cfce..8573b03 100644
--- a/MacOSCleaner/Resources/it.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/it.lproj/Localizable.strings
@@ -567,7 +567,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";
@@ -845,6 +846,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 a2becb6..1f95076 100644
--- a/MacOSCleaner/Resources/ja.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
@@ -568,6 +568,7 @@
"category.sleep_image" = "スリープ画像";
"category.duplicate_files" = "重複ファイル";
"category.unused_apps" = "未使用アプリ";
+"category.project_build_artifacts" = "プロジェクトビルド成果物";
"view_mode" = "表示モード";
"sort_by" = "並べ替え";
@@ -845,6 +846,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 de97925..6532ad5 100644
--- a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
@@ -567,7 +567,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";
@@ -845,6 +846,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 fe6f48c..714ac5d 100644
--- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
@@ -591,6 +591,7 @@
"category.sleep_image" = "Образ сна";
"category.duplicate_files" = "Дубликаты файлов";
"category.unused_apps" = "Неиспользуемые приложения";
+"category.project_build_artifacts" = "Артефакты проектов";
/* Process View - Missing Keys */
"view_mode" = "Режим просмотра";
@@ -952,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/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
index 0c3f2c2..9cff7a8 100644
--- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
@@ -588,6 +588,7 @@
"category.sleep_image" = "Образ сну";
"category.duplicate_files" = "Дублікати файлів";
"category.unused_apps" = "Невикористовувані додатки";
+"category.project_build_artifacts" = "Артефакти проєктів";
/* Process View - Missing Keys */
"view_mode" = "Режим перегляду";
@@ -949,3 +950,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 4148fc4..8a8a344 100644
--- a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
@@ -568,6 +568,7 @@
"category.sleep_image" = "休眠镜像";
"category.duplicate_files" = "重复文件";
"category.unused_apps" = "未使用应用";
+"category.project_build_artifacts" = "项目构建产物";
"view_mode" = "视图模式";
"sort_by" = "排序依据";
@@ -845,6 +846,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" = "身份匹配";
From 979e7d5cca78323b13afec3c98da1ec3aa650708 Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 16:16:25 +0300
Subject: [PATCH 04/10] feat: implement file navigation breadcrumbs, quick look
preview, and search functionality in Disk Analyzer
---
.../DiskAnalyzer/DiskAnalyzerView.swift | 254 +++++++++++++----
.../DiskAnalyzer/DiskAnalyzerViewModel.swift | 117 +++++++-
.../Features/DiskAnalyzer/DiskItem.swift | 39 ++-
.../Features/DiskAnalyzer/DiskScanner.swift | 268 +++++++++++++-----
.../MacOSCleanerTests/DiskScannerTests.swift | 211 ++++++++++----
.../LocalizationCompletenessTests.swift | 5 +-
.../Resources/de.lproj/Localizable.strings | 3 +
.../Resources/en.lproj/Localizable.strings | 3 +
.../Resources/es.lproj/Localizable.strings | 5 +-
.../Resources/fr.lproj/Localizable.strings | 3 +
.../Resources/it.lproj/Localizable.strings | 3 +
.../Resources/ja.lproj/Localizable.strings | 3 +
.../Resources/pt-BR.lproj/Localizable.strings | 3 +
.../Resources/ru.lproj/Localizable.strings | 3 +
.../Resources/uk.lproj/Localizable.strings | 5 +-
.../zh-Hans.lproj/Localizable.strings | 3 +
16 files changed, 737 insertions(+), 191 deletions(-)
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/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
index 1f05b3f..c1f0a61 100644
--- a/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/LocalizationCompletenessTests.swift
@@ -44,7 +44,10 @@ final class LocalizationCompletenessTests: XCTestCase {
"category.project_build_artifacts",
"settings_project_artifacts_age",
"settings_project_artifacts_age_sub",
- "settings_days_count"
+ "settings_days_count",
+ "disk_analyzer_items_count",
+ "disk_analyzer_quick_look",
+ "disk_analyzer_open_folder"
]
func testAllSupportedLanguagesContainRequiredLeftoverKeys() {
diff --git a/MacOSCleaner/Resources/de.lproj/Localizable.strings b/MacOSCleaner/Resources/de.lproj/Localizable.strings
index 66fb0e1..144f427 100644
--- a/MacOSCleaner/Resources/de.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/de.lproj/Localizable.strings
@@ -116,6 +116,9 @@
"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";
diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings
index e7b3a06..1444339 100644
--- a/MacOSCleaner/Resources/en.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings
@@ -116,6 +116,9 @@
"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 */
diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings
index dd724f2..8914f4e 100644
--- a/MacOSCleaner/Resources/es.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings
@@ -115,7 +115,10 @@
"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 */
diff --git a/MacOSCleaner/Resources/fr.lproj/Localizable.strings b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
index 417af5a..0cd9552 100644
--- a/MacOSCleaner/Resources/fr.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/fr.lproj/Localizable.strings
@@ -116,6 +116,9 @@
"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";
diff --git a/MacOSCleaner/Resources/it.lproj/Localizable.strings b/MacOSCleaner/Resources/it.lproj/Localizable.strings
index 8573b03..71df514 100644
--- a/MacOSCleaner/Resources/it.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/it.lproj/Localizable.strings
@@ -116,6 +116,9 @@
"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";
diff --git a/MacOSCleaner/Resources/ja.lproj/Localizable.strings b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
index 1f95076..9fe3a45 100644
--- a/MacOSCleaner/Resources/ja.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ja.lproj/Localizable.strings
@@ -116,6 +116,9 @@
"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 について";
diff --git a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
index 6532ad5..80c7f1d 100644
--- a/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/pt-BR.lproj/Localizable.strings
@@ -116,6 +116,9 @@
"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";
diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
index 714ac5d..d0e122e 100644
--- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings
@@ -116,6 +116,9 @@
"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 */
diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
index 9cff7a8..608c8c1 100644
--- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings
@@ -115,7 +115,10 @@
"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 */
diff --git a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
index 8a8a344..990231d 100644
--- a/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
+++ b/MacOSCleaner/Resources/zh-Hans.lproj/Localizable.strings
@@ -116,6 +116,9 @@
"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";
From cd619af6c0bc810841b600cafed0410309a50ab5 Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 16:41:00 +0300
Subject: [PATCH 05/10] refactor: implement Time Machine snapshot thinning and
standardize DNS flush execution via PrivilegedTaskRunner
---
.../Domains/Cleanup/CleanupEngine.swift | 50 +++++++++++--------
.../Infrastructure/PrivilegedTaskRunner.swift | 2 +-
.../CleanupEngineTests.swift | 8 +++
3 files changed, 39 insertions(+), 21 deletions(-)
diff --git a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift
index 08f2190..00b20f7 100644
--- a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift
+++ b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift
@@ -2388,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
@@ -3105,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/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/MacOSCleanerTests/CleanupEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift
index 4a232fc..74fc26a 100644
--- a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift
+++ b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift
@@ -615,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()
From 896230e04e72919c1e83ec990ec0a67312ab568b Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 16:45:59 +0300
Subject: [PATCH 06/10] fix: add cancellation support and safety bounds to
uninstaller deep scan task
---
.../Uninstaller/UninstallerView.swift | 34 +++++++++++++++----
1 file changed, 27 insertions(+), 7 deletions(-)
diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift
index b072f4c..2260fce 100644
--- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift
+++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift
@@ -46,6 +46,7 @@ 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
@@ -132,7 +133,14 @@ struct UninstallerView: View {
}
)
}
- .onAppear(perform: loadApps)
+ .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 }
@@ -215,10 +223,10 @@ struct UninstallerView: View {
VStack(spacing: 0) {
if isDeepScanning {
VStack(spacing: 4) {
- ProgressView(value: Double(deepScanCompleted), total: Double(deepScanTotal))
+ ProgressView(value: Double(min(deepScanCompleted, deepScanTotal)), total: Double(max(1, deepScanTotal)))
.progressViewStyle(.linear)
.padding(.horizontal, 8)
- Text(String(format: "uninstaller.deep_scanning_progress".localized, deepScanCompleted, deepScanTotal))
+ Text(String(format: "uninstaller.deep_scanning_progress".localized, min(deepScanCompleted, deepScanTotal), deepScanTotal))
.font(.caption2)
.foregroundColor(.secondary)
}
@@ -299,9 +307,16 @@ struct UninstallerView: View {
}
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
@@ -313,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
}
@@ -323,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
+ }
}
}
From 191cec0097aaa780fb836cbc3d42afcc6e38767d Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 16:53:59 +0300
Subject: [PATCH 07/10] refactor: update UI styling, improve iCloud file
handling during scanning, and add fallback URLs for system settings
navigation
---
.../Features/Dashboard/DashboardView.swift | 6 ++-
MacOSCleaner/Infrastructure/FileScanner.swift | 11 ++++-
.../Infrastructure/PermissionsManager.swift | 40 +++++++++++++++----
3 files changed, 46 insertions(+), 11 deletions(-)
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/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.
From 37ce9a2b51c6b1b85fe5f37421ddfcb1c45acdcc Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 16:58:46 +0300
Subject: [PATCH 08/10] docs: update feature descriptions and remove deprecated
roadmap link in README.md
---
README.md | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index f1c52e0..e9e239c 100644
--- a/README.md
+++ b/README.md
@@ -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, applications, developer project build artifacts (Xcode, Docker, Rust `target`, Go `pkg`, Python `.venv`, Node `node_modules` with configurable age limits), local AI models (Ollama, MLX, Hugging Face), and safe Time Machine snapshot thinning (`thinlocalsnapshots`).
+- **Forensic Uninstaller:** inspects 30 evidence types (Bundle ID, Team ID, Spotlight metadata, Launch Services) to find remnants, 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)
From 5e46b0ab76e41a274cba4a53d55ed8444dc0c930 Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Wed, 26 Aug 2026 17:13:09 +0300
Subject: [PATCH 09/10] docs: update feature descriptions to reflect expanded
cleanup and uninstaller coverage
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index e9e239c..39f445d 100644
--- a/README.md
+++ b/README.md
@@ -83,8 +83,8 @@
## ✨ Features
-- **Smart Cleanup:** scans 55+ categories across system caches, applications, developer project build artifacts (Xcode, Docker, Rust `target`, Go `pkg`, Python `.venv`, Node `node_modules` with configurable age limits), local AI models (Ollama, MLX, Hugging Face), and safe Time Machine snapshot thinning (`thinlocalsnapshots`).
-- **Forensic Uninstaller:** inspects 30 evidence types (Bundle ID, Team ID, Spotlight metadata, Launch Services) to find remnants, with standalone orphaned residuals discovery, confidence score tiers, post-uninstall review sheets, and root-level helper removal.
+- **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:** 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.
From 86a552a4c9e4f1c2a14f07908e474632d7aaaf15 Mon Sep 17 00:00:00 2001
From: AlexTkDev
Date: Thu, 27 Aug 2026 18:51:00 +0300
Subject: [PATCH 10/10] chore: update application screenshots to version 2.2
---
README.md | 8 ++++----
assets/screenshots/About_v2_1_1.png | Bin 506909 -> 0 bytes
assets/screenshots/About_v2_2.png | Bin 0 -> 549428 bytes
.../screenshots/Cleanup_Scan_Results_v2_1.png | Bin 583396 -> 0 bytes
.../screenshots/Cleanup_Scan_Results_v2_2.png | Bin 0 -> 559795 bytes
assets/screenshots/Cleanup_Scan_v2_1.png | Bin 678285 -> 0 bytes
assets/screenshots/Cleanup_Scan_v2_2.png | Bin 0 -> 636875 bytes
assets/screenshots/Cleanup_page_v2_1.png | Bin 690489 -> 0 bytes
assets/screenshots/Cleanup_page_v2_2.png | Bin 0 -> 632046 bytes
assets/screenshots/Dashboard_v2_1.png | Bin 916941 -> 0 bytes
assets/screenshots/Dashboard_v2_2.png | Bin 0 -> 863952 bytes
assets/screenshots/Disk_analyzer_v2_1.png | Bin 894900 -> 0 bytes
assets/screenshots/Disk_analyzer_v2_2.png | Bin 0 -> 598106 bytes
assets/screenshots/Duplicate_Finder_v2_1.png | Bin 521331 -> 0 bytes
assets/screenshots/Duplicate_Finder_v2_2.png | Bin 0 -> 628683 bytes
...en_v2_1.png => Permission_screen_v2_2.png} | Bin
assets/screenshots/Processes_v2_1.png | Bin 796153 -> 0 bytes
assets/screenshots/Processes_v2_2.png | Bin 0 -> 684197 bytes
assets/screenshots/Settings_AI_v2_1.png | Bin 767446 -> 0 bytes
assets/screenshots/Settings_AI_v2_2.png | Bin 0 -> 751500 bytes
assets/screenshots/Settings_Advanced_v2_1.png | Bin 659071 -> 0 bytes
assets/screenshots/Settings_Advanced_v2_2.png | Bin 0 -> 600254 bytes
assets/screenshots/Settings_Cleanup_v2_1.png | Bin 747331 -> 0 bytes
assets/screenshots/Settings_Cleanup_v2_2.png | Bin 0 -> 776064 bytes
.../screenshots/Settings_General_v2_1_1.png | Bin 948713 -> 0 bytes
assets/screenshots/Settings_General_v2_2.png | Bin 0 -> 928399 bytes
.../screenshots/Settings_Processes_v2_1.png | Bin 589423 -> 0 bytes
.../screenshots/Settings_Processes_v2_2.png | Bin 0 -> 537636 bytes
assets/screenshots/Startup_Services_v2_1.png | Bin 631822 -> 0 bytes
assets/screenshots/Startup_Services_v2_2.png | Bin 0 -> 622163 bytes
..._1.png => Uninstaller_2_versions_v2_2.png} | Bin
.../Uninstaller_Leftovers_v2_2.png | Bin 0 -> 782555 bytes
.../screenshots/Uninstaller_Shared_v2_1.png | Bin 1135256 -> 0 bytes
...can_v2_1.png => Uninstaller_scan_v2_2.png} | Bin
assets/screenshots/Uninstaller_v2_1.png | Bin 924973 -> 0 bytes
assets/screenshots/Uninstaller_v2_2.png | Bin 0 -> 1131805 bytes
36 files changed, 4 insertions(+), 4 deletions(-)
delete mode 100644 assets/screenshots/About_v2_1_1.png
create mode 100644 assets/screenshots/About_v2_2.png
delete mode 100644 assets/screenshots/Cleanup_Scan_Results_v2_1.png
create mode 100644 assets/screenshots/Cleanup_Scan_Results_v2_2.png
delete mode 100644 assets/screenshots/Cleanup_Scan_v2_1.png
create mode 100644 assets/screenshots/Cleanup_Scan_v2_2.png
delete mode 100644 assets/screenshots/Cleanup_page_v2_1.png
create mode 100644 assets/screenshots/Cleanup_page_v2_2.png
delete mode 100644 assets/screenshots/Dashboard_v2_1.png
create mode 100644 assets/screenshots/Dashboard_v2_2.png
delete mode 100644 assets/screenshots/Disk_analyzer_v2_1.png
create mode 100644 assets/screenshots/Disk_analyzer_v2_2.png
delete mode 100644 assets/screenshots/Duplicate_Finder_v2_1.png
create mode 100644 assets/screenshots/Duplicate_Finder_v2_2.png
rename assets/screenshots/{Permission_screen_v2_1.png => Permission_screen_v2_2.png} (100%)
delete mode 100644 assets/screenshots/Processes_v2_1.png
create mode 100644 assets/screenshots/Processes_v2_2.png
delete mode 100644 assets/screenshots/Settings_AI_v2_1.png
create mode 100644 assets/screenshots/Settings_AI_v2_2.png
delete mode 100644 assets/screenshots/Settings_Advanced_v2_1.png
create mode 100644 assets/screenshots/Settings_Advanced_v2_2.png
delete mode 100644 assets/screenshots/Settings_Cleanup_v2_1.png
create mode 100644 assets/screenshots/Settings_Cleanup_v2_2.png
delete mode 100644 assets/screenshots/Settings_General_v2_1_1.png
create mode 100644 assets/screenshots/Settings_General_v2_2.png
delete mode 100644 assets/screenshots/Settings_Processes_v2_1.png
create mode 100644 assets/screenshots/Settings_Processes_v2_2.png
delete mode 100644 assets/screenshots/Startup_Services_v2_1.png
create mode 100644 assets/screenshots/Startup_Services_v2_2.png
rename assets/screenshots/{Uninstaller_2_versions_v2_1.png => Uninstaller_2_versions_v2_2.png} (100%)
create mode 100644 assets/screenshots/Uninstaller_Leftovers_v2_2.png
delete mode 100644 assets/screenshots/Uninstaller_Shared_v2_1.png
rename assets/screenshots/{Uninstaller_scan_v2_1.png => Uninstaller_scan_v2_2.png} (100%)
delete mode 100644 assets/screenshots/Uninstaller_v2_1.png
create mode 100644 assets/screenshots/Uninstaller_v2_2.png
diff --git a/README.md b/README.md
index 39f445d..146889c 100644
--- a/README.md
+++ b/README.md
@@ -54,12 +54,12 @@
## 📸 Screenshots
-
-
+
+
-
-
+
+
diff --git a/assets/screenshots/About_v2_1_1.png b/assets/screenshots/About_v2_1_1.png
deleted file mode 100644
index e00ac20756588156947c52c889c21090cfc7bfd0..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 506909
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
6Kbo&NfW&|58Z6v>m3oEO*-E_VCaMv3x
zellod=5~EYtCcL&VTv~ledolBc%6~%zwy-0Vn9SK4n0HS)PJ8f+g9wpcqA@G
z?9YJzQlWn3iv{n~izi{MxVmraSTD5YwdgFjMC~%#FB1qr_@W0A`rhw;)V<9^A!QLk
zoN{+oAs?szsjK`T?dR$7=X_c;;}`UVWzSE-!4LvQImAP?9V7iwV%od({xaQFGUrLs
z!v<^n1flBPsk&V}`<$%qn#`xGcl6hLO|Gz#zH>Rx{bsZ|jlZ9guOFD#jF2#yPTbQtdrN|6&0y;(&nhZU?B5E+m|p9-pWc&r;<{&7QPn>ZeN
za4~QxJJ-9H0rQ2=i84H8rROK%y^1BP>LPFz9!*if$)uDUK?^~iA4@>y#H+`}m=>)Z
z-Ad}SKW>~ZRoIB(v2SNdfiK!x)J}%)eSM>TsC3h%`JB||f;-qF!fiKGG#|ll*n9TX0`3=%y
z{I>tq|2_+VnnNyNnBp-8%J;Z!oEP^whq<>|J2nwRO0P-G55rV(=!>}3E4fOP^XkJw
zLW~e5p0of=%^tq!(BQnLh`zL&kkqEZ3v-;VpYfeHL!ISreF?UveODCtm^;q&^NLP1
zQfT82)_$SXusy#yaiJx?77?FK`13M>e7y$Tc8kT}KB8*L;AHN9o^nI;9oU+P?rOEh
zu=Pv!+z77&+a+oHKdLGyr0r7Pii6b$_x8Rg4(~Pa(+(W08pUf$n1r-V;p*3wE`juX
zMgmykuM`5Ur3kY(1DobIjuze80nn?rJc`n%K*lYwi&TkNF3yWuhw#>uslF$pead#j
zlSx_1R{I(Im_utu77Yng$rbPgb;lnE4FwOLPlkvI8%9l2!?>s$i+>dSC|0sER9Dr!
z*$RIu8dmJwONki}YCOt_-Z?CF85&mWo7(z0P*RE$4{^IYP;+hI9S^Zp!Lbo
zKNj-rK|W`f4IqZr!T1=@aE-+BkRSB_L7Qp(KuT^}B&|j3j?I?2Wrh!f#mQ4AVkcsr
z&H@o=Ia2YxdE2{J?o`zoSfDAvu3CgH$9Z#?6PO<9!31vn%+bEw*0o7K!G#@FvQ}JO
zth_&oWW|lPvc<9MT41nizeR){VGK|>!|pD`@Lq~wLt6(35715?3#SCDETa}L>k?-@
zU@WW)=9UP=3vg-nIb5xD{Y_>aTYR={^dh_Z^)wo~=#1~}=rvHla{2=zAzLGyD3vJp
zhxMkLRkyN?uq-i9t)>N+HGwhRVdkx=rGbH6{6+8m@o-2-$8d@bZ=%`nEu-I~O~~99
zcd-OkRo06x!iekaMa-Je=mjox;j*(wEs~cof}D6jW4$7Y*~6N#1>Kt{QG59~jh`i%
zKoeA%q=%eCDmQ|4I#lS_LHAc(CBWIV-Xd`fEJvuR^7U{ox9Tf%GU?w6@B=tN8r_qU
zpf0kOjMXoBLYDIvVlylnt-Bo(p`V`+@Uy~>vk$4YOjQ!>m^a3|PJ8Zx^RWrElElJa
zt7ZFVS)3Fanb}AtCh;-9CM_^Uh?)P?*>ax4uDQ5nz{sP;pAhsPrq=$A#KMn$9c@=0
zf?GZ_(})YROc_zxk7toU$TO+yT{nj}hKeJU_98&?$Y)0VyFQL*991bKyCOzYW_>eC
z(sU(x{*I_(U8Kl>Z;Uv;s9J$v2W-ERDLup1NlXptwJwl8^U@Biy{*=_m9n#ze>Gj=
zExEJf;kyV%*dmxb)CHZ=)j;K54$?i_C}6N(q_NDxR^`O&K`4OQtwVY~P6+zB1Iu=7
z&N3fg)Q~ZX>Q<7`a&=5(xMk$T1YZsf(LB9?g^T*kj_j*}j5m3?wxH=(oKTE9hk79WP
zTvU1#gjs)FSr!xqp_pGzEiZCd`=eMHF?t3~Pf-%#sIYJOZKcXl&?%gu^0iGqo<=kw+g{_tz9XK#76D)y!`d9l)Wt
z=fU^qmtiSeF$hL0wp%q!8AkI0FKu5OKu
zAlUcrc!)AM{mRFIdVqSUB{3({HRw3|>2>9w!EoV@-3NIW;6U@a|)
zqT48(Ws^mPMZ}Jnq1tIbio~?FLa|0wP$drP%v^VY^ut$^VMzemEmmvHvK>piq^EeG
zgM!nAF^;B66|9yKr+St`C2e$Eb5DK|h;CVP~pYsD}lsL52Zo*?%}GNR3gjtatzD4sZg7Q1P(NgrwJV=Z@gb)FsaWh6tf1lLI{&QW;^Edm
zc-wKdG~HrbNgcScj@#;g=BLq5$ZnU=zNP-XS|O*qxGVAC!Dubm#a-c82#SrfZPIxX
zcUQBWPmrD2D-ZZOIA=+AeS>ZZJpOpI+d=g_F)ZWBYrgj{gDMaBtKdM2cgSXm-=D}B
zrM-goBxb+XO^;UX*z%zgR(PIa>pvqy5te|9#-=o%Nm`bn3-AZ(#sdgiYL77EW!UYZ
zROhkOCFa>QP$s%*340s!Knos&l=vzdbfz5*UYW6B&lr>wgIP8R9m(_sUa(!XEW$>c
ziXOKCN(0yPQEqghjbr_jqYbN>l
zGY@3mc>b@jQ+u@@y_yfJq(fwQ%9VJyTtUD47f_+up6hA(8+(kK
z6;w@A1rg*oN#5hyXT@SpRHC1NSqO9QdDP`o-bZ!y%)J?%VxR1+Sp7D%=NST6FnrBf
z){PO2I{y?I^JTq4)d$2DIoI}5`_-ZWLqk=hc6*7#znu&MA@5i(2Wn7xCUw}>7#M(B
z5Pk1+YZwgN92)(?y!1Ktxpm`al$uO9F>i4#YMe>5bM*qKX01{K7+*GtZQ3KPSMkmqKtk|
zJUo)emE-o!*0nrdv`XjjaW3URQqR?2V8g=}z6mIFM5MVY@P%Av24VQ9KQDz^0-$!L
ztxIz$Nzz=Dc_kvT)ksLL9?Q5qUpE?Cc4Xvrf
zDvZ@O`0)FS`Ie)vLzE4dxpis;Lp
zd(06ycf#hWj94&I(%KB6{`hj`@RJxdK9!xuEm#?gPYxJP4%X_$()_DAUBRKu`5Yex
zb`3iHz2^+XHlfVfCk1LIp}9PV^NK+~#@T*kVT*&*HJ5#i$cS`CPEk8`YEXj6!e4yR
z;BE8MpDU%Nv%@#w{?xk494ousJDZ`u{^+WWDY!_hscP2yxX8GXOT-z;7kis-h3kP9V-MpWDiKIXMeZu68{#sXmB^INtDI0lZ_B0dpj3%XSj?(ruOcp$Lsirz5mh9g@@lCz78EE+nlqwn9fw&
zjA`CD(7GTkm6m5T&&L;)^Trfphy}g-KECP73Kz=|yfAXP*XCT_cd_d_P3s9@KDqp0
znK>16+%yg_wm*u=7JZ!lf#4$P`Za}Q=IE&8?FrPGbpsfg;oVV4*KOIxMDoE30lOGP
z)$|PfWo@-dlS}AKPn9d3d*^k?t*nCj^Xl(=?u*?$DKCf
zX1_t{Rr%``{W_OmHvoku0Pk>c$}99pcKIx7M?`jwUr*2uQvFK;r
zoEvhdD7zni8nF16|Fmttn0_sgcCP&KYH?`CLyOx&b~)0+UOc%^zz%qQTW({3>dTfukgQr8P{>I6<*sR5I|x>Sk<1f`Ln^j-Xl~mOI8KhEYbOdXw**d|JLn{b}+m+
z-er@MNc5X4-WB0m`OFOYW%W-!b(ZWs)Sq29#NW&v&|7-_-Y&O_vH3i?MH#DCY5k-`
zx+De^H6dAh{wC@b&NMYKW5=|4z}~rhlAFnh1rBFP*?5TvcQUL_#Ey9eHuTc&M-~Hg
z;;0BoEu9ls#2pj&c+|1qu3W}-YVu>y+}B4ZN`8J!l6Fr2fK+w0DinHG6#qZ5xu17W
z=>^3Letj_pKE~Ef713BE{sZ;@=rrou3ZBfklFsN`3W@cJ-9G3(y$K?aY+4hg}I1ZBHxWnR
zadk})&)MDjI&X*VTDqJ!2}N1EooCNYqBK03uD(NV{}J~uE>FM2WDaamzMguG(P)ak
z>mrnWC&9*KR|rF98M?M)`$A;UTP68$J%SU3f0wQhS
zF*_K%_Hii(39zt^7M5L#AT5KKCGau-z|}$Ni;?Ec{MpXC(=^t<;wFPOE#myA@;F07
zV-o0`c&RE-b!m<2bFcQ73g~r~bJ(9r`y;ol9<=QFr74dVU$e?nA7Q(Bq0a3R2DR$>
zF;j!Z-=a=)pIs>q8g##n=-9e!_8VEeC~zIhII1bnz3o|Fy2*T;aT~Bzl(Fffpk+pz
zEZNu`5V$x;t1r+u^yjkD?VlX?pZsMRim0KuT#MaVG19E;Km99hkhQbySM?Ko4&F?s
zl?&qy3X)+8dgr(Aksp`UG_5grAs9!4&-e6PP2kb0E2~@^CMW^DX$uT6LM4Yzdz}=R
zXWwac-e=F@$znCNmrgm~BDF?(r0f^#yK97nx+rofh{}}ZCEV7qOVCe=N$HJauF9>#
z)z5Sq$Ry-f*P&m*Eh21~gdXws7kOGEgSzckHw;(=M68ZTl>GM%U6BZJ??t&xF?Zh4
zz~z$T66`GI>F$^ZWcvK`%3FW9#d|($B!o?3$7Lk0XFJgpZOh-nU!uM~z@iG>1g_N-NyNBtNT&PaR=^O@`KafE<$c19N{
z1B52m~mUl6;kKHm1XwD>lmpTpUb4QOs}0rlG(2-%R9b0HtJSw1{a~Rm}3?ctYuG>
zWuYMlaqE7Tn^iOuLgt~rphl9P9O?6Jp%BLq>xenH#40-3A*_Z0TL{@RZ5Ma%9^Z$%
z5Oavi*8y*tvjJMAM(d;_2~MVbYpmM8FbmdqtUzR9k=ga0x6{{!&=_O&svNQ^+(aXH
zq#KCs$N?SKX(e=}eeLXwTrpg48?Tbd%v#hiIyRJA3S2q9J<=>@f;}8DY5PoLWc=h1
z$VpT@<^f6r6H5b(EP>=Lc=SoOL8I#IvwJd9Z-Fg;kvD3GzNGDsuoA+02b;4CxAnR`
z7FaO4HO`~yFrczo^4cBtZQfz$H_*w*cS^C=^hN?)}zQDTga_M3qPfC82vV7Q5?x@!BeYXfE*g0D;Nsru#Rx4o`
zj}a13U+n|@eDX=6Ok?0ddDqj`fd#x2vhGB55w7TuW`5I`{kCjt4i8&9*
z)`c);`QHOM$7vQ{;ph+GV!Q7G;Z?Xa2hI>s(m5XEV$5y>EAV>
z!)Am#lUBQYNuR1^S9|@fdiW}oS1q@hQAFVwOBoK1{pz=~kysx9=C*P7O9a55PHSr~
zPlhJu&frHcINUPyB}^N68efE!el5tH
ze3Ql=ta)k`BaU~`d}xih9wIUEg!JUlt^N*U;2*KERu6i=NXUAa*!``@y$|N(9t|W_
z!JEy9rbgJnz4F}ua~Rbw--w=Azi05Z0D$l`PS-L;4Q<|$7kZwnDEa55k|~0>1G%d%
zjsO{ITgXB0nEk5EGHc_>=D`BoEJI90Ot>fO;uw|@{3wrUKw}k0;!{T&$(|0hM
zby0iwy}Pfkmy(?4Ts<1ED+?UjP>mz=X|ok0{OvC2-J7!WUmgByXYDfR7H4%c$W0O|
zUKDy^H2{E%(kEDFf6rpXtvN|WRe*8wcIB0HCM7~r>?||820S+6tvVp01c431|NO;Yn;6wXP22qEkSLvfVTSenh8+V12oJdLV{IY
zp{DR=A5<>BAFidQg1Df3C5?S?NX%prIcV64I2|kt@g2Jb^9sf1CS{_0g;XTX-h#}@
zGGz;|KgFUm``O}uzNr~3cmrxYvMfL}`mzvmO2PjLj%nx?R(qtYkn7@ZzWYApvW-%*
zt$Sb#Qs@?v^O00;@Ev|1xpP@2jyD)uf%cS1bV@5^;H(Z*a$Y~KGZxF&%J|kW+$He(
zMM{br*|FZ2)JS#2n`He@GW1w!Jw?FW8Yy!0FtgH)YJK+tyGuav+Yj6rmn^!~-MeMT
zvx^OBCg6mgf_OrU29RSqfd8Rdo@ObC>2j4Fh6w1-5;y_Kl%Pf
zG_hByZM}ybl`+DVd(o4{UISpImpCbn(F~{HBr5J;GBCfXv_0jH1bKl0O;CRU7_qMNRf~Baj}!x
zFZ8e^{Eki}F=uhyNFsU4>NCxfcqIpM)c96uqzkMT~G54MsFbTyR)F7Wb
zxOm{4*u4pDmrDHB^?_DDV9I45EJgdd7BNu~rrY~a^q|lg;?kIoJw~{}etB!*t6k>T
zeW}3AvOlJ$vK;MPd{*{Lt$3Zk=De+R702S)OZWUzWb<&GO
z*K&cv9c?j%(adlSD*h$#EM}@R7OS;wS?DWWRmjltWA9$iKvK)M3BU&uZEUe@d0pdn
zHY+a4Tqb^rMOUbR=P^xvO#Y
zgoNpmSVczN(o3oQ=+Y3o9Q$!JmYA(pjRt3z@RG&W|My3H=B_>gAh-D9l4D`g1XBj*@P^9I8qTytetsr9+RJ@ncYNk18YxdNV
z)_9)(xfrJF0&mu85zT}kRnBD(n>!N<7GpE^bR=&P_yXXb@wIKN(O@56&~zT0vp$(d
zvXyxh;!I~wJ=cmuK5HpNQdUzFM>j{C6BNp3fm0eaP_`eTC(Bv}xtIy83FDKMXew