From 014501ab2be73ca9085d35bec3e48db3492f2fce Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Tue, 14 Jul 2026 17:41:03 +0300 Subject: [PATCH 01/23] chore: bump project version to 2.0.0 --- MacOSCleaner/Features/About/AboutView.swift | 2 +- MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj | 4 ++-- MacOSCleaner/project.yml | 4 ++-- README.md | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift index ecec158..4361f1c 100644 --- a/MacOSCleaner/Features/About/AboutView.swift +++ b/MacOSCleaner/Features/About/AboutView.swift @@ -132,5 +132,5 @@ struct AboutView: View { } #Preview { - AboutView(availableUpdate: "1.2.0") + AboutView(availableUpdate: "2.0.0") } diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index 3c053a4..3ee7457 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -1074,7 +1074,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; SWIFT_COMPILATION_MODE = singlefile; @@ -1169,7 +1169,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/MacOSCleaner/project.yml b/MacOSCleaner/project.yml index a200be1..d702f8d 100644 --- a/MacOSCleaner/project.yml +++ b/MacOSCleaner/project.yml @@ -20,8 +20,8 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: input.MacOSCleaner - MARKETING_VERSION: 1.1.1 - CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: 2.0.0 + CURRENT_PROJECT_VERSION: 2 ENABLE_HARDENED_RUNTIME: YES SWIFT_VERSION: 6.0 CODE_SIGN_STYLE: Automatic diff --git a/README.md b/README.md index 251129d..5288a20 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Language: Swift 6](https://img.shields.io/badge/Language-Swift%206-FA7343.svg?logo=swift&logoColor=white)](https://swift.org) [![UI: SwiftUI](https://img.shields.io/badge/UI-SwiftUI-007AFF.svg?logo=swift&logoColor=white)](https://developer.apple.com/xcode/swiftui/) [![Build: XcodeGen](https://img.shields.io/badge/Build-XcodeGen-black.svg?logo=xcode&logoColor=white)](https://github.com/yonaskolb/XcodeGen) -[![Version: 1.1.1](https://img.shields.io/badge/Release-1.1.1-brightgreen.svg)]() +[![Version: 2.0.0](https://img.shields.io/badge/Release-2.0.0-brightgreen.svg)]() 🧹 Free up disk space by cleaning caches, temp files, app leftovers, and more. Everything goes to Trash — nothing is gone forever unless you say so. @@ -271,7 +271,7 @@ codesign --verify --deep --strict --verbose=2 "/path/to/MacOSCleaner.app" spctl --assess --type execute --verbose "/path/to/MacOSCleaner.app" ``` -**Fix damaged app attributes:** +**⚠️ Fix damaged app attributes** (if mac asks you to move app to trash): ```bash sudo xattr -r -c /path/to/MacOSCleaner.app ``` From cdf2703c27b9b636a15fdfbe076c3d543d55e176 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Tue, 14 Jul 2026 18:55:24 +0300 Subject: [PATCH 02/23] feat: implement DiskAnalyzer feature with scanner and view models, and update CleanupItemManager to track precise byte sizes. --- MacOSCleaner/App/MacOSCleanerApp.swift | 7 + MacOSCleaner/App/RootView.swift | 2 + .../Domains/Cleanup/CleanupCoordinator.swift | 15 +- .../Domains/Cleanup/CleanupEngine.swift | 4 +- .../Domains/Cleanup/CleanupItemManager.swift | 38 ++- .../Domains/Cleanup/CleanupModels.swift | 11 + .../Domains/Cleanup/CleanupNotifier.swift | 8 +- .../Features/Cleanup/CleanupView.swift | 14 +- .../Features/Cleanup/CleanupViewModel.swift | 2 + .../DiskAnalyzer/DiskAnalyzerView.swift | 309 ++++++++++++++++++ .../DiskAnalyzer/DiskAnalyzerViewModel.swift | 151 +++++++++ .../Features/DiskAnalyzer/DiskItem.swift | 65 ++++ .../Features/DiskAnalyzer/DiskScanner.swift | 214 ++++++++++++ .../Features/Uninstaller/AppIdentity.swift | 2 +- .../Infrastructure/String+Localization.swift | 12 + .../MacOSCleaner.xcodeproj/project.pbxproj | 32 +- .../MacOSCleanerTests/DiskScannerTests.swift | 71 ++++ MacOSCleaner/Models/NavigationItem.swift | 3 + .../Resources/en.lproj/Localizable.strings | 128 +++++--- .../Resources/es.lproj/Localizable.strings | 33 +- .../Resources/ru.lproj/Localizable.strings | 158 +++++---- .../Resources/uk.lproj/Localizable.strings | 155 +++++---- 22 files changed, 1212 insertions(+), 222 deletions(-) create mode 100644 MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift create mode 100644 MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift create mode 100644 MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift create mode 100644 MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift create mode 100644 MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift diff --git a/MacOSCleaner/App/MacOSCleanerApp.swift b/MacOSCleaner/App/MacOSCleanerApp.swift index 053d25b..d6ae494 100644 --- a/MacOSCleaner/App/MacOSCleanerApp.swift +++ b/MacOSCleaner/App/MacOSCleanerApp.swift @@ -3,8 +3,15 @@ import OSLog private let crashLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "input.MacOSCleaner", category: "Crash") +class AppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} + @main struct MacOSCleanerApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate // Note: AppIntents/linkd errors (com.apple.linkd.autoShortcut connection failures) // are expected system noise on some macOS versions. They cannot be fixed in app code // as they originate from the system's AppIntents framework initialization. diff --git a/MacOSCleaner/App/RootView.swift b/MacOSCleaner/App/RootView.swift index 56eb2b8..c67aeae 100644 --- a/MacOSCleaner/App/RootView.swift +++ b/MacOSCleaner/App/RootView.swift @@ -47,6 +47,8 @@ struct RootView: View { DashboardView(journal: journal) case .cleanup: CleanupView(viewModel: cleanupViewModel) + case .diskSpace: + DiskAnalyzerView() case .processes: ProcessesView() case .startupServices: diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift index 20ffb32..9623745 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift @@ -23,6 +23,7 @@ public final class CleanupCoordinator: @unchecked Sendable { public var totalSteps: Int = 1 public var stepTitle: String = "" public var totalFreedMB: Int = 0 + public var totalFreedBytes: Int64 = 0 public var cleanedItems: [CleanupResultItem] = [] public var skippedItems: [SkippedCleanupItem] = [] public var lastError: String? = nil @@ -94,6 +95,7 @@ public final class CleanupCoordinator: @unchecked Sendable { let trashItem = CleanupPreviewItem( label: localizedLabel, sizeMB: trashSizeMB, + sizeBytes: trashSizeBytes, risk: .safe, isSelected: true, isDeletable: true, @@ -105,7 +107,7 @@ public final class CleanupCoordinator: @unchecked Sendable { try self.stateMachine.transition(to: .preview) self.notifier.sendScanComplete( - selectedSizeMB: self.itemManager.selectedSizeMB, + selectedSizeBytes: self.itemManager.selectedSizeBytes, showNotifications: self.settings.showNotifications ) } catch let error { @@ -130,6 +132,7 @@ public final class CleanupCoordinator: @unchecked Sendable { try self.stateMachine.transition(to: .executing) self.totalFreedMB = 0 + self.totalFreedBytes = 0 self.cleanedItems = [] self.lastError = nil self.scriptLogs = [] @@ -154,8 +157,9 @@ public final class CleanupCoordinator: @unchecked Sendable { for result in results { self.totalFreedMB += result.freedMB - self.cleanedItems.append(CleanupResultItem(label: result.label, freedMB: result.freedMB)) - records.append(OperationRecord(id: UUID(), itemPath: result.label, status: "success", bytesFreed: Int64(result.freedMB * 1024 * 1024))) + self.totalFreedBytes += result.freedBytes + self.cleanedItems.append(CleanupResultItem(label: result.label, freedMB: result.freedMB, freedBytes: result.freedBytes)) + records.append(OperationRecord(id: UUID(), itemPath: result.label, status: "success", bytesFreed: result.freedBytes)) } // Check for skipped categories from logs @@ -176,7 +180,8 @@ public final class CleanupCoordinator: @unchecked Sendable { let trashLabel = "trash_user_label".localized self.totalFreedMB += deletedMB - self.cleanedItems.append(CleanupResultItem(label: trashLabel, freedMB: deletedMB)) + self.totalFreedBytes += deletedBytes + self.cleanedItems.append(CleanupResultItem(label: trashLabel, freedMB: deletedMB, freedBytes: deletedBytes)) records.append(OperationRecord(id: UUID(), itemPath: "~/.Trash", status: "success", bytesFreed: deletedBytes)) } @@ -189,7 +194,7 @@ public final class CleanupCoordinator: @unchecked Sendable { } self.notifier.sendCleanupComplete( - totalFreedMB: self.totalFreedMB, + totalFreedBytes: self.totalFreedBytes, showNotifications: self.settings.showNotifications ) diff --git a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift index a973694..5876beb 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift @@ -52,10 +52,12 @@ public enum CleanupEngineEvent: Sendable { public struct CleanupEngineResult: Sendable { public let label: String public let freedMB: Int + public let freedBytes: Int64 - public init(label: String, freedMB: Int) { + public init(label: String, freedMB: Int, freedBytes: Int64? = nil) { self.label = label self.freedMB = freedMB + self.freedBytes = freedBytes ?? Int64(freedMB) * 1024 * 1024 } } diff --git a/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift b/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift index 00d4c4b..230d290 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift @@ -31,13 +31,17 @@ public final class CleanupItemManager { var total = 0 for item in items { if item.isSelected { - if item.children.isEmpty { - total += item.sizeMB - } else { - // parent.sizeMB is the authoritative "what will be freed" - // (set by the .result event in appendPreviewItem). - total += item.sizeMB - } + total += item.sizeMB + } + } + return total + } + + public var selectedSizeBytes: Int64 { + var total: Int64 = 0 + for item in items { + if item.isSelected { + total += item.sizeBytes } } return total @@ -52,6 +56,7 @@ public final class CleanupItemManager { let newItem = CleanupPreviewItem( label: Self.shortLabel(from: path), sizeMB: sizeMB, + sizeBytes: sizeBytes, risk: risk, isSelected: true, isDeletable: true, @@ -66,11 +71,13 @@ public final class CleanupItemManager { if !items[idx].children.contains(where: { $0.path == path }) { items[idx].children.append(newItem) items[idx].sizeMB = items[idx].children.reduce(0) { $0 + $1.sizeMB } + items[idx].sizeBytes = items[idx].children.reduce(0) { $0 + $1.sizeBytes } } } else { let parent = CleanupPreviewItem( label: targetParent, sizeMB: sizeMB, + sizeBytes: sizeBytes, risk: Self.determineRisk(for: targetParent), isSelected: true, isDeletable: true, @@ -84,9 +91,11 @@ public final class CleanupItemManager { public func appendPreviewItem(_ label: String, size: Int, deletable: Bool, parentName: String?, description: String?) { let risk = deletable ? Self.determineRisk(for: label) : .protected + let sizeBytes = Int64(size) * 1024 * 1024 let newItem = CleanupPreviewItem( label: label, sizeMB: size, + sizeBytes: sizeBytes, risk: risk, isSelected: deletable, isDeletable: deletable, @@ -96,28 +105,20 @@ public final class CleanupItemManager { if let parentName = parentName, !parentName.isEmpty { if let idx = items.firstIndex(where: { $0.label == parentName }) { if !items[idx].children.contains(where: { $0.label == label }) { - // When a parent already has children (file items from - // emitFileItem), appending the .result as another child - // double-counts: the UI sums all children for the parent - // sizeMB. Fix: don't add the result as a child — instead, - // use its freedMB as the authoritative "what will be freed" - // value for the parent. This covers edge cases where the - // result includes extra data not in individual file items - // (e.g. orphaned remnants). if !items[idx].children.isEmpty { - // Use the larger of existing size or new result size - // to avoid undercounting when result includes items - // not yet in fileItems (like orphaned remnants) items[idx].sizeMB = max(items[idx].sizeMB, size) + items[idx].sizeBytes = max(items[idx].sizeBytes, sizeBytes) } else { items[idx].children.append(newItem) items[idx].sizeMB = items[idx].children.reduce(0) { $0 + $1.sizeMB } + items[idx].sizeBytes = items[idx].children.reduce(0) { $0 + $1.sizeBytes } } } } else { let parent = CleanupPreviewItem( label: parentName, sizeMB: size, + sizeBytes: sizeBytes, risk: .safe, isSelected: true, isDeletable: true, @@ -129,6 +130,7 @@ public final class CleanupItemManager { if let idx = items.firstIndex(where: { $0.label == label }) { if items[idx].children.isEmpty { items[idx].sizeMB = max(items[idx].sizeMB, size) + items[idx].sizeBytes = max(items[idx].sizeBytes, sizeBytes) } } else { items.append(newItem) diff --git a/MacOSCleaner/Domains/Cleanup/CleanupModels.swift b/MacOSCleaner/Domains/Cleanup/CleanupModels.swift index 96cf549..aefefa5 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupModels.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupModels.swift @@ -18,6 +18,7 @@ public struct CleanupPreviewItem: Identifiable, Sendable, Equatable { public let id: UUID public let label: String public var sizeMB: Int + public var sizeBytes: Int64 public let risk: OperationRisk public var isSelected: Bool public let isDeletable: Bool @@ -31,6 +32,7 @@ public struct CleanupPreviewItem: Identifiable, Sendable, Equatable { id: UUID = UUID(), label: String, sizeMB: Int, + sizeBytes: Int64 = 0, risk: OperationRisk, isSelected: Bool = true, isDeletable: Bool, @@ -43,6 +45,7 @@ public struct CleanupPreviewItem: Identifiable, Sendable, Equatable { self.id = id self.label = label self.sizeMB = sizeMB + self.sizeBytes = sizeBytes == 0 ? Int64(sizeMB) * 1024 * 1024 : sizeBytes self.risk = risk self.isSelected = isSelected self.isDeletable = isDeletable @@ -56,6 +59,7 @@ public struct CleanupPreviewItem: Identifiable, Sendable, Equatable { public static func == (lhs: CleanupPreviewItem, rhs: CleanupPreviewItem) -> Bool { lhs.id == rhs.id && lhs.sizeMB == rhs.sizeMB && + lhs.sizeBytes == rhs.sizeBytes && lhs.isSelected == rhs.isSelected && lhs.children == rhs.children && lhs.description == rhs.description && @@ -67,6 +71,13 @@ public struct CleanupResultItem: Identifiable, Sendable { public let id: UUID = UUID() public let label: String public let freedMB: Int + public let freedBytes: Int64 + + public init(label: String, freedMB: Int, freedBytes: Int64? = nil) { + self.label = label + self.freedMB = freedMB + self.freedBytes = freedBytes ?? Int64(freedMB) * 1024 * 1024 + } } public struct SkippedCleanupItem: Identifiable, Sendable { diff --git a/MacOSCleaner/Domains/Cleanup/CleanupNotifier.swift b/MacOSCleaner/Domains/Cleanup/CleanupNotifier.swift index 8718367..5e0903b 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupNotifier.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupNotifier.swift @@ -4,18 +4,18 @@ public struct CleanupNotifier: Sendable { public init() {} @MainActor - public func sendScanComplete(selectedSizeMB: Int, showNotifications: Bool) { + public func sendScanComplete(selectedSizeBytes: Int64, showNotifications: Bool) { guard showNotifications else { return } let title = "cleanup_scan_complete_title".localized - let body = "cleanup_scan_complete_body".localizedWithArgs(selectedSizeMB) + let body = "cleanup_scan_complete_body".localizedWithArgs(selectedSizeBytes.formattedByteCount()) NotificationManager.shared.sendNotification(title: title, body: body) } @MainActor - public func sendCleanupComplete(totalFreedMB: Int, showNotifications: Bool) { + public func sendCleanupComplete(totalFreedBytes: Int64, showNotifications: Bool) { guard showNotifications else { return } let title = "cleanup_complete_title".localized - let body = "cleanup_complete_body".localizedWithArgs(totalFreedMB) + let body = "cleanup_complete_body".localizedWithArgs(totalFreedBytes.formattedByteCount()) NotificationManager.shared.sendNotification(title: title, body: body) } } diff --git a/MacOSCleaner/Features/Cleanup/CleanupView.swift b/MacOSCleaner/Features/Cleanup/CleanupView.swift index 0266ce2..ffc6943 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupView.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupView.swift @@ -264,7 +264,7 @@ public struct CleanupView: View { Text("cleanup_complete".localized) .font(.system(.title2, design: .rounded, weight: .bold)) - Text(String(format: "cleanup_complete_sub".localized, viewModel.totalFreedMB)) + Text(String(format: "cleanup_complete_sub".localized, viewModel.totalFreedBytes.formattedByteCount())) .font(.body) .foregroundColor(.secondary) } @@ -290,7 +290,7 @@ public struct CleanupView: View { Spacer() - Text(String(format: "cleanup_mb_format".localized, item.freedMB)) + Text(item.freedBytes.formattedByteCount()) .font(.system(.subheadline, design: .monospaced)) .foregroundColor(.green) } @@ -428,7 +428,7 @@ public struct CleanupView: View { } .buttonStyle(.borderedProminent) .controlSize(.large) - .disabled(viewModel.selectedSizeMB == 0) + .disabled(viewModel.selectedSizeBytes == 0) } .padding() .background(Color(NSColor.controlBackgroundColor).opacity(0.3)) @@ -530,7 +530,7 @@ public struct CleanupView: View { Spacer() - Text(String(format: "cleanup_mb_format".localized, category.sizeMB)) + Text(category.sizeBytes.formattedByteCount()) .font(.system(.body, design: .monospaced)) .foregroundColor(.secondary) } @@ -606,7 +606,7 @@ public struct CleanupView: View { Spacer() - Text(String(format: "cleanup_mb_format".localized, item.sizeMB)) + Text(item.sizeBytes.formattedByteCount()) .font(.system(.caption, design: .monospaced)) .foregroundColor(.secondary) } @@ -729,7 +729,7 @@ public struct CleanupView: View { private var footer: some View { HStack(spacing: 12) { if viewModel.state == .preview { - Text(String(format: "cleanup_selected".localized, viewModel.selectedSizeMB)) + Text(String(format: "cleanup_selected".localized, viewModel.selectedSizeBytes.formattedByteCount())) .fontWeight(.semibold) .foregroundColor(.accentColor) } @@ -784,7 +784,7 @@ public struct CleanupView: View { } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) - .disabled(viewModel.selectedSizeMB == 0) + .disabled(viewModel.selectedSizeBytes == 0) } } .padding() diff --git a/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift b/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift index 056e8c0..2823646 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift @@ -15,6 +15,7 @@ public final class CleanupViewModel { public var stepTitle: String { coordinator.stepTitle } public var items: [CleanupPreviewItem] { itemManager.items } public var totalFreedMB: Int { coordinator.totalFreedMB } + public var totalFreedBytes: Int64 { coordinator.totalFreedBytes } public var cleanedItems: [CleanupResultItem] { coordinator.cleanedItems } public var skippedItems: [SkippedCleanupItem] { coordinator.skippedItems } public var lastError: String? { coordinator.lastError } @@ -24,6 +25,7 @@ public final class CleanupViewModel { set { itemManager.selectedItemId = newValue } } public var selectedSizeMB: Int { itemManager.selectedSizeMB } + public var selectedSizeBytes: Int64 { itemManager.selectedSizeBytes } public var selectedItem: CleanupPreviewItem? { itemManager.selectedItem } public var expandedCategoryIds: Set { itemManager.expandedCategoryIds } diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift new file mode 100644 index 0000000..68be85f --- /dev/null +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift @@ -0,0 +1,309 @@ +import SwiftUI + +// Compatibility helpers for macOS 27 Liquid Glass if SDK doesn't have them yet +#if !hasFeature(LiquidGlass) +public struct GlassEffectContainer: View { + let spacing: CGFloat? + let content: () -> Content + + public init(spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + public var body: some View { + VStack(spacing: spacing) { + content() + } + } +} + +public extension View { + func glassEffect() -> some View { + self.background(VisualEffectView(material: .hudWindow, blendingMode: .withinWindow).clipShape(RoundedRectangle(cornerRadius: 12))) + } + + func glassEffect(_ glass: String = "", in shape: some Shape = RoundedRectangle(cornerRadius: 12)) -> some View { + self.background(VisualEffectView(material: .hudWindow, blendingMode: .withinWindow).clipShape(shape)) + } + + func glassEffectID(_ id: AnyHashable, in namespace: Namespace.ID) -> some View { + self + } + + func glassEffectTransition(_ transition: Any) -> some View { + self + } +} +#endif + +public struct DiskAnalyzerView: View { + @State private var viewModel = DiskAnalyzerViewModel() + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + public init() {} + + public var body: some View { + GlassEffectContainer { + VStack(spacing: 16) { + headerView + + categoryFilterView + + if viewModel.isScanning { + scanningView + } else if viewModel.items.isEmpty { + emptyView + } else { + itemsListView + } + } + .padding() + } + .onAppear { + if viewModel.rootURL == nil { + viewModel.startScan(for: FileManager.default.homeDirectoryForCurrentUser) + } + } + } + + private var headerView: some View { + HStack(spacing: 12) { + if !viewModel.pathStack.isEmpty { + Button(action: { + withAnimation { + viewModel.navigateBack() + } + }) { + Image(systemName: "chevron.left") + .font(.headline) + .padding(8) + } + .buttonStyle(.plain) + .background(Circle().fill(Color.secondary.opacity(0.2))) + } + + VStack(alignment: .leading, spacing: 4) { + Text("disk_analyzer_title".localized) + .font(.title2) + .fontWeight(.bold) + + if let currentURL = viewModel.currentURL { + Text(currentURL.path) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.head) + } + } + + Spacer() + + Button("disk_analyzer_scan".localized) { + viewModel.selectFolderAndScan() + } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 4) + } + + private var categoryFilterView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(FileCategory.allCases, id: \.self) { category in + Button(action: { + withAnimation { + viewModel.selectedCategory = category + } + }) { + Text(category.localizedName) + .font(.callout) + .padding(.horizontal, 8) + .padding(.vertical, 3) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(viewModel.selectedCategory == category ? .accentColor : nil) + } + } + .padding(.horizontal, 4) + } + } + + private var scanningView: some View { + VStack(spacing: 20) { + Spacer() + ProgressView() + .scaleEffect(1.2) + + Text("disk_analyzer_scanning".localized) + .font(.headline) + + if !viewModel.currentScanningName.isEmpty { + Text(viewModel.currentScanningName) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 40) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyView: some View { + VStack(spacing: 12) { + Spacer() + Image(systemName: "folder.badge.questionmark") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text("disk_analyzer_empty".localized) + .font(.headline) + .foregroundColor(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var itemsListView: some View { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(viewModel.filteredItems) { item in + DiskItemRow(item: item, onNavigate: { + withAnimation { + viewModel.navigateTo(item: item) + } + }, onShowInFinder: { + viewModel.showInFinder(item: item) + }, onDelete: { + viewModel.moveToTrash(item: item) + }) + } + } + .padding(.horizontal, 4) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.2)) + ) + } +} + +struct DiskItemRow: View { + let item: DiskItem + let onNavigate: () -> Void + let onShowInFinder: () -> Void + let onDelete: () -> Void + + @State private var isHovered = false + @State private var showingDeleteConfirmation = false + + var body: some View { + HStack(spacing: 12) { + 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) + + if item.isDirectory { + Text("folder".localized) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + Spacer() + + Text(item.size.formattedByteCount()) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + .padding(.trailing, 8) + + if isHovered { + HStack(spacing: 4) { + Button(action: onShowInFinder) { + Image(systemName: "magnifyingglass") + } + .buttonStyle(.plain) + .help("disk_analyzer_show_in_finder".localized) + + Button(action: { + showingDeleteConfirmation = true + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.plain) + .help("disk_analyzer_move_to_trash".localized) + } + .transition(.opacity) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.secondary.opacity(0.1) : Color.clear) + ) + .onHover { hover in + withAnimation(.easeInOut(duration: 0.1)) { + isHovered = hover + } + } + .onTapGesture { + if item.isDirectory { + onNavigate() + } + } + .confirmationDialog( + "disk_analyzer_delete_confirm".localized, + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("delete_action".localized, role: .destructive) { + onDelete() + } + Button("cancel".localized, role: .cancel) {} + } + } + + private var iconName: String { + if item.isDirectory { + return "folder.fill" + } + switch item.fileType { + case .video: return "play.rectangle.fill" + case .audio: return "music.note" + case .apps: return "app.badge.fill" + case .docs: return "doc.text.fill" + case .archives: return "doc.zip.fill" + case .dev: return "curlybraces" + default: return "doc.fill" + } + } + + private var iconColor: Color { + if item.isDirectory { + return .blue + } + switch item.fileType { + case .video: return .purple + case .audio: return .pink + case .apps: return .orange + case .docs: return .green + case .archives: return .yellow + case .dev: return .teal + default: return .secondary + } + } +} diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift new file mode 100644 index 0000000..7c67aa4 --- /dev/null +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift @@ -0,0 +1,151 @@ +import Foundation +import SwiftUI +import Observation +import OSLog + +@MainActor +@Observable +public final class DiskAnalyzerViewModel { + private let logger = Logger(subsystem: "input.MacOSCleaner", category: "DiskAnalyzerViewModel") + private let scanner = DiskScanner() + private let trashManager = TrashManager() + + public var isScanning = false + public var currentScanningName = "" + public var rootURL: URL? + public var currentURL: URL? + public var items: [DiskItem] = [] + public var selectedCategory: FileCategory = .all + + private var scanTask: Task? + public var pathStack: [URL] = [] + + // Cache to prevent scanning the same directory twice + private var scanCache: [URL: [DiskItem]] = [:] + + public init() {} + + public var filteredItems: [DiskItem] { + guard selectedCategory != .all else { return items } + return items.filter { item in + if item.isDirectory { + // Directories are kept to allow navigation down the tree + return true + } else { + return item.fileType == selectedCategory + } + } + } + + public func selectFolderAndScan() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser + + if panel.runModal() == .OK, let url = panel.url { + startScan(for: url) + } + } + + public func startScan(for url: URL) { + scanTask?.cancel() + + rootURL = url + currentURL = url + pathStack = [] + isScanning = true + currentScanningName = "" + items = [] + scanCache.removeAll() + + scanTask = Task { + do { + let scannedItems = 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.scanCache[url] = scannedItems + self.isScanning = false + } catch { + self.logger.error("Scan failed: \(error.localizedDescription)") + self.isScanning = false + } + } + } + + public func navigateTo(item: DiskItem) { + guard item.isDirectory, let currentURL = currentURL else { return } + + pathStack.append(currentURL) + self.currentURL = item.url + + if let cached = scanCache[item.url] { + self.items = cached + } else if let children = item.children { + self.items = children + self.scanCache[item.url] = children + } else { + // Scan subdirectory + isScanning = true + items = [] + + scanTask?.cancel() + scanTask = Task { + do { + let scannedItems = try await scanner.scan(directoryURL: item.url) { [weak self] folderName in + guard let self else { return } + Task { @MainActor in + self.currentScanningName = folderName + } + } + self.items = scannedItems + self.scanCache[item.url] = scannedItems + self.isScanning = false + } catch { + self.logger.error("Subdirectory scan failed: \(error.localizedDescription)") + self.isScanning = false + } + } + } + } + + public func navigateBack() { + guard !pathStack.isEmpty else { return } + let prevURL = pathStack.removeLast() + self.currentURL = prevURL + + if let cached = scanCache[prevURL] { + self.items = cached + } else { + startScan(for: prevURL) + } + } + + 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 } + + // Update cache + if let currentURL = currentURL { + scanCache[currentURL] = self.items + } + } catch { + self.logger.error("Failed to move item to trash: \(error.localizedDescription)") + } + } + } + + public func showInFinder(item: DiskItem) { + NSWorkspace.shared.selectFile(item.url.path, inFileViewerRootedAtPath: "") + } +} diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift new file mode 100644 index 0000000..0e310f6 --- /dev/null +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift @@ -0,0 +1,65 @@ +import Foundation + +public enum FileCategory: String, CaseIterable, Sendable { + case all + case video + case audio + case apps + case docs + case archives + case dev + case other + + public var localizedName: String { + switch self { + case .all: return "disk_analyzer_category_all".localized + case .video: return "disk_analyzer_category_video".localized + case .audio: return "disk_analyzer_category_audio".localized + case .apps: return "disk_analyzer_category_apps".localized + case .docs: return "disk_analyzer_category_docs".localized + case .archives: return "disk_analyzer_category_archives".localized + case .dev: return "disk_analyzer_category_dev".localized + case .other: return "disk_analyzer_category_other".localized + } + } + + public static func from(url: URL) -> FileCategory { + let ext = url.pathExtension.lowercased() + switch ext { + case "mp4", "mkv", "mov", "avi", "m4v", "webm", "flv": + return .video + case "mp3", "wav", "flac", "m4a", "aac", "ogg", "wma": + return .audio + case "app", "dmg", "pkg", "ipa": + return .apps + case "pdf", "docx", "doc", "txt", "xlsx", "xls", "pptx", "ppt", "pages", "numbers", "key", "rtf", "odt": + return .docs + case "zip", "tar", "gz", "rar", "7z", "bz2", "xz": + return .archives + case "swift", "py", "java", "c", "cpp", "h", "js", "ts", "json", "yaml", "yml", "sh", "html", "css", "go", "rs", "kt": + return .dev + default: + return .other + } + } +} + +public struct DiskItem: Identifiable, Hashable, Sendable { + public let id: UUID + public let url: URL + public let name: String + public let isDirectory: Bool + public var size: Int64 + public var children: [DiskItem]? + public let fileType: FileCategory + + public init(id: UUID = UUID(), url: URL, name: String, isDirectory: Bool, size: Int64, children: [DiskItem]? = nil, fileType: FileCategory) { + self.id = id + self.url = url + self.name = name + self.isDirectory = isDirectory + self.size = size + self.children = children + self.fileType = fileType + } +} diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift new file mode 100644 index 0000000..566c8e8 --- /dev/null +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift @@ -0,0 +1,214 @@ +import Foundation +import OSLog + +public actor DiskScanner { + private let logger = Logger(subsystem: "input.MacOSCleaner", category: "DiskScanner") + + public init() {} + + /// Scans a directory and returns its immediate children with calculated sizes. + public func scan( + directoryURL: URL, + onProgress: @Sendable @escaping (String) -> Void + ) async throws -> [DiskItem] { + let activity = ProcessInfo.processInfo.beginActivity( + options: .userInitiated, + reason: "Scanning disk space at \(directoryURL.lastPathComponent)" + ) + defer { + ProcessInfo.processInfo.endActivity(activity) + } + + let fm = FileManager.default + var isDir: ObjCBool = false + guard fm.fileExists(atPath: directoryURL.path, isDirectory: &isDir), isDir.boolValue else { + return [] + } + + // 1. Get immediate children + let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .totalFileAllocatedSizeKey] + let childrenURLs = try fm.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles] + ) + + var results: [DiskItem] = [] + var dirsToScan: [URL] = [] + + for url in childrenURLs { + if Task.isCancelled { break } + + // Check exclusion + if FileManager.shouldExclude(url: url) { + continue + } + + guard let resourceValues = try? url.resourceValues(forKeys: Set(keys)) else { + continue + } + + let isDirectory = resourceValues.isDirectory ?? false + if isDirectory { + // If it's a bundle like .app, we treat it as a file/app category + if url.pathExtension.lowercased() == "app" { + let size = await calculateDirectorySize(url: url) + results.append(DiskItem( + url: url, + name: url.lastPathComponent, + isDirectory: false, + size: size, + fileType: .apps + )) + } else { + dirsToScan.append(url) + } + } else { + let size = Int64(resourceValues.fileSize ?? 0) + results.append(DiskItem( + url: url, + name: url.lastPathComponent, + isDirectory: false, + size: size, + fileType: FileCategory.from(url: url) + )) + } + } + + if Task.isCancelled { return results } + + // 2. Scan directories in parallel using TaskGroup + let scannedDirs = try await withThrowingTaskGroup(of: DiskItem?.self) { group in + for dirURL in dirsToScan { + group.addTask { + if Task.isCancelled { return nil } + let name = dirURL.lastPathComponent + onProgress(name) + + let size = await self.calculateDirectorySize(url: dirURL) + + // Also recursively scan 1 level deeper to show children inside if expanded + let children = await self.scanSubdirectoryOneLevel(url: dirURL) + + return DiskItem( + url: dirURL, + name: name, + isDirectory: true, + size: size, + children: children, + fileType: .other + ) + } + } + + var list: [DiskItem] = [] + while let item = try await group.next() { + if let item { + list.append(item) + } + } + return list + } + + results.append(contentsOf: scannedDirs) + + // Sort by size descending + return results.sorted { $0.size > $1.size } + } + + private func scanSubdirectoryOneLevel(url: URL) async -> [DiskItem] { + let fm = FileManager.default + let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey] + guard let childrenURLs = try? fm.contentsOfDirectory( + at: url, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var list: [DiskItem] = [] + for child in childrenURLs { + if Task.isCancelled { break } + if FileManager.shouldExclude(url: child) { continue } + + guard let values = try? child.resourceValues(forKeys: Set(keys)) else { continue } + let isDir = values.isDirectory ?? false + + if isDir { + if child.pathExtension.lowercased() == "app" { + let size = await calculateDirectorySize(url: child) + list.append(DiskItem( + url: child, + name: child.lastPathComponent, + isDirectory: false, + size: size, + fileType: .apps + )) + } else { + let size = await calculateDirectorySize(url: child) + list.append(DiskItem( + url: child, + name: child.lastPathComponent, + isDirectory: true, + size: size, + fileType: .other + )) + } + } else { + let size = Int64(values.fileSize ?? 0) + list.append(DiskItem( + url: child, + name: child.lastPathComponent, + isDirectory: false, + size: size, + fileType: FileCategory.from(url: child) + )) + } + } + + return list.sorted { $0.size > $1.size } + } + + private func calculateDirectorySize(url: URL) async -> Int64 { + if Task.isCancelled { return 0 } + let fm = FileManager.default + let keys: [URLResourceKey] = [.fileSizeKey, .isDirectoryKey] + + guard let enumerator = fm.enumerator( + at: url, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + var totalSize: Int64 = 0 + var count = 0 + + while let fileURL = enumerator.nextObject() as? URL { + if Task.isCancelled { break } + + if FileManager.shouldExclude(url: fileURL) { + if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { + enumerator.skipDescendants() + } + continue + } + + 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 % 500 == 0 { + await Task.yield() + } + } + + return totalSize + } +} diff --git a/MacOSCleaner/Features/Uninstaller/AppIdentity.swift b/MacOSCleaner/Features/Uninstaller/AppIdentity.swift index ef5261d..8d89a9b 100644 --- a/MacOSCleaner/Features/Uninstaller/AppIdentity.swift +++ b/MacOSCleaner/Features/Uninstaller/AppIdentity.swift @@ -180,7 +180,7 @@ private func deriveVendorNames(bundleID: String, appName: String, authority: Str let staticAliases: [String: Set] = [ "jetbrains": ["JetBrains", "IntelliJ", "PyCharm", "DataGrip", "GoLand", "WebStorm", "RubyMine", "CLion", "Rider", "AppCode"], "adobe": ["Adobe"], - "microsoft": ["Microsoft", "Office", "Teams", "VS", "Code"], + "microsoft": ["Microsoft", "Office", "Teams", "VSCode"], "google": ["Google"], "docker": ["Docker"], "oracle": ["Oracle"], diff --git a/MacOSCleaner/Infrastructure/String+Localization.swift b/MacOSCleaner/Infrastructure/String+Localization.swift index 32bf181..9011824 100644 --- a/MacOSCleaner/Infrastructure/String+Localization.swift +++ b/MacOSCleaner/Infrastructure/String+Localization.swift @@ -12,6 +12,12 @@ public extension String { public extension ByteCountFormatter { static func localizedString(fromByteCount count: Int64, countStyle: ByteCountFormatter.CountStyle) -> String { + if abs(count) < 1000 { + let isRussian = LanguageManager.shared.currentLocale.language.languageCode?.identifier == "ru" || + LanguageManager.shared.currentLocale.language.languageCode?.identifier == "uk" + let unit = isRussian ? "Б" : "B" + return "\(count) \(unit)" + } let style: ByteCountFormatStyle.Style = (countStyle == .memory) ? .memory : .file return count.formatted(.byteCount(style: style).locale(LanguageManager.shared.currentLocale)) } @@ -25,6 +31,12 @@ public extension ByteCountFormatter { public extension Int64 { func formattedByteCount(style: ByteCountFormatStyle.Style = .file) -> String { + if abs(self) < 1000 { + let isRussian = LanguageManager.shared.currentLocale.language.languageCode?.identifier == "ru" || + LanguageManager.shared.currentLocale.language.languageCode?.identifier == "uk" + let unit = isRussian ? "Б" : "B" + return "\(self) \(unit)" + } let locale = LanguageManager.shared.currentLocale return self.formatted(.byteCount(style: style).locale(locale)) } diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index 3ee7457..1665af1 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -73,6 +73,7 @@ 622E4977F3A1981F681C3D8B /* XcodeRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB05B780E52D2EA5A76D570 /* XcodeRule.swift */; }; 624F501955DDCE47F965A24C /* FileScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966FFAFDB46A9A4BD17A38C0 /* FileScannerTests.swift */; }; 63A1AF79D3508862AED5B581 /* DefaultRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68475F1254EF60843A610CAC /* DefaultRule.swift */; }; + 6459FDDA62B9985000DEFEF2 /* DiskAnalyzerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43BADDC2D912E6DCD4FB078 /* DiskAnalyzerViewModel.swift */; }; 66773DCDEB6DDD828C28ECB3 /* PlistAnalyzerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D694D1ECD87105CC67BA55C1 /* PlistAnalyzerTests.swift */; }; 6815BD9B08943A3B4F2F4FF6 /* BaselineFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C92C74A3F932664D5001998 /* BaselineFixture.swift */; }; 6A91F8F5E97E9DC385D90906 /* SteamRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBD05CD906BFA50A593DAA2 /* SteamRule.swift */; }; @@ -120,10 +121,12 @@ AEA91CD04EACACE4AB9C75B6 /* AppIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BC06C9BBE41099A9D54344 /* AppIdentity.swift */; }; AEBD6F435B4BC89EB6A27021 /* ProcessSafetyPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AFDCF6F0B36A4DCBEB9BC9 /* ProcessSafetyPolicy.swift */; }; AEEE3F61B192C14B2BA9741D /* ScanResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DABEFC1D64E9E06931BBF8 /* ScanResult.swift */; }; + B001282F4B0F905C44783B6B /* DiskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 829209F31C2C64485ADD1F74 /* DiskItem.swift */; }; B17DA1284BD059BAA16189CF /* TransactionJournal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 753F374AA91758652C409A39 /* TransactionJournal.swift */; }; B262FF81F64BA96FB492B9FD /* Postman.json in Resources */ = {isa = PBXBuildFile; fileRef = 2013004E1BB0E55214A2CC26 /* Postman.json */; }; B31A42615B4637F2D8C2F303 /* BackgroundItemsReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FA0CF16A34242F0F8DAD420 /* BackgroundItemsReaderTests.swift */; }; B3519A02A233AC4C5D343D1A /* EvidenceGraphTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D4AA64672B844EB923DFF3 /* EvidenceGraphTests.swift */; }; + B64E3358A5DE4CD6D4D28394 /* DiskAnalyzerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D0235DC89C6C2BDB34309D1 /* DiskAnalyzerView.swift */; }; B74924CC7A66323AB8CD2E28 /* CleanupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC107E0D03F39A8293BD8FE4 /* CleanupCoordinator.swift */; }; BA45213B9E4C9FC0430EBC68 /* DaVinciResolveRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFF580DF2A229E8922F2A19 /* DaVinciResolveRule.swift */; }; BB644D53B3367954A53BA10F /* NavigationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B421C9DC6196E3977508E1CD /* NavigationItem.swift */; }; @@ -151,6 +154,7 @@ D5EB7139957B403064AFD099 /* RetryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA2BC762F643159A5EE7AA7 /* RetryPolicyTests.swift */; }; D664F8B31004BF2B0D3B1560 /* NordVPN.json in Resources */ = {isa = PBXBuildFile; fileRef = DD4DB6F7DA427DA269274700 /* NordVPN.json */; }; D768D4102DD98AD91B8F4EEA /* CandidateCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF1797889E54A02B080F3BE6 /* CandidateCollectorTests.swift */; }; + DA751D9D2C2235BC30A8039B /* DiskScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8E546AF7573B471B314407 /* DiskScannerTests.swift */; }; DDF1A3BD13DF7AA1C39EA144 /* LaunchServiceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5A3FFB9EF8E6D7D62FD68D /* LaunchServiceManager.swift */; }; DE4F65F07814A5139023D54F /* SystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180E53446953349EB5FB2E04 /* SystemInfo.swift */; }; DFBF63D178857930319DBB9B /* SafetyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36158099B9A85FFD8B5536BA /* SafetyManagerTests.swift */; }; @@ -177,6 +181,7 @@ F66F8B5C81F0594E86965308 /* ConfidenceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB088C6253EADF3B4E7AE7 /* ConfidenceEngine.swift */; }; F6FA71CE2DE763D8605DE9B9 /* CommandCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B27F13D467CB9E08F5B350 /* CommandCache.swift */; }; F7247FDBD033B431021D0DE7 /* Unity.json in Resources */ = {isa = PBXBuildFile; fileRef = 8329098CAFECCA528C771F2F /* Unity.json */; }; + F94A21F184BE968E2C98AC27 /* DiskScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A4B8FC5247795B48722C8E /* DiskScanner.swift */; }; F9CC8AC236AEC39E5C6D9FD7 /* CommunicationRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EDBEFA89D961FFCCC6803C5 /* CommunicationRule.swift */; }; FCA4F949ED18C01812E53B56 /* VirtualizationRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8D95CDCD82E40F60BA1E6A /* VirtualizationRule.swift */; }; FF78CCB002655FB528308A84 /* RancherDesktopRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9533ED1F79B059880118CDE /* RancherDesktopRule.swift */; }; @@ -201,6 +206,7 @@ 08A2575EBBEE5EC6B202760B /* AlfredRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlfredRule.swift; sourceTree = ""; }; 0BB6DE85A7D910E1F0424CEF /* TransactionJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionJournalTests.swift; sourceTree = ""; }; 0C92C74A3F932664D5001998 /* BaselineFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaselineFixture.swift; sourceTree = ""; }; + 0D0235DC89C6C2BDB34309D1 /* DiskAnalyzerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskAnalyzerView.swift; sourceTree = ""; }; 0E50B7CFC3A4DFDD52CC0EF1 /* MacOSCleanerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacOSCleanerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0F6BA430DBA22418A7A0D0D8 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; 1208E5D13CB1494773BCD1C7 /* DashboardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModelTests.swift; sourceTree = ""; }; @@ -250,6 +256,7 @@ 4C881B157A7F5ACBF84D7E64 /* LanguageManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageManagerTests.swift; sourceTree = ""; }; 4D39CB998D05F06C7122FCA3 /* CleanupStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupStateMachine.swift; sourceTree = ""; }; 4EA68BEDE6F540E3477849FC /* SafetyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyManager.swift; sourceTree = ""; }; + 4F8E546AF7573B471B314407 /* DiskScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskScannerTests.swift; sourceTree = ""; }; 50C1C6C0C2F943213529B9BC /* AppDiscoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDiscoveryTests.swift; sourceTree = ""; }; 51B066CCFCE8479C82161FDF /* AppIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentityTests.swift; sourceTree = ""; }; 51EC79CD41147A35440026B2 /* Homebrew.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Homebrew.json; sourceTree = ""; }; @@ -284,12 +291,14 @@ 7B1DD590DCE0D34A3129D242 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = ""; }; 7BA3A02A464DC643AB220B58 /* JetBrainsRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetBrainsRule.swift; sourceTree = ""; }; 817EB9AE4524A850962213CB /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 829209F31C2C64485ADD1F74 /* DiskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskItem.swift; sourceTree = ""; }; 8329098CAFECCA528C771F2F /* Unity.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Unity.json; sourceTree = ""; }; 8526E6E523335A441DE17858 /* AppSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsTests.swift; sourceTree = ""; }; 906E6511D36A808F61A68A15 /* ArtifactClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactClassifierTests.swift; sourceTree = ""; }; 90B1C1E9B0FFC93988C69561 /* ApplicationRuleRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRuleRegistry.swift; sourceTree = ""; }; 90BF90F2E933654B06A18BA4 /* MacOSCleanerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacOSCleanerApp.swift; sourceTree = ""; }; 91A11C06D6D12353C59E09EF /* FileScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileScanner.swift; sourceTree = ""; }; + 93A4B8FC5247795B48722C8E /* DiskScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskScanner.swift; sourceTree = ""; }; 93C5FD8CA29FB9E59A400223 /* TrashManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashManager.swift; sourceTree = ""; }; 966FFAFDB46A9A4BD17A38C0 /* FileScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileScannerTests.swift; sourceTree = ""; }; 9B7117987756095224817906 /* CleanupCategory+FixtureMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CleanupCategory+FixtureMapping.swift"; sourceTree = ""; }; @@ -310,6 +319,7 @@ B1DABEFC1D64E9E06931BBF8 /* ScanResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanResult.swift; sourceTree = ""; }; B29CC70A6B8DA27FD15BB199 /* StartupVendorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupVendorSettingsView.swift; sourceTree = ""; }; B421C9DC6196E3977508E1CD /* NavigationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationItem.swift; sourceTree = ""; }; + B43BADDC2D912E6DCD4FB078 /* DiskAnalyzerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskAnalyzerViewModel.swift; sourceTree = ""; }; B625D7C04F6EC419FA5F0764 /* LaunchctlCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchctlCache.swift; sourceTree = ""; }; B7F3794501798DF583436B09 /* CleanupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupEngine.swift; sourceTree = ""; }; B8008E88C0D03BB15C65998D /* UninstallerServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallerServiceTests.swift; sourceTree = ""; }; @@ -410,6 +420,17 @@ path = Models; sourceTree = ""; }; + 1B65F853368321C72C177963 /* DiskAnalyzer */ = { + isa = PBXGroup; + children = ( + 0D0235DC89C6C2BDB34309D1 /* DiskAnalyzerView.swift */, + B43BADDC2D912E6DCD4FB078 /* DiskAnalyzerViewModel.swift */, + 829209F31C2C64485ADD1F74 /* DiskItem.swift */, + 93A4B8FC5247795B48722C8E /* DiskScanner.swift */, + ); + path = DiskAnalyzer; + sourceTree = ""; + }; 366AFB5C6194CEB0E6465E95 /* Permissions */ = { isa = PBXGroup; children = ( @@ -446,6 +467,7 @@ DBA64165D2FBAC03ACE56DC6 /* ConfidenceEngineTests.swift */, 1208E5D13CB1494773BCD1C7 /* DashboardViewModelTests.swift */, 1B00A5A77A7B76F9E8B32699 /* DeveloperComponentsDetectorTests.swift */, + 4F8E546AF7573B471B314407 /* DiskScannerTests.swift */, 2854706813F7B138A3E1A17C /* EvidenceCategoryTests.swift */, DF2C09A741A9F06D78858A0C /* EvidenceExplanationTests.swift */, F3D4AA64672B844EB923DFF3 /* EvidenceGraphTests.swift */, @@ -492,6 +514,7 @@ 4A12525602569D1123C99869 /* About */, 022B8B6F621A212C80CE7BE4 /* Cleanup */, C8BB00A9C6F9B8C48B055441 /* Dashboard */, + 1B65F853368321C72C177963 /* DiskAnalyzer */, 366AFB5C6194CEB0E6465E95 /* Permissions */, 486FFC3EF3DDBDAFAA3397EB /* Processes */, 3763CF3EC0BEBFBF397B8E66 /* Settings */, @@ -873,6 +896,7 @@ 03FDBD7F79B43C582A6C0EC5 /* ConfidenceEngineTests.swift in Sources */, 977AD64777E29D8DE44620DD /* DashboardViewModelTests.swift in Sources */, E41739F33D7BBF38F05583A8 /* DeveloperComponentsDetectorTests.swift in Sources */, + DA751D9D2C2235BC30A8039B /* DiskScannerTests.swift in Sources */, 32D15D3B2B3404DE9662CEA5 /* EvidenceCategoryTests.swift in Sources */, 2EFB638B382F68A4A21CD7AB /* EvidenceExplanationTests.swift in Sources */, B3519A02A233AC4C5D343D1A /* EvidenceGraphTests.swift in Sources */, @@ -943,6 +967,10 @@ 63A1AF79D3508862AED5B581 /* DefaultRule.swift in Sources */, 2FBBF85D96918E09A80C5939 /* DeveloperComponentsDetector.swift in Sources */, 73B0F634314F9E8EE2D4C768 /* DirectorySizeCache.swift in Sources */, + B64E3358A5DE4CD6D4D28394 /* DiskAnalyzerView.swift in Sources */, + 6459FDDA62B9985000DEFEF2 /* DiskAnalyzerViewModel.swift in Sources */, + B001282F4B0F905C44783B6B /* DiskItem.swift in Sources */, + F94A21F184BE968E2C98AC27 /* DiskScanner.swift in Sources */, 0D71B75F7521BAECEAEF7119 /* DockerRule.swift in Sources */, C50AEB6F8125492F79A73313 /* ElectronRule.swift in Sources */, 0034381EEDFEBD9342599AB4 /* EmbeddedCleanupPaths.swift in Sources */, @@ -1060,7 +1088,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1155,7 +1183,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; diff --git a/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift b/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift new file mode 100644 index 0000000..5f9fbd4 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import MacOSCleaner + +final class DiskScannerTests: XCTestCase { + var scanner: DiskScanner! + var tempDirectory: URL! + + override func setUp() async throws { + scanner = DiskScanner() + let home = NSHomeDirectory() + tempDirectory = URL(fileURLWithPath: home).appendingPathComponent("Library/Application Support/MacOSCleanerTests_DiskScanner") + + if FileManager.default.fileExists(atPath: tempDirectory.path) { + try? FileManager.default.removeItem(at: tempDirectory) + } + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + if FileManager.default.fileExists(atPath: tempDirectory.path) { + try? FileManager.default.removeItem(at: tempDirectory) + } + } + + func testDirectoryScanningAndSizes() async throws { + // Create folder structure + 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 file (1,572,864 bytes) + let fileVideo = dir1.appendingPathComponent("video.mp4") + let videoData = Data(repeating: 0, count: 1024 * 1024 + 512 * 1024) + try videoData.write(to: fileVideo) + + // 300 KB text document (307,200 bytes) + let fileDoc = dir2.appendingPathComponent("document.docx") + let docData = Data(repeating: 0, count: 300 * 1024) + try docData.write(to: fileDoc) + + // Scan tempDirectory + let items = try await scanner.scan(directoryURL: tempDirectory) { _ in } + + XCTAssertEqual(items.count, 2) + + let moviesItem = items.first { $0.name == "Movies" } + XCTAssertNotNil(moviesItem) + XCTAssertTrue(moviesItem?.isDirectory ?? false) + XCTAssertEqual(moviesItem?.size, Int64(videoData.count)) + + let docsItem = items.first { $0.name == "Documents" } + XCTAssertNotNil(docsItem) + XCTAssertTrue(docsItem?.isDirectory ?? false) + XCTAssertEqual(docsItem?.size, Int64(docData.count)) + } + + 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), .dev) + } +} diff --git a/MacOSCleaner/Models/NavigationItem.swift b/MacOSCleaner/Models/NavigationItem.swift index e95e199..3c8e638 100644 --- a/MacOSCleaner/Models/NavigationItem.swift +++ b/MacOSCleaner/Models/NavigationItem.swift @@ -3,6 +3,7 @@ import Foundation enum NavigationItem: String, CaseIterable, Identifiable, Hashable { case dashboard = "Dashboard" case cleanup = "Cleanup" + case diskSpace = "Disk Space" case processes = "Processes" case startupServices = "Startup Services" case uninstaller = "Uninstaller" @@ -14,6 +15,7 @@ enum NavigationItem: String, CaseIterable, Identifiable, Hashable { switch self { case .dashboard: return "menu_dashboard".localized case .cleanup: return "menu_cleanup".localized + case .diskSpace: return "menu_disk_space".localized case .processes: return "menu_processes".localized case .startupServices: return "menu_startup_services".localized case .uninstaller: return "menu_uninstaller".localized @@ -25,6 +27,7 @@ enum NavigationItem: String, CaseIterable, Identifiable, Hashable { switch self { case .dashboard: return "gauge.medium" case .cleanup: return "sparkles" + case .diskSpace: return "folder.fill" case .processes: return "cpu" case .startupServices: return "bolt.horizontal" case .uninstaller: return "trash" diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index 5ac98e3..ab76dab 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -// Common +/* Common */ "welcome_msg" = "Welcome back!"; "app_title" = "Cleaner"; "sidebar_select_item" = "Select an item from the sidebar"; @@ -13,15 +13,38 @@ "size" = "Size"; "last_used" = "Last Used"; -// Sidebar / Navigation Menu +/* Sidebar / Navigation Menu */ "menu_dashboard" = "Dashboard"; "menu_cleanup" = "Cleanup"; "menu_startup_services" = "Startup Services"; "menu_startup_vendors" = "Startup Vendors"; "menu_uninstaller" = "Uninstaller"; "menu_settings" = "Settings"; - -// About View +"menu_disk_space" = "Disk Analyzer"; + +/* Disk Analyzer Screen */ +"disk_analyzer_title" = "Disk Space Analyzer"; +"disk_analyzer_scan" = "Scan Folder"; +"disk_analyzer_scanning" = "Scanning..."; +"disk_analyzer_back" = "Back"; +"disk_analyzer_delete_confirm" = "Move selected items to Trash?"; +"delete_action" = "Delete"; +"disk_analyzer_show_in_finder" = "Show in Finder"; +"disk_analyzer_move_to_trash" = "Move to Trash"; +"disk_analyzer_select_folder" = "Select Folder to Scan"; +"disk_analyzer_empty" = "Folder is empty or not scanned yet"; +"disk_analyzer_no_permissions" = "No access permissions for this folder"; +"disk_analyzer_category_all" = "All"; +"disk_analyzer_category_video" = "Video"; +"disk_analyzer_category_audio" = "Audio"; +"disk_analyzer_category_apps" = "Apps"; +"disk_analyzer_category_docs" = "Documents"; +"disk_analyzer_category_archives" = "Archives"; +"disk_analyzer_category_dev" = "Developer"; +"disk_analyzer_category_other" = "Other"; + + +/* About View */ "about_title" = "About MacOS Cleaner"; "about_version" = "Version %@"; "about_developer" = "Developed by AlexTkDev"; @@ -29,7 +52,7 @@ "about_linkedin" = "LinkedIn Profile"; "about_copyright" = "© 2026 AlexTkDev. All rights reserved."; -// Dashboard View +/* Dashboard View */ "dashboard_title" = "Dashboard"; "dashboard_system_info" = "System Information"; "dashboard_model" = "Model"; @@ -48,13 +71,13 @@ "dashboard_recent_operations" = "Recent Operations"; "dashboard_no_recent_operations" = "No recent operations"; -// Language Names +/* Language Names */ "language.english" = "English"; "language.russian" = "Russian"; "language.ukrainian" = "Ukrainian"; "language.spanish" = "Spanish"; -// Settings View +/* Settings View */ "settings_title" = "Settings"; "settings_subtitle" = "Configure app preferences"; "settings_general" = "General"; @@ -82,7 +105,7 @@ "settings_forget_description" = "Clear all saved data and reset settings to their defaults."; "settings_reset_button" = "Reset All Settings"; -// Uninstaller Scan Mode +/* Uninstaller Scan Mode */ "settings_uninstaller" = "Uninstaller"; "scan_mode" = "Scan Mode"; "scan_mode.safe" = "Safe"; @@ -91,17 +114,17 @@ "scan_mode.safe.desc" = "Finds only high-confidence files matched by Bundle ID, app name, or package receipts. Does not use Spotlight. Minimal risk of removing shared files."; "scan_mode.balanced.desc" = "Full scan including Spotlight (mdfind), extended paths, and all possible matches. Finds more residuals including non-standard locations. Recommended for thorough cleanup."; -// Update Checker +/* Update Checker */ "update.check" = "Check for Updates"; "update.available" = "Version %@ is available"; "update.download" = "Download on GitHub"; "update.up_to_date" = "Up to date"; "update.checking" = "Checking..."; -// Settings Tooltips +/* Settings Tooltips */ "settings_tooltip_language" = "Select the interface language for the application."; -// Notification Status +/* Notification Status */ "settings_notifications_status" = "Notification Status"; "settings_notifications_granted" = "Allowed"; "settings_notifications_denied" = "Denied (open System Settings)"; @@ -120,15 +143,15 @@ "settings_tooltip_skip_expert" = "Skip the Expert Mode file selection step and immediately uninstall applications with all associated files selected."; "settings_tooltip_forget" = "Remove all saved preferences and restore factory defaults."; -// Settings Reset Dialog +/* Settings Reset Dialog */ "settings_reset_confirm_title" = "Reset All Settings?"; "settings_reset_confirm_button" = "Reset All"; "settings_reset_confirm_message" = "This will clear all saved data and reset every setting to its default value. This action cannot be undone."; -// Trash & Deletion Warning +/* Trash & Deletion Warning */ "settings_trash_warning" = "These settings make deletion irreversible. Files bypassing or immediately emptied from the Trash cannot be recovered."; -// Startup Services View +/* Startup Services View */ "startup_title" = "Startup Services"; "startup_subtitle" = "Manage agents that start automatically."; "startup_refresh" = "Refresh list"; @@ -141,20 +164,20 @@ "startup_disable" = "Disable"; "startup_enable" = "Enable"; -// Startup Categories +/* Startup Categories */ "startup_category_user" = "My Services"; "startup_category_third_party" = "Third-party"; "startup_category_system" = "System"; -// Startup Filters +/* Startup Filters */ "startup_filter_all" = "All"; -// Startup Category Help +/* Startup Category Help */ "startup_help_user" = "User service from ~/Library/. Safe to disable."; "startup_help_third_party" = "Third-party service from /Library/. Can be disabled with caution."; "startup_help_system" = "Apple system service. Not recommended to disable."; -// Startup Vendor Settings +/* Startup Vendor Settings */ "settings_startup_vendors" = "System Vendors"; "startup_vendors_title" = "System Vendors"; "startup_vendors_description" = "Label prefixes considered as system services."; @@ -167,7 +190,7 @@ "startup_vendors_error_no_dot" = "Prefix must contain a dot"; "startup_vendors_error_duplicate" = "This prefix already exists"; -// Cleanup View +/* Cleanup View */ "cleanup_scanning" = "Scanning System..."; "cleanup_clean" = "System is Clean"; "cleanup_clean_sub" = "No unnecessary files were found during the scan."; @@ -199,11 +222,10 @@ "cleanup_failed" = "Cleanup Failed"; "cleanup_failed_default" = "An error occurred during the cleanup process."; "cleanup_script_logs" = "Script Logs:"; -"cleanup_complete" = "Cleanup Complete"; -"cleanup_complete_sub" = "Successfully freed %lld MB of disk space."; +"cleanup_complete" = "Cleanup Complete";"cleanup_complete_sub" = "Successfully freed %@ of disk space."; "cleanup_summary" = "Deleted Items Summary"; "cleanup_skipped" = "Could Not Clean"; -"cleanup_selected" = "Selected: %lld MB"; +"cleanup_selected" = "Selected: %@"; "cleanup_hide_logs" = "Hide Logs"; "cleanup_show_logs" = "Show Logs"; "cleanup_copy" = "Copy"; @@ -217,21 +239,21 @@ "cleanup_select_all" = "Select All"; "cleanup_show_all_count" = "Show all (%lld more)"; "cleanup_debug_log" = "Debug Log (%lld lines)"; - -// Cleanup Notifications + +/* Cleanup Notifications */ "cleanup_scan_complete_title" = "Scan Complete"; -"cleanup_scan_complete_body" = "Found %lld MB of files to clean."; +"cleanup_scan_complete_body" = "Found %@ of files to clean."; "cleanup_emptying_trash" = "Emptying Recycle Bin..."; "cleanup_complete_title" = "Cleanup Complete"; -"cleanup_complete_body" = "Successfully freed %lld MB."; +"cleanup_complete_body" = "Successfully freed %@."; -// Trash +/* Trash */ "trash_user_label" = "User Recycle Bin"; "trash_user_description" = "Contents of your system Recycle Bin. Contains deleted files."; "trash_access_prompt_message" = "Please select the Trash folder to grant access for scanning and cleaning. (It is already selected, just click 'Grant Access')"; "trash_access_prompt_button" = "Grant Access"; -// Uninstaller View +/* Uninstaller View */ "uninstaller_title" = "Uninstaller"; "uninstaller_search" = "Search Apps"; "uninstaller_reload" = "Reload Applications"; @@ -265,7 +287,7 @@ "uninstaller_complete_body" = "Application %@ was successfully removed."; "shared_data_warning" = "This data is shared with other apps (e.g., Android SDK, AVDs). Deleting may affect other IDEs."; -// Processes View +/* Processes View */ "menu_processes" = "Processes"; "processes_title" = "Processes"; "processes_subtitle" = "Manage running system processes."; @@ -298,7 +320,7 @@ "processes_whitelist_placeholder" = "Process name to protect..."; "add" = "Add"; -// Permissions +/* Permissions */ "permissions_title" = "Permissions Required"; "permissions_subtitle" = "MacOSCleaner needs access to system folders for cleanup."; "permissions_fda_description" = "Required to access ~/Library/Caches, ~/Library/Application Support, and other system folders."; @@ -315,18 +337,18 @@ "permissions_status_required" = "Required"; "permissions_window_title" = "Permissions"; -// Settings Permissions +/* Settings Permissions */ "settings_permissions" = "Permissions"; "settings_fda_description" = "Required for cleaning system caches and application data."; "settings_open_settings" = "Open Settings"; "settings_check_permissions" = "Check Permissions"; -// Format strings +/* Format strings */ "dashboard_used_percent_format" = "%lld%%"; "dashboard_freed_prefix" = "+%@"; "cleanup_mb_format" = "%lld MB"; -// Process format strings +/* Process format strings */ "processes_view_mode_grouped" = "Grouped"; "processes_view_mode_flat" = "Flat"; "processes_selected_count" = "%lld selected"; @@ -336,31 +358,31 @@ "process_uptime_hours_format" = "%lldh %lldm"; "process_uptime_minutes_format" = "%lldm"; -// Common +/* Common */ "version_unknown" = "N/A"; -// Operation Risk +/* Operation Risk */ "risk.safe" = "Safe"; "risk.moderate" = "Moderate"; "risk.dangerous" = "Dangerous"; "risk.protected" = "Protected"; -// Cleanup Dev Badge +/* Cleanup Dev Badge */ "cleanup_dev_badge" = "DEV"; -// Refresh Interval (Settings -> Processes) +/* Refresh Interval (Settings -> Processes) */ "refresh_manual" = "Manual"; "refresh_5s" = "Every 5 seconds"; "refresh_10s" = "Every 10 seconds"; "refresh_30s" = "Every 30 seconds"; -// Sort By (Settings -> Processes) +/* Sort By (Settings -> Processes) */ "sort_cpu" = "CPU Usage"; "sort_memory" = "Memory Usage"; "sort_name" = "Name"; "sort_threads" = "Thread Count"; -// Cleanup Category Names +/* Cleanup Category Names */ "category.app_caches" = "User app caches"; "category.package_managers" = "Package managers"; "category.gradle_maven" = "Gradle + Maven"; @@ -416,7 +438,7 @@ "category.duplicate_files" = "Duplicate Files"; "category.unused_apps" = "Unused Apps"; -// Process View - Missing Keys +/* Process View - Missing Keys */ "view_mode" = "View Mode"; "sort_by" = "Sort By"; "cancel_selection" = "Cancel Selection"; @@ -429,17 +451,17 @@ "processes_force_kill_all" = "Force Kill All"; "process.unknown" = "Unknown"; -// Uninstaller - Scan Progress +/* Uninstaller - Scan Progress */ "uninstaller.progress.discovering" = "Discovering applications..."; "uninstaller.progress.complete" = "Scan complete"; -// Uninstaller - Confidence Tiers +/* Uninstaller - Confidence Tiers */ "uninstaller.tier.ignore" = "Ignore"; "uninstaller.tier.possible" = "Possible"; "uninstaller.tier.very_likely" = "Very Likely"; "uninstaller.tier.guaranteed" = "Guaranteed"; -// Developer Components +/* Developer Components */ "developer.android_sdk" = "Android SDK"; "developer.gradle_cache" = "Gradle Cache"; "developer.xcode_derived_data" = "Xcode Derived Data"; @@ -448,7 +470,7 @@ "developer.docker" = "Docker"; "developer.homebrew" = "Homebrew"; -// Evidence Categories +/* Evidence Categories */ "uninstaller.evidence_category.identity" = "Identity Match"; "uninstaller.evidence_category.signature" = "Code Signature"; "uninstaller.evidence_category.system" = "System Integration"; @@ -457,7 +479,7 @@ "uninstaller.evidence_category.graph" = "Graph Propagation"; "uninstaller.evidence_category.launch_services" = "Launch Services"; -// Evidence Descriptions +/* Evidence Descriptions */ "uninstaller.evidence.bundleIDExact.title" = "Bundle ID Matches"; "uninstaller.evidence.bundleIDExact.description" = "Name matches the app's bundle identifier."; "uninstaller.evidence.bundleIDPrefix.title" = "Bundle ID Prefix"; @@ -517,7 +539,7 @@ "uninstaller.evidence.launchServicesRegistered.title" = "Launch Services"; "uninstaller.evidence.launchServicesRegistered.description" = "Registered in the Launch Services database."; -// Permissions - Missing Access Descriptions +/* Permissions - Missing Access Descriptions */ "permissions.full_disk_access" = "Full Disk Access"; "permissions.accessibility" = "Accessibility"; "permissions.automation" = "Automation (Apple Events)"; @@ -526,13 +548,13 @@ "permissions.notification_ephemeral" = "Ephemeral"; "permissions.unknown_status" = "Unknown"; -// Process Categories +/* Process Categories */ "process.category.applications" = "Applications"; "process.category.launch_agents" = "Launch Agents"; "process.category.launch_daemons" = "Launch Daemons"; "process.category.system" = "System"; -// Uninstaller - Additional UI +/* Uninstaller - Additional UI */ "uninstaller.scanning_deep" = "Deep Scanning..."; "uninstaller.why_this_file" = "Why this file?"; "uninstaller.related_files" = "Related Files"; @@ -540,20 +562,20 @@ "uninstaller.progress.developer_components" = "Checking developer components..."; "uninstaller.footer.summary" = "%lld file(s) selected across %@ tiers"; -// Format Helpers +/* Format Helpers */ "format_bytes_b" = "%lld B"; "format_bytes_kb" = "%.1f KB"; "format_bytes_mb" = "%.1f MB"; "format_bytes_gb" = "%.2f GB"; -// Process Block Reasons +/* Process Block Reasons */ "process_block_pid_format" = "PID %lld is a system-critical process"; "process_block_whitelist_name_format" = "%@ is in your whitelist (protected)"; "process_block_whitelist_bundle_format" = "%@ is in your whitelist (protected)"; "process_block_protected_format" = "%@ is a protected system process"; "process_block_no_path_format" = "%@ has no path info — proceed with caution"; -// Error Messages +/* Error Messages */ "error_ps_failed_format" = "Failed to list processes: %@"; "error_operation_blocked_format" = "Cannot terminate %@: %@"; "error_kill_failed_format" = "Failed to kill %@ (exit %lld): %@"; @@ -562,9 +584,9 @@ "error_command_failed_format" = "Command failed: %@"; "error_invalid_transition_format" = "Invalid transition from %@ to %@"; -// OS Version +/* OS Version */ "os_version_format" = "macOS %lld.%lld.%lld"; -// Uninstaller Tooltips +/* Uninstaller Tooltips */ "uninstaller_show_in_finder" = "Show in Finder"; "uninstaller_used_by" = "Used by %@"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index a9c6e3b..01cd2eb 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -20,6 +20,29 @@ "menu_startup_vendors" = "Proveedores de inicio"; "menu_uninstaller" = "Desinstalador"; "menu_settings" = "Ajustes"; +"menu_disk_space" = "Analizador de disco"; + +// Disk Analyzer Screen +"disk_analyzer_title" = "Analizador de espacio de disco"; +"disk_analyzer_scan" = "Escanear carpeta"; +"disk_analyzer_scanning" = "Escaneando..."; +"disk_analyzer_back" = "Volver"; +"disk_analyzer_delete_confirm" = "¿Mover elementos seleccionados a la Papelera?"; +"delete_action" = "Eliminar"; +"disk_analyzer_show_in_finder" = "Mostrar en Finder"; +"disk_analyzer_move_to_trash" = "Mover a la Papelera"; +"disk_analyzer_select_folder" = "Seleccionar carpeta para escanear"; +"disk_analyzer_empty" = "La carpeta está vacía o no ha sido escaneada"; +"disk_analyzer_no_permissions" = "Sin permisos de acceso para esta carpeta"; +"disk_analyzer_category_all" = "Todo"; +"disk_analyzer_category_video" = "Vídeo"; +"disk_analyzer_category_audio" = "Audio"; +"disk_analyzer_category_apps" = "Aplicaciones"; +"disk_analyzer_category_docs" = "Documentos"; +"disk_analyzer_category_archives" = "Archivos"; +"disk_analyzer_category_dev" = "Desarrollo"; +"disk_analyzer_category_other" = "Otro"; + // About View "about_title" = "Acerca de MacOS Cleaner"; @@ -200,10 +223,10 @@ "cleanup_failed_default" = "Ocurrió un error durante el proceso de limpieza."; "cleanup_script_logs" = "Registros de script:"; "cleanup_complete" = "Limpieza completa"; -"cleanup_complete_sub" = "Se liberaron %lld MB de espacio en disco."; +"cleanup_complete_sub" = "Se liberaron %@ de espacio en disco."; "cleanup_summary" = "Resumen de elementos eliminados"; "cleanup_skipped" = "No se pudo limpiar"; -"cleanup_selected" = "Seleccionado: %lld MB"; +"cleanup_selected" = "Seleccionado: %@"; "cleanup_hide_logs" = "Ocultar registros"; "cleanup_show_logs" = "Mostrar registros"; "cleanup_copy" = "Copiar"; @@ -217,13 +240,13 @@ "cleanup_select_all" = "Seleccionar todo"; "cleanup_show_all_count" = "Mostrar todo (%lld más)"; "cleanup_debug_log" = "Registro de depuración (%lld líneas)"; - + // Cleanup Notifications "cleanup_scan_complete_title" = "Escaneo completo"; -"cleanup_scan_complete_body" = "Se encontraron %lld MB de archivos para limpiar."; +"cleanup_scan_complete_body" = "Se encontraron %@ de archivos para limpiar."; "cleanup_emptying_trash" = "Vaciando papelera..."; "cleanup_complete_title" = "Limpieza completa"; -"cleanup_complete_body" = "Se liberaron %lld MB."; +"cleanup_complete_body" = "Se liberaron %@."; // Trash "trash_user_label" = "Papelera del usuario"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index 728a867..f53bb88 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -1,4 +1,4 @@ -// Common +/* Common */ "welcome_msg" = "С возвращением!"; "app_title" = "Очистка"; "sidebar_select_item" = "Выберите элемент в боковом меню"; @@ -13,15 +13,38 @@ "size" = "Размер"; "last_used" = "Последний запуск"; -// Sidebar / Navigation Menu +/* Sidebar / Navigation Menu */ "menu_dashboard" = "Обзор"; "menu_cleanup" = "Очистка"; "menu_startup_services" = "Автозапуск"; "menu_startup_vendors" = "Вендоры автозапуска"; "menu_uninstaller" = "Деинсталлятор"; "menu_settings" = "Настройки"; - -// About View +"menu_disk_space" = "Анализатор диска"; + +/* Disk Analyzer Screen */ +"disk_analyzer_title" = "Анализатор диска"; +"disk_analyzer_scan" = "Сканировать папку"; +"disk_analyzer_scanning" = "Сканирование..."; +"disk_analyzer_back" = "Назад"; +"disk_analyzer_delete_confirm" = "Переместить выбранные элементы в Корзину?"; +"delete_action" = "Удалить"; +"disk_analyzer_show_in_finder" = "Показать в Finder"; +"disk_analyzer_move_to_trash" = "В Корзину"; +"disk_analyzer_select_folder" = "Выбрать папку для сканирования"; +"disk_analyzer_empty" = "Папка пуста или еще не просканирована"; +"disk_analyzer_no_permissions" = "Нет прав доступа к этой папке"; +"disk_analyzer_category_all" = "Все"; +"disk_analyzer_category_video" = "Видео"; +"disk_analyzer_category_audio" = "Аудио"; +"disk_analyzer_category_apps" = "Программы"; +"disk_analyzer_category_docs" = "Документы"; +"disk_analyzer_category_archives" = "Архивы"; +"disk_analyzer_category_dev" = "Разработка"; +"disk_analyzer_category_other" = "Другое"; + + +/* About View */ "about_title" = "О программе MacOS Cleaner"; "about_version" = "Версия %@"; "about_developer" = "Разработчик: AlexTkDev"; @@ -29,7 +52,7 @@ "about_linkedin" = "Профиль LinkedIn"; "about_copyright" = "© 2026 AlexTkDev. Все права защищены."; -// Dashboard View +/* Dashboard View */ "dashboard_title" = "Обзор"; "dashboard_system_info" = "Информация о системе"; "dashboard_model" = "Модель"; @@ -48,13 +71,13 @@ "dashboard_recent_operations" = "Недавние операции"; "dashboard_no_recent_operations" = "Нет недавних операций"; -// Language Names +/* Language Names */ "language.english" = "Английский"; "language.russian" = "Русский"; "language.ukrainian" = "Украинский"; "language.spanish" = "Испанский"; -// Settings View +/* Settings View */ "settings_title" = "Настройки"; "settings_subtitle" = "Настройка параметров приложения"; "settings_general" = "Общие"; @@ -82,7 +105,7 @@ "settings_forget_description" = "Очистить все сохраненные данные и восстановить настройки по умолчанию."; "settings_reset_button" = "Сбросить все настройки"; -// Uninstaller Scan Mode +/* Uninstaller Scan Mode */ "settings_uninstaller" = "Деинсталлятор"; "scan_mode" = "Режим сканирования"; "scan_mode.safe" = "Безопасный"; @@ -91,17 +114,17 @@ "scan_mode.safe.desc" = "Находит только файлы с высокой уверенностью совпадения: по Bundle ID, имени приложения или пакетным квитанциям. Не использует Spotlight. Минимальный риск затронуть файлы других приложений."; "scan_mode.balanced.desc" = "Полный скан: включая Spotlight (mdfind), расширенные пути и все возможные совпадения. Находит больше остатков, включая файлы в нестандартных местах. Рекомендуется для максимальной чистоты."; -// Update Checker +/* Update Checker */ "update.check" = "Проверить обновления"; "update.available" = "Доступна версия %@"; "update.download" = "Скачать на GitHub"; "update.up_to_date" = "Актуальная версия"; "update.checking" = "Проверяем..."; -// Settings Tooltips +/* Settings Tooltips */ "settings_tooltip_language" = "Выберите язык интерфейса приложения."; -// Notification Status +/* Notification Status */ "settings_notifications_status" = "Статус уведомлений"; "settings_notifications_granted" = "Разрешено"; "settings_notifications_denied" = "Запрещено (открыть Настройки)"; @@ -120,15 +143,15 @@ "settings_tooltip_skip_expert" = "Пропускать выбор файлов в Экспертном режиме и сразу удалять приложения со всеми найденными связанными файлами."; "settings_tooltip_forget" = "Удалить все сохраненные настройки и восстановить параметры по умолчанию."; -// Settings Reset Dialog +/* Settings Reset Dialog */ "settings_reset_confirm_title" = "Сбросить все настройки?"; "settings_reset_confirm_button" = "Сбросить всё"; "settings_reset_confirm_message" = "Это приведет к очистке всех сохраненных данных и сбросу всех настроек до значений по умолчанию. Это действие нельзя отменить."; -// Trash & Deletion Warning +/* Trash & Deletion Warning */ "settings_trash_warning" = "Эти настройки делают удаление необратимым. Файлы, обходящие Корзину или удаляемые из неё сразу, восстановить нельзя."; -// Startup Services View +/* Startup Services View */ "startup_title" = "Автозапуск"; "startup_subtitle" = "Управление агентами автозагрузки."; "startup_refresh" = "Обновить список"; @@ -141,33 +164,42 @@ "startup_disable" = "Отключить"; "startup_enable" = "Включить"; -// Startup Categories +/* Startup Categories */ "startup_category_user" = "Мои сервисы"; "startup_category_third_party" = "Сторонние"; "startup_category_system" = "Системные"; -// Startup Filters +/* Startup Filters */ "startup_filter_all" = "Все"; -// Startup Category Help +/* Startup Category Help */ "startup_help_user" = "Пользовательский сервис из ~/Library/. Безопасно отключать."; -"startup_help_third_party" = "Сторонний сервис из /Library/. Можно отключать с осторожностью."; -"startup_help_system" = "Системный сервис Apple. Не рекомендуется отключать."; - -// Startup Vendor Settings -"settings_startup_vendors" = "Системные вендоры"; -"startup_vendors_title" = "Системные вендоры"; -"startup_vendors_description" = "Префиксы лейблов, которые считаются системными сервисами."; -"startup_vendors_description_sub" = "Сервисы с такими префиксами помечаются как 'Системные' и не рекомендуются к отключению."; -"startup_vendors_current" = "Текущие префиксы"; -"startup_vendors_reset" = "Сбросить"; -"startup_vendors_empty" = "Нет добавленных префиксов"; -"startup_vendors_protected" = "Защищён"; -"startup_vendors_placeholder" = "com.vendor."; -"startup_vendors_error_no_dot" = "Префикс должен содержать точку"; -"startup_vendors_error_duplicate" = "Такой префикс уже существует"; - -// Cleanup View +"startup_help_third_party" = "Сторонний сервис из /Library/. Безопасно отключать."; +"cleanup_summary" = "Сводка удаленных файлов"; +"cleanup_skipped" = "Не удалось очистить"; +"cleanup_selected" = "Выбрано: %@"; +"cleanup_hide_logs" = "Скрыть логи"; +"cleanup_show_logs" = "Показать логи"; +"cleanup_copy" = "Копировать"; +"cleanup_copy_logs" = "Копировать логи"; +"cleanup_now" = "Очистить сейчас"; +"cleanup_manual_instructions" = "Инструкции для ручной очистки"; +"cleanup_scan_results" = "Результаты сканирования"; +"cleanup_scan_results_sub" = "Выберите элементы для удаления и нажмите 'Очистить сейчас'."; +"cleanup_recommended" = "Рекомендуется к удалению"; +"cleanup_deselect_all" = "Снять выделение"; +"cleanup_select_all" = "Выбрать все"; +"cleanup_show_all_count" = "Показать все (ещё %lld)"; +"cleanup_debug_log" = "Отладочный лог (%lld строк)"; + +/* Cleanup Notifications */ +"cleanup_scan_complete_title" = "Сканирование завершено"; +"cleanup_scan_complete_body" = "Найдено файлов для удаления: %@."; +"cleanup_emptying_trash" = "Очистка корзины..."; +"cleanup_complete_title" = "Очистка завершена"; +"cleanup_complete_body" = "Успешно освобождено %@."; + +/* Cleanup View */ "cleanup_scanning" = "Сканирование системы..."; "cleanup_clean" = "Система чиста"; "cleanup_clean_sub" = "В ходе сканирования не было найдено ненужных файлов."; @@ -200,7 +232,7 @@ "cleanup_failed_default" = "Произошла ошибка в процессе очистки."; "cleanup_script_logs" = "Логи выполнения:"; "cleanup_complete" = "Очистка завершена"; -"cleanup_complete_sub" = "Успешно освобождено %lld МБ дискового пространства."; +"cleanup_complete_sub" = "Успешно освобождено %@ дискового пространства."; "cleanup_summary" = "Сводка удаленных файлов"; "cleanup_skipped" = "Не удалось очистить"; "cleanup_selected" = "Выбрано: %lld МБ"; @@ -218,20 +250,20 @@ "cleanup_show_all_count" = "Показать все (ещё %lld)"; "cleanup_debug_log" = "Отладочный лог (%lld строк)"; -// Cleanup Notifications +/* Cleanup Notifications */ "cleanup_scan_complete_title" = "Сканирование завершено"; "cleanup_scan_complete_body" = "Найдено файлов для удаления: %lld МБ."; "cleanup_emptying_trash" = "Очистка корзины..."; "cleanup_complete_title" = "Очистка завершена"; -"cleanup_complete_body" = "Успешно освобождено %lld МБ."; +"cleanup_complete_body" = "Успешно освобождено %@."; -// Trash +/* Trash */ "trash_user_label" = "Пользовательская корзина"; "trash_user_description" = "Содержимое вашей корзины. Включает удаленные файлы."; "trash_access_prompt_message" = "Пожалуйста, выберите папку Корзины для предоставления доступа к сканированию и очистке. (Она уже выбрана, просто нажмите «Предоставить доступ»)."; "trash_access_prompt_button" = "Предоставить доступ"; -// Uninstaller View +/* Uninstaller View */ "uninstaller_title" = "Деинсталлятор"; "uninstaller_search" = "Поиск приложений"; "uninstaller_reload" = "Обновить список"; @@ -267,7 +299,7 @@ "uninstaller_complete_body" = "Приложение %@ было успешно удалено."; "shared_data_warning" = "Эти данные общие для других приложений (например, Android SDK, AVD). Удаление может повлиять на другие IDE."; -// Processes View +/* Processes View */ "menu_processes" = "Процессы"; "processes_title" = "Процессы"; "processes_subtitle" = "Управление запущенными системными процессами."; @@ -300,7 +332,7 @@ "processes_whitelist_placeholder" = "Имя процесса для защиты..."; "add" = "Добавить"; -// Permissions +/* Permissions */ "permissions_title" = "Требуются разрешения"; "permissions_subtitle" = "MacOSCleaner нужен доступ к системным папкам для очистки."; "permissions_fda_description" = "Необходим для доступа к ~/Library/Caches, ~/Library/Application Support и другим системным папкам."; @@ -317,18 +349,18 @@ "permissions_status_required" = "Требуется"; "permissions_window_title" = "Разрешения"; -// Settings Permissions +/* Settings Permissions */ "settings_permissions" = "Разрешения"; "settings_fda_description" = "Необходим для очистки системных кэшей и данных приложений."; "settings_open_settings" = "Открыть настройки"; "settings_check_permissions" = "Проверить разрешения"; -// Format strings +/* Format strings */ "dashboard_used_percent_format" = "%lld%%"; "dashboard_freed_prefix" = "+%@"; "cleanup_mb_format" = "%lld МБ"; -// Process format strings +/* Process format strings */ "processes_view_mode_grouped" = "Группировать"; "processes_view_mode_flat" = "Списком"; "processes_selected_count" = "Выбрано: %lld"; @@ -338,31 +370,31 @@ "process_uptime_hours_format" = "%lldч %lldм"; "process_uptime_minutes_format" = "%lldм"; -// Common +/* Common */ "version_unknown" = "N/A"; -// Operation Risk +/* Operation Risk */ "risk.safe" = "Безопасно"; "risk.moderate" = "Умеренно"; "risk.dangerous" = "Опасно"; "risk.protected" = "Защищено"; -// Cleanup Dev Badge +/* Cleanup Dev Badge */ "cleanup_dev_badge" = "РАЗРАБ"; -// Refresh Interval (Settings -> Processes) +/* Refresh Interval (Settings -> Processes) */ "refresh_manual" = "Вручную"; "refresh_5s" = "Каждые 5 секунд"; "refresh_10s" = "Каждые 10 секунд"; "refresh_30s" = "Каждые 30 секунд"; -// Sort By (Settings -> Processes) +/* Sort By (Settings -> Processes) */ "sort_cpu" = "Загрузка CPU"; "sort_memory" = "Использование памяти"; "sort_name" = "Имя"; "sort_threads" = "Кол-во потоков"; -// Cleanup Category Names +/* Cleanup Category Names */ "category.app_caches" = "Кэши приложений"; "category.package_managers" = "Менеджеры пакетов"; "category.gradle_maven" = "Gradle + Maven"; @@ -418,7 +450,7 @@ "category.duplicate_files" = "Дубликаты файлов"; "category.unused_apps" = "Неиспользуемые приложения"; -// Process View - Missing Keys +/* Process View - Missing Keys */ "view_mode" = "Режим просмотра"; "sort_by" = "Сортировка"; "cancel_selection" = "Отменить выбор"; @@ -431,17 +463,17 @@ "processes_force_kill_all" = "Принудительно завершить все"; "process.unknown" = "Неизвестно"; -// Uninstaller - Scan Progress +/* Uninstaller - Scan Progress */ "uninstaller.progress.discovering" = "Поиск приложений..."; "uninstaller.progress.complete" = "Сканирование завершено"; -// Uninstaller - Confidence Tiers +/* Uninstaller - Confidence Tiers */ "uninstaller.tier.ignore" = "Игнорировать"; "uninstaller.tier.possible" = "Возможно"; "uninstaller.tier.very_likely" = "Очень вероятно"; "uninstaller.tier.guaranteed" = "Гарантировано"; -// Developer Components +/* Developer Components */ "developer.android_sdk" = "Android SDK"; "developer.gradle_cache" = "Кэш Gradle"; "developer.xcode_derived_data" = "Xcode Derived Data"; @@ -450,7 +482,7 @@ "developer.docker" = "Docker"; "developer.homebrew" = "Homebrew"; -// Evidence Categories +/* Evidence Categories */ "uninstaller.evidence_category.identity" = "Совпадение идентификатора"; "uninstaller.evidence_category.signature" = "Подпись кода"; "uninstaller.evidence_category.system" = "Системная интеграция"; @@ -459,7 +491,7 @@ "uninstaller.evidence_category.graph" = "Распространение графа"; "uninstaller.evidence_category.launch_services" = "Launch Services"; -// Evidence Descriptions +/* Evidence Descriptions */ "uninstaller.evidence.bundleIDExact.title" = "Совпадение Bundle ID"; "uninstaller.evidence.bundleIDExact.description" = "Имя совпадает с идентификатором пакета приложения."; "uninstaller.evidence.bundleIDPrefix.title" = "Префикс Bundle ID"; @@ -519,7 +551,7 @@ "uninstaller.evidence.launchServicesRegistered.title" = "Launch Services"; "uninstaller.evidence.launchServicesRegistered.description" = "Зарегистрировано в базе Launch Services."; -// Permissions - Missing Access Descriptions +/* Permissions - Missing Access Descriptions */ "permissions.full_disk_access" = "Полный доступ к диску"; "permissions.accessibility" = "Специальные возможности"; "permissions.automation" = "Автоматизация (Apple Events)"; @@ -528,13 +560,13 @@ "permissions.notification_ephemeral" = "Эфемерные"; "permissions.unknown_status" = "Неизвестно"; -// Process Categories +/* Process Categories */ "process.category.applications" = "Приложения"; "process.category.launch_agents" = "Агенты автозапуска"; "process.category.launch_daemons" = "Демоны"; "process.category.system" = "Системные"; -// Uninstaller - Additional UI +/* Uninstaller - Additional UI */ "uninstaller.scanning_deep" = "Глубокое сканирование..."; "uninstaller.why_this_file" = "Почему этот файл?"; "uninstaller.related_files" = "Связанные файлы"; @@ -542,20 +574,20 @@ "uninstaller.progress.developer_components" = "Проверка компонентов разработчика..."; "uninstaller.footer.summary" = "Выбрано %lld файлов в %@ категориях"; -// Format Helpers +/* Format Helpers */ "format_bytes_b" = "%lld Б"; "format_bytes_kb" = "%.1f КБ"; "format_bytes_mb" = "%.1f МБ"; "format_bytes_gb" = "%.2f ГБ"; -// Process Block Reasons +/* Process Block Reasons */ "process_block_pid_format" = "PID %lld — системный критический процесс"; "process_block_whitelist_name_format" = "%@ в вашем белом списке (защищён)"; "process_block_whitelist_bundle_format" = "%@ в вашем белом списке (защищён)"; "process_block_protected_format" = "%@ — защищённый системный процесс"; "process_block_no_path_format" = "%@ — нет информации о пути, действуйте осторожно"; -// Error Messages +/* Error Messages */ "error_ps_failed_format" = "Не удалось получить список процессов: %@"; "error_operation_blocked_format" = "Невозможно завершить %@: %@"; "error_kill_failed_format" = "Не удалось завершить %@ (код %lld): %@"; @@ -564,5 +596,5 @@ "error_command_failed_format" = "Команда не выполнена: %@"; "error_invalid_transition_format" = "Недопустимый переход из %@ в %@"; -// OS Version +/* OS Version */ "os_version_format" = "macOS %lld.%lld.%lld"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index 2428896..ccec1e3 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -1,4 +1,4 @@ -// Common +/* Common */ "welcome_msg" = "З поверненням!"; "app_title" = "Очищення"; "sidebar_select_item" = "Виберіть елемент у бічному меню"; @@ -13,15 +13,38 @@ "size" = "Розмір"; "last_used" = "Останній запуск"; -// Sidebar / Navigation Menu +/* Sidebar / Navigation Menu */ "menu_dashboard" = "Огляд"; "menu_cleanup" = "Очищення"; "menu_startup_services" = "Автозапуск"; "menu_startup_vendors" = "Вендори автозапуску"; "menu_uninstaller" = "Деінсталятор"; "menu_settings" = "Налаштування"; - -// About View +"menu_disk_space" = "Аналізатор диска"; + +/* Disk Analyzer Screen */ +"disk_analyzer_title" = "Аналізатор диска"; +"disk_analyzer_scan" = "Сканувати папку"; +"disk_analyzer_scanning" = "Сканування..."; +"disk_analyzer_back" = "Назад"; +"disk_analyzer_delete_confirm" = "Перемістити вибрані елементи в Смітник?"; +"delete_action" = "Видалити"; +"disk_analyzer_show_in_finder" = "Показати у Finder"; +"disk_analyzer_move_to_trash" = "У Смітник"; +"disk_analyzer_select_folder" = "Вибрати папку для сканирования"; +"disk_analyzer_empty" = "Папка порожня або ще не просканована"; +"disk_analyzer_no_permissions" = "Немає прав доступу до цієї папки"; +"disk_analyzer_category_all" = "Все"; +"disk_analyzer_category_video" = "Відео"; +"disk_analyzer_category_audio" = "Аудіо"; +"disk_analyzer_category_apps" = "Програми"; +"disk_analyzer_category_docs" = "Документи"; +"disk_analyzer_category_archives" = "Архів"; +"disk_analyzer_category_dev" = "Розробка"; +"disk_analyzer_category_other" = "Інше"; + + +/* About View */ "about_title" = "Про програму MacOS Cleaner"; "about_version" = "Версія %@"; "about_developer" = "Розробник: AlexTkDev"; @@ -29,7 +52,7 @@ "about_linkedin" = "Профіль LinkedIn"; "about_copyright" = "© 2026 AlexTkDev. Усі права захищені."; -// Dashboard View +/* Dashboard View */ "dashboard_title" = "Огляд"; "dashboard_system_info" = "Інформація про систему"; "dashboard_model" = "Модель"; @@ -48,13 +71,13 @@ "dashboard_recent_operations" = "Недавні операції"; "dashboard_no_recent_operations" = "Немає недавніх операцій"; -// Language Names +/* Language Names */ "language.english" = "Англійська"; "language.russian" = "Російська"; "language.ukrainian" = "Українська"; "language.spanish" = "Іспанська"; -// Settings View +/* Settings View */ "settings_title" = "Налаштування"; "settings_subtitle" = "Налаштування параметрів додатка"; "settings_general" = "Загальні"; @@ -82,7 +105,7 @@ "settings_forget_description" = "Очистити всі збережені дані та відновити налаштування за замовчуванням."; "settings_reset_button" = "Скинути всі налаштування"; -// Uninstaller Scan Mode +/* Uninstaller Scan Mode */ "settings_uninstaller" = "Деінсталятор"; "scan_mode" = "Режим сканування"; "scan_mode.safe" = "Безпечний"; @@ -91,17 +114,17 @@ "scan_mode.safe.desc" = "Знаходить лише файли з високою впевненістю збігу: за Bundle ID, назвою програми або пакетними квитанціями. Не використовує Spotlight. Мінімальний ризик зачепити файли інших програм."; "scan_mode.balanced.desc" = "Повне сканування: включаючи Spotlight (mdfind), розширені шляхи та всі можливі збіги. Знаходить більше залишків, включаючи файли в нестандартних місцях. Рекомендується для максимального очищення."; -// Update Checker +/* Update Checker */ "update.check" = "Перевірити оновлення"; "update.available" = "Доступна версія %@"; "update.download" = "Завантажити на GitHub"; "update.up_to_date" = "Актуальна версія"; "update.checking" = "Перевіряємо..."; -// Settings Tooltips +/* Settings Tooltips */ "settings_tooltip_language" = "Виберіть мову інтерфейсу додатка."; -// Notification Status +/* Notification Status */ "settings_notifications_status" = "Статус сповіщень"; "settings_notifications_granted" = "Дозволено"; "settings_notifications_denied" = "Заборонено (відкрити Налаштування)"; @@ -120,15 +143,15 @@ "settings_tooltip_skip_expert" = "Пропускати вибір файлів в Експертному режимі та одразу видаляти додатки з усіма знайденими пов'язаними файлами."; "settings_tooltip_forget" = "Видалити всі збережені налаштування та відновити параметри за замовчуванням."; -// Settings Reset Dialog +/* Settings Reset Dialog */ "settings_reset_confirm_title" = "Скинути всі налаштування?"; "settings_reset_confirm_button" = "Скинути все"; "settings_reset_confirm_message" = "Це призведе до очищення всіх збережених даних і скидання всіх налаштувань до значень за замовчуванням. Цю дію не можна скасувати."; -// Trash & Deletion Warning +/* Trash & Deletion Warning */ "settings_trash_warning" = "Ці налаштування роблять видалення незворотним. Файли, що обходять Смітник або видаляються з нього одразу, відновити неможливо."; -// Startup Services View +/* Startup Services View */ "startup_title" = "Автозапуск"; "startup_subtitle" = "Керування агентами автозапуску."; "startup_refresh" = "Оновити список"; @@ -144,29 +167,35 @@ "startup_category_third_party" = "Сторонні"; "startup_category_system" = "Система"; -// Startup Filters +/* Startup Filters */ "startup_filter_all" = "Усі"; -// Startup Category Help +/* Startup Category Help */ "startup_help_user" = "Служба користувача з ~/Library/. Безпечно вимикати."; -"startup_help_third_party" = "Стороння служба з /Library/. Вимикати з обережністю."; -"startup_help_system" = "Системна служба Apple. Не рекомендується вимикати."; - -// Startup Vendor Settings -"settings_startup_vendors" = "Системні вендори"; -"startup_vendors_title" = "Системні вендори"; -"startup_vendors_description" = "Префікси міток, які вважаються системними службами."; -"startup_vendors_description_sub" = "Служби з цими префіксами позначаються как 'Система' і не рекомендуються до вимкнення."; -"startup_vendors_current" = "Поточні префікси"; -"startup_vendors_reset" = "Скинути"; -"startup_vendors_empty" = "Префікси не додані"; -"startup_vendors_protected" = "Захищено"; -"startup_vendors_placeholder" = "com.vendor."; -"startup_vendors_error_no_dot" = "Префікс повинен містити крапку"; -"startup_vendors_error_duplicate" = "Цей префікс вже існує"; - -// Cleanup View -"cleanup_scanning" = "Сканування системи..."; +"startup_help_third_party" = "Стороння служба з /Library/. Безпечно вимикати."; +"cleanup_summary" = "Зведення видалених файлів"; +"cleanup_skipped" = "Не вдалося очистити"; +"cleanup_selected" = "Вибрано: %@"; +"cleanup_hide_logs" = "Приховати логи"; +"cleanup_show_logs" = "Показати логи"; +"cleanup_copy" = "Копіювати"; +"cleanup_copy_logs" = "Копиювати логи"; +"cleanup_now" = "Очистити зараз"; +"cleanup_manual_instructions" = "Інструкції для ручного очищення"; +"cleanup_scan_results" = "Результати сканирування"; +"cleanup_scan_results_sub" = "Виберіть елементи для видалення та натисніть 'Очистити зараз'."; +"cleanup_recommended" = "Рекомендується до видалення"; +"cleanup_deselect_all" = "Зняти виділення"; +"cleanup_select_all" = "Вибрати все"; +"cleanup_show_all_count" = "Показати все (ще %lld)"; +"cleanup_debug_log" = "Блокнот відладки (%lld рядків)"; + +/* Cleanup Notifications */ +"cleanup_scan_complete_title" = "Сканування завершено"; +"cleanup_scan_complete_body" = "Знайдено файлів для видалення: %@."; +"cleanup_emptying_trash" = "Очищення смітника..."; +"cleanup_complete_title" = "Очищення завершено"; +"cleanup_complete_body" = "Успішно звільнено %@."; "cleanup_clean" = "Система чиста"; "cleanup_clean_sub" = "Під час сканування не було знайдено непотрібних файлів."; "cleanup_rescan" = "Повторити сканування"; @@ -198,7 +227,7 @@ "cleanup_failed_default" = "Сталася помилка в процесі очищення."; "cleanup_script_logs" = "Логи виконання:"; "cleanup_complete" = "Очищення завершено"; -"cleanup_complete_sub" = "Успішно звільнено %lld МБ дискового простору."; +"cleanup_complete_sub" = "Успішно звільнено %@ дискового простору."; "cleanup_summary" = "Зведення видалених файлів"; "cleanup_skipped" = "Не вдалося очистити"; "cleanup_selected" = "Вибрано: %lld МБ"; @@ -216,20 +245,20 @@ "cleanup_show_all_count" = "Показати все (ще %lld)"; "cleanup_debug_log" = "Блокнот відладки (%lld рядків)"; -// Cleanup Notifications +/* Cleanup Notifications */ "cleanup_scan_complete_title" = "Сканування завершено"; "cleanup_scan_complete_body" = "Знайдено файлів для видалення: %lld МБ."; "cleanup_emptying_trash" = "Очищення смітника..."; "cleanup_complete_title" = "Очищення завершено"; -"cleanup_complete_body" = "Успішно звільнено %lld МБ."; +"cleanup_complete_body" = "Успішно звільнено %@."; -// Trash +/* Trash */ "trash_user_label" = "Користувацький смітник"; "trash_user_description" = "Вміст вашого смітника. Включає видалені файли."; "trash_access_prompt_message" = "Будь ласка, виберіть папку Смітника для надання доступу до сканування та очищення. (Вона вже вибрана, просто натисніть «Надати доступ»)."; "trash_access_prompt_button" = "Надати доступ"; -// Uninstaller View +/* Uninstaller View */ "uninstaller_title" = "Деінсталятор"; "uninstaller_search" = "Пошук додатків"; "uninstaller_reload" = "Оновити список"; @@ -265,7 +294,7 @@ "uninstaller_complete_body" = "Додаток %@ був успішно видалений."; "shared_data_warning" = "Ці дані спільні для інших додатків (наприклад, Android SDK, AVD). Видалення може вплинути на інші IDE."; -// Processes View +/* Processes View */ "menu_processes" = "Процеси"; "processes_title" = "Процеси"; "processes_subtitle" = "Керування запущеними системними процесами."; @@ -298,7 +327,7 @@ "processes_whitelist_placeholder" = "Ім'я процесу для захисту..."; "add" = "Додати"; -// Permissions +/* Permissions */ "permissions_title" = "Потрібні дозволи"; "permissions_subtitle" = "MacOSCleaner потрібен доступ до системних папок для очищення."; "permissions_fda_description" = "Необхідний для доступу до ~/Library/Caches, ~/Library/Application Support та інших системних папок."; @@ -315,18 +344,18 @@ "permissions_status_required" = "Потрібно"; "permissions_window_title" = "Дозволи"; -// Settings Permissions +/* Settings Permissions */ "settings_permissions" = "Дозволи"; "settings_fda_description" = "Необхідний для очищення системних кешів та даних додатків."; "settings_open_settings" = "Відкрити налаштування"; "settings_check_permissions" = "Перевірити дозволи"; -// Format strings +/* Format strings */ "dashboard_used_percent_format" = "%lld%%"; "dashboard_freed_prefix" = "+%@"; "cleanup_mb_format" = "%lld МБ"; -// Process format strings +/* Process format strings */ "processes_view_mode_grouped" = "Групувати"; "processes_view_mode_flat" = "Списком"; "processes_selected_count" = "Вибрано: %lld"; @@ -336,31 +365,31 @@ "process_uptime_hours_format" = "%lldгод %lldхв"; "process_uptime_minutes_format" = "%lldхв"; -// Common +/* Common */ "version_unknown" = "N/A"; -// Operation Risk +/* Operation Risk */ "risk.safe" = "Безпечно"; "risk.moderate" = "Помірно"; "risk.dangerous" = "Небезпечно"; "risk.protected" = "Захищено"; -// Cleanup Dev Badge +/* Cleanup Dev Badge */ "cleanup_dev_badge" = "РОЗРОБ"; -// Refresh Interval (Settings -> Processes) +/* Refresh Interval (Settings -> Processes) */ "refresh_manual" = "Вручну"; "refresh_5s" = "Кожні 5 секунд"; "refresh_10s" = "Кожні 10 секунд"; "refresh_30s" = "Кожні 30 секунд"; -// Sort By (Settings -> Processes) +/* Sort By (Settings -> Processes) */ "sort_cpu" = "CPU"; "sort_memory" = "Пам'ять"; "sort_name" = "Ім'я"; "sort_threads" = "К-сть потоків"; -// Cleanup Category Names +/* Cleanup Category Names */ "category.app_caches" = "Кеші додатків"; "category.package_managers" = "Менеджери пакетів"; "category.gradle_maven" = "Gradle + Maven"; @@ -416,7 +445,7 @@ "category.duplicate_files" = "Дублікати файлів"; "category.unused_apps" = "Невикористовувані додатки"; -// Process View - Missing Keys +/* Process View - Missing Keys */ "view_mode" = "Режим перегляду"; "sort_by" = "Сортування"; "cancel_selection" = "Скасувати вибір"; @@ -429,17 +458,17 @@ "processes_force_kill_all" = "Примусово завершити всі"; "process.unknown" = "Невідомо"; -// Uninstaller - Scan Progress +/* Uninstaller - Scan Progress */ "uninstaller.progress.discovering" = "Пошук додатків..."; "uninstaller.progress.complete" = "Сканування завершено"; -// Uninstaller - Confidence Tiers +/* Uninstaller - Confidence Tiers */ "uninstaller.tier.ignore" = "Ігнорувати"; "uninstaller.tier.possible" = "Можливо"; "uninstaller.tier.very_likely" = "Дуже ймовірно"; "uninstaller.tier.guaranteed" = "Гарантовано"; -// Developer Components +/* Developer Components */ "developer.android_sdk" = "Android SDK"; "developer.gradle_cache" = "Кеш Gradle"; "developer.xcode_derived_data" = "Xcode Derived Data"; @@ -448,7 +477,7 @@ "developer.docker" = "Docker"; "developer.homebrew" = "Homebrew"; -// Evidence Categories +/* Evidence Categories */ "uninstaller.evidence_category.identity" = "Збіг ідентифікатора"; "uninstaller.evidence_category.signature" = "Підпис коду"; "uninstaller.evidence_category.system" = "Системна інтеграція"; @@ -457,7 +486,7 @@ "uninstaller.evidence_category.graph" = "Поширення графа"; "uninstaller.evidence_category.launch_services" = "Launch Services"; -// Evidence Descriptions +/* Evidence Descriptions */ "uninstaller.evidence.bundleIDExact.title" = "Збіг Bundle ID"; "uninstaller.evidence.bundleIDExact.description" = "Ім'я збігається з ідентифікатором пакета додатка."; "uninstaller.evidence.bundleIDPrefix.title" = "Префікс Bundle ID"; @@ -517,7 +546,7 @@ "uninstaller.evidence.launchServicesRegistered.title" = "Launch Services"; "uninstaller.evidence.launchServicesRegistered.description" = "Зареєстровано в базі Launch Services."; -// Permissions - Missing Access Descriptions +/* Permissions - Missing Access Descriptions */ "permissions.full_disk_access" = "Повний доступ до диска"; "permissions.accessibility" = "Спеціальні можливості"; "permissions.automation" = "Автоматизація (Apple Events)"; @@ -526,13 +555,13 @@ "permissions.notification_ephemeral" = "Ефемерні"; "permissions.unknown_status" = "Невідомо"; -// Process Categories +/* Process Categories */ "process.category.applications" = "Додатки"; "process.category.launch_agents" = "Агенти автозапуску"; "process.category.launch_daemons" = "Демони"; "process.category.system" = "Системні"; -// Uninstaller - Additional UI +/* Uninstaller - Additional UI */ "uninstaller.scanning_deep" = "Глибоке сканування..."; "uninstaller.why_this_file" = "Чому цей файл?"; "uninstaller.related_files" = "Пов'язані файли"; @@ -540,20 +569,20 @@ "uninstaller.progress.developer_components" = "Перевірка компонентів розробника..."; "uninstaller.footer.summary" = "Вибрано %lld файлів у %@ категоріях"; -// Format Helpers +/* Format Helpers */ "format_bytes_b" = "%lld Б"; "format_bytes_kb" = "%.1f КБ"; "format_bytes_mb" = "%.1f МБ"; "format_bytes_gb" = "%.2f ГБ"; -// Process Block Reasons +/* Process Block Reasons */ "process_block_pid_format" = "PID %lld — системний критичний процес"; "process_block_whitelist_name_format" = "%@ у вашому білому списку (захищено)"; "process_block_whitelist_bundle_format" = "%@ у вашому білому списку (захищено)"; "process_block_protected_format" = "%@ — захищений системний процес"; "process_block_no_path_format" = "%@ — немає інформації про шлях, дійте обережно"; -// Error Messages +/* Error Messages */ "error_ps_failed_format" = "Не вдалося отримати список процесів: %@"; "error_operation_blocked_format" = "Неможливо завершити %@: %@"; "error_kill_failed_format" = "Не вдалося завершити %@ (код %lld): %@"; @@ -562,5 +591,5 @@ "error_command_failed_format" = "Команда не виконана: %@"; "error_invalid_transition_format" = "Неприпустимий перехід із %@ у %@"; -// OS Version +/* OS Version */ "os_version_format" = "macOS %lld.%lld.%lld"; From 75f794c1cbc3834afdc4ec6139b219aa373e6c7a Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Tue, 14 Jul 2026 19:18:46 +0300 Subject: [PATCH 03/23] feat: redesign dashboard with glass-morphism and radar disk usage chart --- MacOSCleaner/Features/About/AboutView.swift | 12 ++ .../Features/Dashboard/DashboardView.swift | 117 +++++++------- .../Dashboard/DashboardViewModel.swift | 108 +++++++++++++ .../Features/Dashboard/RadarChartView.swift | 145 ++++++++++++++++++ .../DiskAnalyzer/DiskAnalyzerView.swift | 37 ----- .../Uninstaller/EvidenceExplanation.swift | 15 +- .../LiquidGlass+Compatibility.swift | 50 ++++++ .../MacOSCleaner.xcodeproj/project.pbxproj | 12 +- .../DashboardViewModelTests.swift | 14 ++ .../Resources/en.lproj/Localizable.strings | 7 + .../Resources/es.lproj/Localizable.strings | 7 + .../Resources/ru.lproj/Localizable.strings | 7 + .../Resources/uk.lproj/Localizable.strings | 7 + MacOSCleaner/project.yml | 2 +- 14 files changed, 428 insertions(+), 112 deletions(-) create mode 100644 MacOSCleaner/Features/Dashboard/RadarChartView.swift create mode 100644 MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift diff --git a/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift index 4361f1c..b7d292a 100644 --- a/MacOSCleaner/Features/About/AboutView.swift +++ b/MacOSCleaner/Features/About/AboutView.swift @@ -79,9 +79,15 @@ struct AboutView: View { Text(String(format: "update.available".localized, version)) .font(.headline) .foregroundStyle(.white) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) Text("update.download".localized + " →") .font(.subheadline) .foregroundStyle(.white.opacity(0.8)) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } Spacer() } @@ -113,6 +119,9 @@ struct AboutView: View { VStack(spacing: 0) { Link(destination: URL(string: "https://github.com/AlexTkDev/MacOSCleaner/issues")!) { Label("about_problem_link".localized, systemImage: "exclamationmark.bubble.fill") + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .contentShape(Rectangle()) @@ -120,6 +129,9 @@ struct AboutView: View { Divider().padding(.leading, 44) Link(destination: URL(string: "https://www.linkedin.com/in/aleksandrtk/")!) { Label("about_linkedin".localized, systemImage: "person.crop.circle.badge.plus") + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .contentShape(Rectangle()) diff --git a/MacOSCleaner/Features/Dashboard/DashboardView.swift b/MacOSCleaner/Features/Dashboard/DashboardView.swift index d7ab9f1..45e7444 100644 --- a/MacOSCleaner/Features/Dashboard/DashboardView.swift +++ b/MacOSCleaner/Features/Dashboard/DashboardView.swift @@ -3,52 +3,59 @@ import Charts struct DashboardView: View { @StateObject private var viewModel: DashboardViewModel + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency init(journal: TransactionJournal) { _viewModel = StateObject(wrappedValue: DashboardViewModel(journal: journal)) } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - Text("dashboard_title".localized) - .font(.largeTitle) - .fontWeight(.bold) - - HStack(alignment: .top, spacing: 20) { - diskUsageCard - statsCard + GlassEffectContainer { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("dashboard_title".localized) + .font(.largeTitle) + .fontWeight(.bold) + + HStack(alignment: .top, spacing: 20) { + diskUsageCard + rightColumn + } + + recentOperationsSection } - - systemInfoSection - - recentOperationsSection + .padding(24) } - .padding(24) + .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15)) } - .background(Color(NSColor.controlBackgroundColor)) .task { await viewModel.refresh() } } - private var systemInfoSection: some View { + // Right column: Stats + System Info stacked + private var rightColumn: some View { VStack(alignment: .leading, spacing: 16) { + statsCard + systemInfoCard + } + } + + private var systemInfoCard: some View { + VStack(alignment: .leading, spacing: 12) { Label("dashboard_system_info".localized, systemImage: "info.circle") .font(.headline) - HStack(spacing: 40) { + VStack(alignment: .leading, spacing: 10) { SystemInfoItem(title: "dashboard_model".localized, value: viewModel.systemInfo.model, icon: "laptopcomputer") SystemInfoItem(title: "dashboard_os_version".localized, value: viewModel.systemInfo.osVersion, icon: "info.circle") SystemInfoItem(title: "dashboard_processor".localized, value: viewModel.systemInfo.processor, icon: "cpu") SystemInfoItem(title: "dashboard_memory".localized, value: viewModel.systemInfo.memory, icon: "memorychip") } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect() } private var diskUsageCard: some View { @@ -56,32 +63,22 @@ struct DashboardView: View { Label("dashboard_disk_usage".localized, systemImage: "internaldrive") .font(.headline) - ZStack { - Chart { - SectorMark( - angle: .value("dashboard_used".localized, viewModel.usedDiskSpace), - innerRadius: .ratio(0.55), - angularInset: 2 - ) - .foregroundStyle(Color.accentColor) - - SectorMark( - angle: .value("dashboard_free".localized, viewModel.freeDiskSpace), - innerRadius: .ratio(0.55), - angularInset: 2 - ) - .foregroundStyle(Color.secondary.opacity(0.15)) - } - .frame(height: 200) - - VStack { - Text(String(format: "dashboard_used_percent_format".localized, Int(viewModel.usedDiskPercentage * 100))) - .font(.system(size: 28, weight: .bold)) - .minimumScaleFactor(0.5) - Text("dashboard_used".localized) - .font(.caption) + if viewModel.isCategoriesLoading { + VStack(spacing: 12) { + ProgressView() + .scaleEffect(1.2) + Text("disk_analyzer_scanning".localized) + .font(.subheadline) .foregroundColor(.secondary) } + .frame(height: 300) + .frame(maxWidth: .infinity) + } else { + DiskDonutChartView( + items: viewModel.diskCategories, + totalUsed: viewModel.usedDiskSpace, + totalDisk: viewModel.totalDiskSpace + ) } HStack(spacing: 0) { @@ -93,27 +90,23 @@ struct DashboardView: View { } } .padding() - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .glassEffect() } private var statsCard: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { Label("dashboard_statistics".localized, systemImage: "chart.bar") .font(.headline) - VStack(spacing: 20) { - StatRow(title: "dashboard_total_freed".localized, value: ByteCountFormatter.localizedString(fromByteCount: viewModel.totalFreedBytes, countStyle: .file), icon: "trash") + VStack(spacing: 12) { + StatRow(title: "dashboard_total_freed".localized, value: viewModel.totalFreedBytes.formattedByteCount(), icon: "trash") StatRow(title: "dashboard_cleanups".localized, value: "\(viewModel.cleanupCount)", icon: "arrow.counterclockwise") StatRow(title: "dashboard_status".localized, value: "dashboard_healthy".localized, icon: "checkmark.circle", color: .green) } - Spacer() } .padding() - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect() } private var recentOperationsSection: some View { @@ -126,9 +119,7 @@ struct DashboardView: View { .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 40) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .glassEffect() } else { VStack(spacing: 0) { ForEach(viewModel.recentTransactions) { transaction in @@ -138,9 +129,7 @@ struct DashboardView: View { } } } - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .glassEffect() } } } @@ -190,7 +179,7 @@ struct TransactionRow: View { Spacer() - Text(String(format: "dashboard_freed_prefix".localized, ByteCountFormatter.localizedString(fromByteCount: totalFreed, countStyle: .file))) + Text(String(format: "dashboard_freed_prefix".localized, totalFreed.formattedByteCount())) .foregroundColor(.green) .fontWeight(.bold) } @@ -216,7 +205,7 @@ struct DiskStatItem: View { .font(.caption) .foregroundColor(.secondary) } - Text(ByteCountFormatter.localizedString(fromByteCount: value, countStyle: .file)) + Text(value.formattedByteCount()) .fontWeight(.medium) } } diff --git a/MacOSCleaner/Features/Dashboard/DashboardViewModel.swift b/MacOSCleaner/Features/Dashboard/DashboardViewModel.swift index 8950d7d..684077b 100644 --- a/MacOSCleaner/Features/Dashboard/DashboardViewModel.swift +++ b/MacOSCleaner/Features/Dashboard/DashboardViewModel.swift @@ -15,6 +15,10 @@ class DashboardViewModel: ObservableObject { @Published var recentTransactions: [CleanupTransaction] = [] @Published var systemInfo: SystemInfo = .current + // Disk Categories (Donut Chart) + @Published var isCategoriesLoading: Bool = true + @Published var diskCategories: [DiskCategoryItem] = [] + private let journal: TransactionJournal init(journal: TransactionJournal) { @@ -24,6 +28,7 @@ class DashboardViewModel: ObservableObject { func refresh() async { await fetchDiskUsage() await fetchHistory() + await fetchCategoryData() } private func fetchDiskUsage() async { @@ -52,6 +57,109 @@ class DashboardViewModel: ObservableObject { } } + private func fetchCategoryData() async { + isCategoriesLoading = true + let home = NSHomeDirectory() + + let categoryPaths: [(key: String, paths: [String])] = [ + ("caches", ["\(home)/Library/Caches", "/Library/Caches"]), + ("logs", ["\(home)/Library/Logs", "/Library/Logs"]), + ("dev", ["\(home)/Library/Developer"]), + ("apps", ["/Applications", "\(home)/Applications"]), + ("media", ["\(home)/Music", "\(home)/Pictures", "\(home)/Movies"]) + ] + + var calculatedSizes: [String: Int64] = [:] + + await withTaskGroup(of: (String, Int64).self) { group in + for entry in categoryPaths { + group.addTask { + var total: Int64 = 0 + for path in entry.paths { + total += await self.calculatePathSize(path) + } + return (entry.key, total) + } + } + for await (key, size) in group { + calculatedSizes[key] = size + } + } + + let cachesSize = calculatedSizes["caches", default: 0] + let logsSize = calculatedSizes["logs", default: 0] + let devSize = calculatedSizes["dev", default: 0] + let appsSize = calculatedSizes["apps", default: 0] + let mediaSize = calculatedSizes["media", default: 0] + let identifiedSum = cachesSize + logsSize + devSize + appsSize + mediaSize + // "Other" = used space not accounted for by the 5 categories + // Free space is shown as a separate explicit segment + let otherUsed = max(0, usedDiskSpace - identifiedSum) + + var items: [DiskCategoryItem] = [] + if cachesSize > 0 { + items.append(DiskCategoryItem(label: "dashboard_radar_caches".localized, bytes: cachesSize, color: .blue)) + } + if logsSize > 0 { + items.append(DiskCategoryItem(label: "dashboard_radar_logs".localized, bytes: logsSize, color: .orange)) + } + if devSize > 0 { + items.append(DiskCategoryItem(label: "dashboard_radar_dev".localized, bytes: devSize, color: .teal)) + } + if appsSize > 0 { + items.append(DiskCategoryItem(label: "dashboard_radar_apps".localized, bytes: appsSize, color: .purple)) + } + if mediaSize > 0 { + items.append(DiskCategoryItem(label: "dashboard_radar_media".localized, bytes: mediaSize, color: .pink)) + } + if otherUsed > 0 { + items.append(DiskCategoryItem(label: "dashboard_radar_other".localized, bytes: otherUsed, color: .brown)) + } + // Always show free space explicitly + if freeDiskSpace > 0 { + items.append(DiskCategoryItem(label: "dashboard_free".localized, bytes: freeDiskSpace, color: Color.secondary.opacity(0.35), isFree: true)) + } + + self.diskCategories = items + self.isCategoriesLoading = false + } + + private func calculatePathSize(_ path: String) async -> Int64 { + let fm = FileManager.default + let url = URL(fileURLWithPath: path) + var isDirectory: ObjCBool = false + + guard fm.fileExists(atPath: path, isDirectory: &isDirectory) else { return 0 } + if !isDirectory.boolValue { + let attribs = try? fm.attributesOfItem(atPath: path) + return (attribs?[.size] as? Int64) ?? 0 + } + + let keys: [URLResourceKey] = [.fileSizeKey, .isDirectoryKey] + guard let enumerator = fm.enumerator( + at: url, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { return 0 } + + var totalSize: Int64 = 0 + var yieldCount = 0 + + while let fileURL = enumerator.nextObject() as? URL { + guard let values = try? fileURL.resourceValues(forKeys: Set(keys)) else { continue } + if let isDir = values.isDirectory, isDir { + continue + } + totalSize += Int64(values.fileSize ?? 0) + yieldCount += 1 + if yieldCount % 1000 == 0 { + await Task.yield() + } + } + + return totalSize + } + var usedDiskSpace: Int64 { totalDiskSpace - freeDiskSpace } diff --git a/MacOSCleaner/Features/Dashboard/RadarChartView.swift b/MacOSCleaner/Features/Dashboard/RadarChartView.swift new file mode 100644 index 0000000..51a66e5 --- /dev/null +++ b/MacOSCleaner/Features/Dashboard/RadarChartView.swift @@ -0,0 +1,145 @@ +import SwiftUI +import Charts + +// MARK: - Model + +public struct DiskCategoryItem: Identifiable, Sendable, Hashable { + public let id = UUID() + public let label: String + public let bytes: Int64 + public let color: Color + public let isFree: Bool + + public var formattedValue: String { bytes.formattedByteCount() } + + public init(label: String, bytes: Int64, color: Color, isFree: Bool = false) { + self.label = label + self.bytes = bytes + self.color = color + self.isFree = isFree + } +} + +// MARK: - Donut Chart View + +public struct DiskDonutChartView: View { + let items: [DiskCategoryItem] + let totalUsed: Int64 + let totalDisk: Int64 + + @State private var hoveredID: UUID? = nil + + public init(items: [DiskCategoryItem], totalUsed: Int64, totalDisk: Int64) { + self.items = items + self.totalUsed = totalUsed + self.totalDisk = totalDisk + } + + private var hoveredItem: DiskCategoryItem? { + guard let id = hoveredID else { return nil } + return items.first { $0.id == id } + } + + private var usedPercent: Int { + guard totalDisk > 0 else { return 0 } + return Int((Double(totalUsed) / Double(totalDisk)) * 100) + } + + public var body: some View { + VStack(spacing: 16) { + ZStack { + Chart(items) { item in + SectorMark( + angle: .value(item.label, item.bytes), + innerRadius: .ratio(0.55), + angularInset: hoveredID == item.id ? 3 : 1.5 + ) + .foregroundStyle(item.color) + .opacity(hoveredID == nil || hoveredID == item.id ? 1.0 : 0.45) + } + .frame(height: 220) + + // Center label + VStack(spacing: 2) { + if let h = hoveredItem { + Text(h.label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(h.formattedValue) + .font(.system(size: 20, weight: .bold)) + .minimumScaleFactor(0.6) + } else { + Text("\(usedPercent)%") + .font(.system(size: 28, weight: .bold)) + .minimumScaleFactor(0.5) + Text("dashboard_used".localized) + .font(.caption) + .foregroundColor(.secondary) + } + } + .animation(.easeInOut(duration: 0.2), value: hoveredID) + .frame(width: 100) + } + // Hit testing overlay per sector is not directly supported in Charts, + // so we use the legend rows as hover targets + legendView + } + } + + private var legendView: some View { + let usedItems = items.filter { !$0.isFree } + let freeItem = items.first { $0.isFree } + let columns = [GridItem(.flexible()), GridItem(.flexible())] + return VStack(alignment: .leading, spacing: 4) { + LazyVGrid(columns: columns, spacing: 6) { + ForEach(usedItems) { item in + legendRow(item: item) + } + } + if let free = freeItem { + Divider().padding(.vertical, 2) + legendRow(item: free) + } + } + } + + private func legendRow(item: DiskCategoryItem) -> some View { + HStack(spacing: 6) { + if item.isFree { + RoundedRectangle(cornerRadius: 3) + .stroke(Color.secondary, lineWidth: 1.5) + .frame(width: 10, height: 10) + } else { + RoundedRectangle(cornerRadius: 3) + .fill(item.color) + .frame(width: 10, height: 10) + } + Text(item.label) + .font(.caption) + .foregroundColor(item.isFree ? .secondary : .primary) + .lineLimit(1) + Spacer(minLength: 0) + Text(item.formattedValue) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + !item.isFree && hoveredID == item.id + ? item.color.opacity(0.15) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + .onHover { isHovered in + guard !item.isFree else { return } + withAnimation(.easeInOut(duration: 0.15)) { + hoveredID = isHovered ? item.id : nil + } + } + } +} diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift index 68be85f..f2a4f97 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift @@ -1,42 +1,5 @@ import SwiftUI -// Compatibility helpers for macOS 27 Liquid Glass if SDK doesn't have them yet -#if !hasFeature(LiquidGlass) -public struct GlassEffectContainer: View { - let spacing: CGFloat? - let content: () -> Content - - public init(spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { - self.spacing = spacing - self.content = content - } - - public var body: some View { - VStack(spacing: spacing) { - content() - } - } -} - -public extension View { - func glassEffect() -> some View { - self.background(VisualEffectView(material: .hudWindow, blendingMode: .withinWindow).clipShape(RoundedRectangle(cornerRadius: 12))) - } - - func glassEffect(_ glass: String = "", in shape: some Shape = RoundedRectangle(cornerRadius: 12)) -> some View { - self.background(VisualEffectView(material: .hudWindow, blendingMode: .withinWindow).clipShape(shape)) - } - - func glassEffectID(_ id: AnyHashable, in namespace: Namespace.ID) -> some View { - self - } - - func glassEffectTransition(_ transition: Any) -> some View { - self - } -} -#endif - public struct DiskAnalyzerView: View { @State private var viewModel = DiskAnalyzerViewModel() @Environment(\.accessibilityReduceTransparency) private var reduceTransparency diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceExplanation.swift b/MacOSCleaner/Features/Uninstaller/EvidenceExplanation.swift index 325db54..17f1023 100644 --- a/MacOSCleaner/Features/Uninstaller/EvidenceExplanation.swift +++ b/MacOSCleaner/Features/Uninstaller/EvidenceExplanation.swift @@ -50,14 +50,13 @@ public enum EvidenceExplanations { } } } - var grouped: [EvidenceCategory: [EvidenceExplanation]] = [:] - for evidence in set { - let arg = args[evidence] ?? "" as CVarArg - let exp = explanation(for: evidence, args: arg) - grouped[exp.category, default: []].append(exp) - } - for key in grouped.keys { - grouped[key]?.sort { $0.evidence.rawValue < $1.evidence.rawValue } + let initialGrouped = Dictionary(grouping: set) { $0.category } + let grouped = initialGrouped.mapValues { (evidences: [Evidence]) -> [EvidenceExplanation] in + let explanations = evidences.map { (evidence: Evidence) -> EvidenceExplanation in + let arg = args[evidence] ?? "" as CVarArg + return explanation(for: evidence, args: arg) + } + return explanations.sorted { $0.evidence.rawValue < $1.evidence.rawValue } } return grouped } diff --git a/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift b/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift new file mode 100644 index 0000000..69e101c --- /dev/null +++ b/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift @@ -0,0 +1,50 @@ +import SwiftUI + +#if !hasFeature(LiquidGlass) +public struct Glass: Sendable, Hashable { + public static let regular = Glass() + public static let clear = Glass() + public static let identity = Glass() + + public func tint(_ color: Color) -> Glass { self } + public func interactive(_ active: Bool = true) -> Glass { self } +} + +public struct GlassEffectContainer: View { + let spacing: CGFloat? + let content: () -> Content + + public init(spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + public var body: some View { + VStack(spacing: spacing) { + content() + } + } +} + +public extension View { + func glassEffect() -> some View { + self.background(VisualEffectView(material: .hudWindow, blendingMode: .withinWindow).clipShape(RoundedRectangle(cornerRadius: 12))) + } + + func glassEffect(_ glass: Glass, in shape: some Shape = RoundedRectangle(cornerRadius: 12)) -> some View { + self.background(VisualEffectView(material: .hudWindow, blendingMode: .withinWindow).clipShape(shape)) + } + + func glassEffectID(_ id: (some Hashable & Sendable)?, in namespace: Namespace.ID) -> some View { + self + } + + func glassEffectTransition(_ transition: Any) -> some View { + self + } + + func glassEffectUnion(id: (some Hashable & Sendable)?, namespace: Namespace.ID) -> some View { + self + } +} +#endif diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index 1665af1..a731094 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ 2FBBF85D96918E09A80C5939 /* DeveloperComponentsDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD2376BA6F50A27A67E4736 /* DeveloperComponentsDetector.swift */; }; 31B9F1688720FBE10F8F3B46 /* MicrosoftOffice.json in Resources */ = {isa = PBXBuildFile; fileRef = D1E2968239F9E780D1928B58 /* MicrosoftOffice.json */; }; 32D15D3B2B3404DE9662CEA5 /* EvidenceCategoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2854706813F7B138A3E1A17C /* EvidenceCategoryTests.swift */; }; + 332803EC2632D55C1CA2C21D /* RadarChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC46B82149E32DE965863BC /* RadarChartView.swift */; }; 338946DFC3F61C72FCC52D5E /* AdobeRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8D50E5702E806D30909278 /* AdobeRule.swift */; }; 36CFC6F9A71EE6AA66E178C5 /* LaunchctlCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B625D7C04F6EC419FA5F0764 /* LaunchctlCache.swift */; }; 370555A1E46A82B05D6374B0 /* CleanupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16400AE1862B76A844B01CFC /* CleanupView.swift */; }; @@ -92,6 +93,7 @@ 7F80CDAE8621DF99A473AB3B /* CleanupStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D39CB998D05F06C7122FCA3 /* CleanupStateMachine.swift */; }; 81622E787C1700C4F73D32CB /* BrowserRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF63D60CE321A50740AB3 /* BrowserRule.swift */; }; 87EDEF516A814FC99DCA0B31 /* BackgroundItemsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70C3847C35AB0FA0E8C80210 /* BackgroundItemsReader.swift */; }; + 89BAEC96A8994A5092900B77 /* LiquidGlass+Compatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F30EB8C917D9A5DB59EB765 /* LiquidGlass+Compatibility.swift */; }; 8E3DECCD5B86A373058DED54 /* StartupServicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0086F9F7800BFD540E5F25E /* StartupServicesViewModel.swift */; }; 8F1C2ACF00DF4A34A6CA9862 /* AnimatedScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6703835CE5100161449E56 /* AnimatedScanView.swift */; }; 92E0F73F02E1797F191A0BCD /* RaycastRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB502AA9A1CC1FB93686DC00 /* RaycastRule.swift */; }; @@ -204,6 +206,7 @@ 08402BF5E1C6101310B2B476 /* LanguageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageManager.swift; sourceTree = ""; }; 086A1B781AA3F6ACC5AFA252 /* Cursor.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Cursor.json; sourceTree = ""; }; 08A2575EBBEE5EC6B202760B /* AlfredRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlfredRule.swift; sourceTree = ""; }; + 0AC46B82149E32DE965863BC /* RadarChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadarChartView.swift; sourceTree = ""; }; 0BB6DE85A7D910E1F0424CEF /* TransactionJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionJournalTests.swift; sourceTree = ""; }; 0C92C74A3F932664D5001998 /* BaselineFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaselineFixture.swift; sourceTree = ""; }; 0D0235DC89C6C2BDB34309D1 /* DiskAnalyzerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskAnalyzerView.swift; sourceTree = ""; }; @@ -256,6 +259,7 @@ 4C881B157A7F5ACBF84D7E64 /* LanguageManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageManagerTests.swift; sourceTree = ""; }; 4D39CB998D05F06C7122FCA3 /* CleanupStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupStateMachine.swift; sourceTree = ""; }; 4EA68BEDE6F540E3477849FC /* SafetyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyManager.swift; sourceTree = ""; }; + 4F30EB8C917D9A5DB59EB765 /* LiquidGlass+Compatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LiquidGlass+Compatibility.swift"; sourceTree = ""; }; 4F8E546AF7573B471B314407 /* DiskScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskScannerTests.swift; sourceTree = ""; }; 50C1C6C0C2F943213529B9BC /* AppDiscoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDiscoveryTests.swift; sourceTree = ""; }; 51B066CCFCE8479C82161FDF /* AppIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentityTests.swift; sourceTree = ""; }; @@ -704,6 +708,7 @@ 280CD515ADC8F97A79AD7B12 /* DashboardView.swift */, DB8F5E087C85389E6C6C2B26 /* DashboardView.swift.back */, A5EFE9DE1E343AF8F2490C67 /* DashboardViewModel.swift */, + 0AC46B82149E32DE965863BC /* RadarChartView.swift */, ); path = Dashboard; sourceTree = ""; @@ -719,6 +724,7 @@ 78EEF9CB2BE762A51083464F /* FileManager+Size.swift */, 91A11C06D6D12353C59E09EF /* FileScanner.swift */, 08402BF5E1C6101310B2B476 /* LanguageManager.swift */, + 4F30EB8C917D9A5DB59EB765 /* LiquidGlass+Compatibility.swift */, 9E5214D1AD87C67377076C08 /* NotificationManager.swift */, 70E3A139737CC8A7ECBD1900 /* PermissionsManager.swift */, F514077EE94AA6AF042FE5B0 /* PosixScanner.swift */, @@ -993,6 +999,7 @@ 9A045237ED42AC3CC1C06031 /* LanguageManager.swift in Sources */, DDF1A3BD13DF7AA1C39EA144 /* LaunchServiceManager.swift in Sources */, 36CFC6F9A71EE6AA66E178C5 /* LaunchctlCache.swift in Sources */, + 89BAEC96A8994A5092900B77 /* LiquidGlass+Compatibility.swift in Sources */, 0428CB5B74191FAFF78C6F48 /* LittleSnitchRule.swift in Sources */, 9CEF93EA4AC1719CDF985A58 /* LogicProRule.swift in Sources */, D5736DBAF67A7BA78B39290F /* MacOSCleanerApp.swift in Sources */, @@ -1020,6 +1027,7 @@ AEBD6F435B4BC89EB6A27021 /* ProcessSafetyPolicy.swift in Sources */, 9E12B9110CAF741A51367B1C /* ProcessesView.swift in Sources */, 7F6B179C933B447BD9F76FCA /* ProcessesViewModel.swift in Sources */, + 332803EC2632D55C1CA2C21D /* RadarChartView.swift in Sources */, FF78CCB002655FB528308A84 /* RancherDesktopRule.swift in Sources */, 92E0F73F02E1797F191A0BCD /* RaycastRule.swift in Sources */, 2C15EA5886E20334BA912869 /* RetryPolicy.swift in Sources */, @@ -1107,7 +1115,7 @@ SDKROOT = macosx; SWIFT_COMPILATION_MODE = singlefile; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; }; name = Debug; @@ -1202,7 +1210,7 @@ SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; }; name = Release; diff --git a/MacOSCleaner/MacOSCleanerTests/DashboardViewModelTests.swift b/MacOSCleaner/MacOSCleanerTests/DashboardViewModelTests.swift index 4a76d06..ef1ec13 100644 --- a/MacOSCleaner/MacOSCleanerTests/DashboardViewModelTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/DashboardViewModelTests.swift @@ -52,4 +52,18 @@ final class DashboardViewModelTests: XCTestCase { XCTAssertGreaterThanOrEqual(viewModel.usedDiskPercentage, 0) XCTAssertLessThanOrEqual(viewModel.usedDiskPercentage, 1.0) } + + func testDiskCategoriesData() async { + await viewModel.refresh() + XCTAssertFalse(viewModel.isCategoriesLoading) + XCTAssertEqual(viewModel.diskCategories.count, 6) + + let labels = Set(viewModel.diskCategories.map { $0.label }) + XCTAssertTrue(labels.contains("dashboard_radar_caches".localized)) + XCTAssertTrue(labels.contains("dashboard_radar_logs".localized)) + XCTAssertTrue(labels.contains("dashboard_radar_dev".localized)) + XCTAssertTrue(labels.contains("dashboard_radar_apps".localized)) + XCTAssertTrue(labels.contains("dashboard_radar_media".localized)) + XCTAssertTrue(labels.contains("dashboard_radar_other".localized)) + } } diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index ab76dab..6bfd8f3 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -70,6 +70,13 @@ "dashboard_healthy" = "Healthy"; "dashboard_recent_operations" = "Recent Operations"; "dashboard_no_recent_operations" = "No recent operations"; +"dashboard_radar_caches" = "Caches"; +"dashboard_radar_logs" = "Logs"; +"dashboard_radar_dev" = "Development"; +"dashboard_radar_apps" = "Applications"; +"dashboard_radar_media" = "Media"; +"dashboard_radar_other" = "Other"; +"dashboard_radar_tooltip_format" = "%@: %@"; /* Language Names */ "language.english" = "English"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index 01cd2eb..334a060 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -70,6 +70,13 @@ "dashboard_healthy" = "Saludable"; "dashboard_recent_operations" = "Operaciones recientes"; "dashboard_no_recent_operations" = "Sin operaciones recientes"; +"dashboard_radar_caches" = "Cachés"; +"dashboard_radar_logs" = "Registros"; +"dashboard_radar_dev" = "Desarrollo"; +"dashboard_radar_apps" = "Aplicaciones"; +"dashboard_radar_media" = "Medios"; +"dashboard_radar_other" = "Otros"; +"dashboard_radar_tooltip_format" = "%@: %@"; // Language Names "language.english" = "Inglés"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index f53bb88..3243e4b 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -70,6 +70,13 @@ "dashboard_healthy" = "В норме"; "dashboard_recent_operations" = "Недавние операции"; "dashboard_no_recent_operations" = "Нет недавних операций"; +"dashboard_radar_caches" = "Кэши"; +"dashboard_radar_logs" = "Логи"; +"dashboard_radar_dev" = "Разработка"; +"dashboard_radar_apps" = "Программы"; +"dashboard_radar_media" = "Медиа"; +"dashboard_radar_other" = "Прочее"; +"dashboard_radar_tooltip_format" = "%@: %@"; /* Language Names */ "language.english" = "Английский"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index ccec1e3..41aa3d3 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -70,6 +70,13 @@ "dashboard_healthy" = "У нормі"; "dashboard_recent_operations" = "Недавні операції"; "dashboard_no_recent_operations" = "Немає недавніх операцій"; +"dashboard_radar_caches" = "Кеші"; +"dashboard_radar_logs" = "Логи"; +"dashboard_radar_dev" = "Розробка"; +"dashboard_radar_apps" = "Програми"; +"dashboard_radar_media" = "Медіа"; +"dashboard_radar_other" = "Інше"; +"dashboard_radar_tooltip_format" = "%@: %@"; /* Language Names */ "language.english" = "Англійська"; diff --git a/MacOSCleaner/project.yml b/MacOSCleaner/project.yml index d702f8d..efdc98b 100644 --- a/MacOSCleaner/project.yml +++ b/MacOSCleaner/project.yml @@ -33,7 +33,7 @@ targets: INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 MacOSCleaner. All rights reserved." SWIFT_COMPILATION_MODE: wholemodule DEAD_CODE_STRIPPING: YES - SWIFT_STRICT_CONCURRENCY: minimal + SWIFT_STRICT_CONCURRENCY: complete CLANG_ANALYZER_NONNULL: YES GCC_WARN_ABOUT_RETURN_TYPE: YES_ERROR COPY_PHASE_STRIP: NO From 978b7d1a0a75ca3412b5cc72f0bdcae7df47f8be Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Thu, 16 Jul 2026 15:20:25 +0300 Subject: [PATCH 04/23] feat: add interactive update checker and integrate glass UI effect into Uninstaller view --- MacOSCleaner/App/MacOSCleanerApp.swift | 58 ++++++++ MacOSCleaner/Features/About/AboutView.swift | 12 +- .../Features/Cleanup/CleanupView.swift | 69 ++++----- .../Features/Processes/ProcessesView.swift | 104 ++++++------- .../StartupServices/StartupServicesView.swift | 50 +++---- .../Uninstaller/UninstallerView.swift | 140 +++++++++--------- .../LiquidGlass+Compatibility.swift | 87 +++++++++++ .../Resources/en.lproj/Localizable.strings | 4 + .../Resources/es.lproj/Localizable.strings | 4 + .../Resources/ru.lproj/Localizable.strings | 4 + .../Resources/uk.lproj/Localizable.strings | 4 + 11 files changed, 338 insertions(+), 198 deletions(-) diff --git a/MacOSCleaner/App/MacOSCleanerApp.swift b/MacOSCleaner/App/MacOSCleanerApp.swift index d6ae494..5c542fa 100644 --- a/MacOSCleaner/App/MacOSCleanerApp.swift +++ b/MacOSCleaner/App/MacOSCleanerApp.swift @@ -24,6 +24,7 @@ struct MacOSCleanerApp: App { private let permissionsManager = PermissionsManager() private let updateChecker = UpdateChecker() @State private var availableUpdate: String? = nil + @State private var isCheckingForUpdates = false init() { Self.installCrashHandlers() @@ -79,6 +80,33 @@ struct MacOSCleanerApp: App { Button("about_title".localized) { openWindow(id: "about") } + Button(isCheckingForUpdates ? "update.checking".localized : "update.check".localized) { + isCheckingForUpdates = true + Task { @MainActor in + let result = await updateChecker.checkForUpdate() + availableUpdate = result + isCheckingForUpdates = false + + let alert = NSAlert() + alert.messageText = "update.check".localized + if let result { + alert.informativeText = String(format: "update.available".localized, result) + alert.addButton(withTitle: "update.download".localized) + alert.addButton(withTitle: "cancel".localized) + let response = alert.runModal() + if response == .alertFirstButtonReturn { + NSWorkspace.shared.open(UpdateChecker.releasesURL) + } + } else { + let accessory = NSHostingView(rootView: UpToDateAlertView()) + accessory.frame = NSRect(x: 0, y: 0, width: 300, height: 70) + alert.accessoryView = accessory + alert.addButton(withTitle: "close".localized) + alert.runModal() + } + } + } + .disabled(isCheckingForUpdates) } } @@ -95,3 +123,33 @@ struct MacOSCleanerApp: App { .defaultPosition(.center) } } + +struct UpToDateAlertView: View { + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("update.up_to_date_message".localized) + .font(.system(size: 13)) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("update.releases_label".localized) + .font(.system(size: 11)) + .foregroundColor(.secondary) + Link("https://github.com/AlexTkDev/MacOSCleaner/releases", destination: URL(string: "https://github.com/AlexTkDev/MacOSCleaner/releases")!) + .font(.system(size: 11)) + } + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("update.website_label".localized) + .font(.system(size: 11)) + .foregroundColor(.secondary) + Link("https://alextkdev.github.io/MacOSCleaner/", destination: URL(string: "https://alextkdev.github.io/MacOSCleaner/")!) + .font(.system(size: 11)) + } + } + } + .frame(width: 300, height: 70, alignment: .leading) + } +} diff --git a/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift index b7d292a..8af3f72 100644 --- a/MacOSCleaner/Features/About/AboutView.swift +++ b/MacOSCleaner/Features/About/AboutView.swift @@ -22,7 +22,7 @@ struct AboutView: View { .shadow(color: .black.opacity(0.2), radius: 8, y: 4) VStack(spacing: 2) { - Text("app_title".localized) + Text("MacOS Cleaner") .font(.title) .fontWeight(.bold) Text(String(format: "about_version".localized, Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "version_unknown".localized)) @@ -117,6 +117,16 @@ struct AboutView: View { private var linksCard: some View { VStack(spacing: 0) { + Link(destination: URL(string: "https://alextkdev.github.io/MacOSCleaner/")!) { + Label("about_website".localized, systemImage: "globe") + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .contentShape(Rectangle()) + } + Divider().padding(.leading, 44) Link(destination: URL(string: "https://github.com/AlexTkDev/MacOSCleaner/issues")!) { Label("about_problem_link".localized, systemImage: "exclamationmark.bubble.fill") .lineLimit(nil) diff --git a/MacOSCleaner/Features/Cleanup/CleanupView.swift b/MacOSCleaner/Features/Cleanup/CleanupView.swift index ffc6943..5110d82 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupView.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupView.swift @@ -1,37 +1,40 @@ import SwiftUI public struct CleanupView: View { - @State private var viewModel: CleanupViewModel + let viewModel: CleanupViewModel @State private var showLogs = false @State private var showCopiedHint = false @State private var logHeight: CGFloat = 160 @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency public init(viewModel: CleanupViewModel) { - _viewModel = State(initialValue: viewModel) + self.viewModel = viewModel } public var body: some View { - VStack(spacing: 0) { - content - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - - footer - } - .background( - ZStack { - Color(NSColor.windowBackgroundColor) - if viewModel.state == .scanning || viewModel.state == .executing { - LinearGradient( - colors: [.accentColor.opacity(0.08), .accentColor.opacity(0.02), .clear], - startPoint: .top, - endPoint: .bottom - ) - } + GlassEffectContainer { + VStack(spacing: 0) { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + footer } - ) + .background( + ZStack { + Color(NSColor.windowBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15) + if viewModel.state == .scanning || viewModel.state == .executing { + LinearGradient( + colors: [.accentColor.opacity(0.08), .accentColor.opacity(0.02), .clear], + startPoint: .top, + endPoint: .bottom + ) + } + } + ) + } } @ViewBuilder @@ -777,36 +780,18 @@ public struct CleanupView: View { Button("reset".localized) { viewModel.reset() } + .glassButtonStyle() .keyboardShortcut(.cancelAction) Button("cleanup_now".localized) { viewModel.executeCleanup() } - .buttonStyle(.borderedProminent) + .glassButtonStyle() .keyboardShortcut(.defaultAction) .disabled(viewModel.selectedSizeBytes == 0) } } .padding() - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) - } -} - -// Glassmorphism blur effect helper -struct VisualEffectView: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.material = material - view.blendingMode = blendingMode - view.state = .active - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) { - nsView.material = material - nsView.blendingMode = blendingMode + .glassEffect() } } diff --git a/MacOSCleaner/Features/Processes/ProcessesView.swift b/MacOSCleaner/Features/Processes/ProcessesView.swift index 8e09613..b76f780 100644 --- a/MacOSCleaner/Features/Processes/ProcessesView.swift +++ b/MacOSCleaner/Features/Processes/ProcessesView.swift @@ -1,54 +1,54 @@ import SwiftUI public struct ProcessesView: View { - @State private var viewModel: ProcessesViewModel + @State private var viewModel = ProcessesViewModel() @State private var isEditMode = false - - public init(viewModel: ProcessesViewModel = ProcessesViewModel()) { - _viewModel = State(initialValue: viewModel) - } + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency public var body: some View { - VStack(spacing: 0) { - header - - HStack(spacing: 12) { - searchField - Spacer() - Text("\(viewModel.filteredProcesses.count)/\(viewModel.processes.count)") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal) - .padding(.vertical, 8) + GlassEffectContainer { + VStack(spacing: 0) { + header + + HStack(spacing: 12) { + searchField + Spacer() + Text("\(viewModel.filteredProcesses.count)/\(viewModel.processes.count)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 8) - if isEditMode { - selectionToolbar - } + if isEditMode { + selectionToolbar + .transition(.move(edge: .top).combined(with: .opacity)) + } - if viewModel.isLoading { - AnimatedScanView( - title: "processes_scanning".localized, - subtitle: "", - currentStep: 0, - totalSteps: 1 - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = viewModel.lastError { - errorView(error) - } else if viewModel.filteredProcesses.isEmpty && !viewModel.searchText.isEmpty { - emptySearchView - } else if viewModel.processes.isEmpty { - emptyView - } else { - if viewModel.viewMode == .grouped { - groupedProcessList + if viewModel.isLoading { + AnimatedScanView( + title: "processes_scanning".localized, + subtitle: "", + currentStep: 0, + totalSteps: 1 + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = viewModel.lastError { + errorView(error) + } else if viewModel.filteredProcesses.isEmpty && !viewModel.searchText.isEmpty { + emptySearchView + } else if viewModel.processes.isEmpty { + emptyView } else { - flatProcessList + if viewModel.viewMode == .grouped { + groupedProcessList + } else { + flatProcessList + } } } + .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15)) } - .background(Color(NSColor.controlBackgroundColor)) .alert( "processes_confirm_terminate".localized, isPresented: Binding( @@ -157,7 +157,7 @@ public struct ProcessesView: View { Image(systemName: "ellipsis.circle") .font(.system(size: 14, weight: .semibold)) } - .buttonStyle(.bordered) + .glassButtonStyle() Button(action: { viewModel.showBlacklistAlert = true }) { HStack(spacing: 4) { @@ -169,7 +169,7 @@ public struct ProcessesView: View { } } } - .buttonStyle(.bordered) + .glassButtonStyle() .help("processes_tooltip_blacklist".localized) Button(action: { viewModel.showWhitelistAlert = true }) { @@ -182,18 +182,17 @@ public struct ProcessesView: View { } } } - .buttonStyle(.bordered) + .glassButtonStyle() .help("processes_tooltip_whitelist".localized) Button(action: { Task { await viewModel.scan() } }) { Image(systemName: "arrow.clockwise") .font(.system(size: 14, weight: .semibold)) } - .buttonStyle(.bordered) + .glassButtonStyle() .help("processes_tooltip_refresh".localized) } .padding() - .background(Color(NSColor.windowBackgroundColor)) } private var selectionToolbar: some View { @@ -201,12 +200,12 @@ public struct ProcessesView: View { Button(action: viewModel.selectAll) { Label("select_all".localized, systemImage: "checkmark.circle") } - .buttonStyle(.bordered) + .glassButtonStyle() Button(action: viewModel.deselectAll) { Label("deselect_all".localized, systemImage: "circle") } - .buttonStyle(.bordered) + .glassButtonStyle() Spacer() @@ -217,18 +216,17 @@ public struct ProcessesView: View { Button(action: { Task { await viewModel.terminateSelected() } }) { Label("terminate_selected".localized, systemImage: "xmark.circle") } - .buttonStyle(.bordered) + .glassButtonStyle() .disabled(viewModel.selection.isEmpty) Button(role: .destructive, action: { Task { await viewModel.forceKillSelected() } }) { Label("force_kill_selected".localized, systemImage: "exclamationmark.triangle") } - .buttonStyle(.bordered) + .glassButtonStyle() .disabled(viewModel.selection.isEmpty) } .padding(.horizontal) .padding(.vertical, 8) - .background(Color(NSColor.windowBackgroundColor)) } private var searchField: some View { @@ -255,9 +253,7 @@ public struct ProcessesView: View { } .padding(.horizontal) .padding(.vertical, 8) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .glassEffect() .padding() } } @@ -272,9 +268,7 @@ public struct ProcessesView: View { } .padding(.horizontal) .padding(.vertical, 8) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .glassEffect() .padding() } } diff --git a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift index 47d1949..bf6aa49 100644 --- a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift +++ b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift @@ -1,33 +1,32 @@ import SwiftUI public struct StartupServicesView: View { - @State private var viewModel: StartupServicesViewModel - - public init(viewModel: StartupServicesViewModel = StartupServicesViewModel()) { - _viewModel = State(initialValue: viewModel) - } + @State private var viewModel = StartupServicesViewModel() + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency public var body: some View { - VStack(spacing: 0) { - header + GlassEffectContainer { + VStack(spacing: 0) { + header - if viewModel.isLoading { - AnimatedScanView( - title: "startup_scanning".localized, - subtitle: "", - currentStep: 0, - totalSteps: 1 - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = viewModel.lastError { - errorView(error) - } else if viewModel.services.isEmpty { - emptyView - } else { - serviceList + if viewModel.isLoading { + AnimatedScanView( + title: "startup_scanning".localized, + subtitle: "", + currentStep: 0, + totalSteps: 1 + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = viewModel.lastError { + errorView(error) + } else if viewModel.services.isEmpty { + emptyView + } else { + serviceList + } } + .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15)) } - .background(Color(NSColor.controlBackgroundColor)) .onAppear { Task { await viewModel.scan() } } } @@ -47,14 +46,13 @@ public struct StartupServicesView: View { Image(systemName: "arrow.clockwise") .font(.system(size: 14, weight: .semibold)) } - .buttonStyle(.bordered) + .glassButtonStyle() .help("startup_refresh".localized) } filterPicker } .padding() - .background(Color(NSColor.windowBackgroundColor)) } private var filterPicker: some View { @@ -153,9 +151,7 @@ public struct StartupServicesView: View { } .padding(.horizontal) .padding(.vertical, 8) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .glassEffect() .padding() } } diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift index 14fabca..20cf281 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift @@ -10,6 +10,7 @@ private extension Logger { struct UninstallerView: View { let settings: AppSettings let navigateToCleanup: () -> Void + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @State private var service = UninstallerService() @State private var allApps: [UninstallerService.AppInfo] = [] @State private var selectedApp: UninstallerService.AppInfo? @@ -37,72 +38,75 @@ struct UninstallerView: View { } var body: 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) + 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)) } - .padding(.vertical, 6) - .background(Color(NSColor.controlBackgroundColor)) - } - List(filteredApps) { app in - let unscan = app.scanState != .deepScanned - AppRowView( - app: app, - formatter: formatter, - showRelatedFiles: settings.showRelatedFiles, - isUnscannable: unscan - ) - .contentShape(Rectangle()) - .onTapGesture { - guard app.scanState == .deepScanned else { return } - selectedApp = app + List(filteredApps) { app in + let unscan = app.scanState != .deepScanned + AppRowView( + app: app, + formatter: formatter, + showRelatedFiles: settings.showRelatedFiles, + isUnscannable: unscan + ) + .contentShape(Rectangle()) + .onTapGesture { + guard app.scanState == .deepScanned else { return } + selectedApp = app + } + .listRowBackground( + selectedApp?.url == app.url + ? Color.accentColor.opacity(0.1) + : Color.clear + ) } - .listRowBackground( - selectedApp?.url == app.url - ? Color.accentColor.opacity(0.1) - : Color.clear - ) + .listStyle(.inset) } - .listStyle(.inset) } } - } - .frame(width: max(250, geometry.size.width * 0.3)) // 30% width but min 250 - .background(Color(NSColor.controlBackgroundColor)) - - Divider() - - // Detail Area - ZStack { - if let app = selectedApp { - appDetailView(app) - .frame(maxWidth: .infinity) - } else { - dropZoneView - .frame(maxWidth: .infinity) + .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 let app = selectedApp { + appDetailView(app) + .frame(maxWidth: .infinity) + } else { + dropZoneView + .frame(maxWidth: .infinity) + } } + .layoutPriority(1) // Occupy remaining space } - .layoutPriority(1) // Occupy remaining space } + .background(Color(NSColor.windowBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.1)) } .navigationTitle("uninstaller_title".localized) .searchable(text: $searchText, placement: .toolbar, prompt: "uninstaller_search".localized) @@ -132,6 +136,7 @@ struct UninstallerView: View { Button(action: loadApps) { Image(systemName: "arrow.clockwise") } + .glassButtonStyle() .help("uninstaller_reload".localized) } } @@ -393,8 +398,7 @@ struct UninstallerView: View { .frame(maxWidth: 300) .frame(height: 32) } - .buttonStyle(.borderedProminent) - .tint(.red) + .destructiveGlassButtonStyle() .controlSize(.large) } } @@ -422,12 +426,7 @@ struct UninstallerView: View { ) } } - .background(Color(NSColor.alternatingContentBackgroundColors[0])) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) - ) + .glassEffect() } } } @@ -499,12 +498,7 @@ struct UninstallerView: View { } .padding(.horizontal, 12) .padding(.vertical, 10) - .background(Color.purple.opacity(0.04)) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.purple.opacity(0.2), lineWidth: 1) - ) + .glassEffect(.regular.tint(.purple)) } HStack { @@ -515,7 +509,7 @@ struct UninstallerView: View { Button("uninstaller_open_cleanup".localized) { navigateToCleanup() } - .buttonStyle(.borderedProminent) + .glassButtonStyle() .controlSize(.small) } } diff --git a/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift b/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift index 69e101c..c3aa1f4 100644 --- a/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift +++ b/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift @@ -47,4 +47,91 @@ public extension View { self } } + +public struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + public init(material: NSVisualEffectView.Material, blendingMode: NSVisualEffectView.BlendingMode) { + self.material = material + self.blendingMode = blendingMode + } + + public func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + public func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } +} #endif + +public extension View { + @ViewBuilder + func glassButtonStyle() -> some View { + if #available(macOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + + func destructiveGlassButtonStyle() -> some View { + self.buttonStyle(DestructiveGlassButtonStyle()) + } +} + +public struct DestructiveGlassButtonStyle: ButtonStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + DestructiveGlassButton(configuration: configuration) + } +} + +private struct DestructiveGlassButton: View { + let configuration: ButtonStyle.Configuration + @State private var isHovered = false + + var body: some View { + configuration.label + .foregroundColor(.white) + .padding(.vertical, 8) + .frame(maxWidth: 300) + .background( + ZStack { + if #available(macOS 26.0, *) { + #if hasFeature(LiquidGlass) + Color.clear.glassEffect(.regular.tint(isHovered ? .red : Color(white: 0.15)).interactive()) + #else + Color.clear.background(.ultraThinMaterial) + #endif + } else { + Color.clear.background(.ultraThinMaterial) + } + + if isHovered { + Color.red.opacity(configuration.isPressed ? 0.35 : 0.2) + } else { + Color(white: 0.1).opacity(configuration.isPressed ? 0.5 : 0.3) + } + } + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isHovered ? Color.red.opacity(0.6) : Color.white.opacity(0.12), lineWidth: 1.5) + ) + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.easeInOut(duration: 0.15), value: isHovered) + .onHover { hovering in + isHovered = hovering + } + } +} diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index 6bfd8f3..e1faa33 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -50,6 +50,7 @@ "about_developer" = "Developed by AlexTkDev"; "about_problem_link" = "If you have a problem with the app, let me know here"; "about_linkedin" = "LinkedIn Profile"; +"about_website" = "Website"; "about_copyright" = "© 2026 AlexTkDev. All rights reserved."; /* Dashboard View */ @@ -126,6 +127,9 @@ "update.available" = "Version %@ is available"; "update.download" = "Download on GitHub"; "update.up_to_date" = "Up to date"; +"update.up_to_date_message" = "You are using the latest version of the application."; +"update.releases_label" = "Releases:"; +"update.website_label" = "Website:"; "update.checking" = "Checking..."; /* Settings Tooltips */ diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index 334a060..05dba03 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -50,6 +50,7 @@ "about_developer" = "Desarrollado por AlexTkDev"; "about_problem_link" = "Si tiene un problema con la aplicación, avíseme aquí"; "about_linkedin" = "Perfil de LinkedIn"; +"about_website" = "Sitio web"; "about_copyright" = "© 2026 AlexTkDev. Todos los derechos reservados."; // Dashboard View @@ -126,6 +127,9 @@ "update.available" = "Versión %@ disponible"; "update.download" = "Descargar en GitHub"; "update.up_to_date" = "Versión actual"; +"update.up_to_date_message" = "Está utilizando la última versión de la aplicación."; +"update.releases_label" = "Lanzamientos:"; +"update.website_label" = "Sitio web:"; "update.checking" = "Comprobando..."; // Settings Tooltips diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index 3243e4b..f99e140 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -50,6 +50,7 @@ "about_developer" = "Разработчик: AlexTkDev"; "about_problem_link" = "Если возникла проблема с приложением, сообщите мне здесь"; "about_linkedin" = "Профиль LinkedIn"; +"about_website" = "Веб-сайт"; "about_copyright" = "© 2026 AlexTkDev. Все права защищены."; /* Dashboard View */ @@ -126,6 +127,9 @@ "update.available" = "Доступна версия %@"; "update.download" = "Скачать на GitHub"; "update.up_to_date" = "Актуальная версия"; +"update.up_to_date_message" = "Вы используете актуальную версию приложения."; +"update.releases_label" = "Релизы:"; +"update.website_label" = "Веб-сайт:"; "update.checking" = "Проверяем..."; /* Settings Tooltips */ diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index 41aa3d3..f8b4c58 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -50,6 +50,7 @@ "about_developer" = "Розробник: AlexTkDev"; "about_problem_link" = "Якщо виникла проблема з додатком, повідомте мені тут"; "about_linkedin" = "Профіль LinkedIn"; +"about_website" = "Веб-сайт"; "about_copyright" = "© 2026 AlexTkDev. Усі права захищені."; /* Dashboard View */ @@ -126,6 +127,9 @@ "update.available" = "Доступна версія %@"; "update.download" = "Завантажити на GitHub"; "update.up_to_date" = "Актуальна версія"; +"update.up_to_date_message" = "Ви використовуєте актуальну версію програми."; +"update.releases_label" = "Релізи:"; +"update.website_label" = "Веб-сайт:"; "update.checking" = "Перевіряємо..."; /* Settings Tooltips */ From 52f3d6afd1da99a9e02eee68f30bd93ab1a67d99 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Thu, 16 Jul 2026 15:23:10 +0300 Subject: [PATCH 05/23] feat: add localized message and prompt to disk analyzer folder selection dialog --- MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift | 2 ++ MacOSCleaner/Resources/en.lproj/Localizable.strings | 1 + MacOSCleaner/Resources/es.lproj/Localizable.strings | 1 + MacOSCleaner/Resources/ru.lproj/Localizable.strings | 1 + MacOSCleaner/Resources/uk.lproj/Localizable.strings | 1 + 5 files changed, 6 insertions(+) diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift index 7c67aa4..18703e3 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift @@ -43,6 +43,8 @@ public final class DiskAnalyzerViewModel { panel.canChooseFiles = false panel.allowsMultipleSelection = false panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser + panel.message = "disk_analyzer_select_folder".localized + panel.prompt = "disk_analyzer_scan".localized if panel.runModal() == .OK, let url = panel.url { startScan(for: url) diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index e1faa33..2192ac5 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -34,6 +34,7 @@ "disk_analyzer_select_folder" = "Select Folder to Scan"; "disk_analyzer_empty" = "Folder is empty or not scanned yet"; "disk_analyzer_no_permissions" = "No access permissions for this folder"; +"folder" = "Folder"; "disk_analyzer_category_all" = "All"; "disk_analyzer_category_video" = "Video"; "disk_analyzer_category_audio" = "Audio"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index 05dba03..a8e6940 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -34,6 +34,7 @@ "disk_analyzer_select_folder" = "Seleccionar carpeta para escanear"; "disk_analyzer_empty" = "La carpeta está vacía o no ha sido escaneada"; "disk_analyzer_no_permissions" = "Sin permisos de acceso para esta carpeta"; +"folder" = "Carpeta"; "disk_analyzer_category_all" = "Todo"; "disk_analyzer_category_video" = "Vídeo"; "disk_analyzer_category_audio" = "Audio"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index f99e140..e3ca7d5 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -34,6 +34,7 @@ "disk_analyzer_select_folder" = "Выбрать папку для сканирования"; "disk_analyzer_empty" = "Папка пуста или еще не просканирована"; "disk_analyzer_no_permissions" = "Нет прав доступа к этой папке"; +"folder" = "Папка"; "disk_analyzer_category_all" = "Все"; "disk_analyzer_category_video" = "Видео"; "disk_analyzer_category_audio" = "Аудио"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index f8b4c58..b50f7aa 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -34,6 +34,7 @@ "disk_analyzer_select_folder" = "Вибрати папку для сканирования"; "disk_analyzer_empty" = "Папка порожня або ще не просканована"; "disk_analyzer_no_permissions" = "Немає прав доступу до цієї папки"; +"folder" = "Папка"; "disk_analyzer_category_all" = "Все"; "disk_analyzer_category_video" = "Відео"; "disk_analyzer_category_audio" = "Аудіо"; From d14b4ad77c3f2e719e8ec71bae3e3b04ade14ace Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Thu, 16 Jul 2026 15:58:00 +0300 Subject: [PATCH 06/23] feat: implement AIExplanationService to provide context for cleanup items, processes, and disk files using local language models --- MacOSCleaner/App/RootView.swift | 6 +- .../Features/Cleanup/CleanupView.swift | 179 +++++++--- .../Features/Cleanup/CleanupViewModel.swift | 3 + .../Features/Dashboard/DashboardView.swift | 4 - .../DiskAnalyzer/DiskAnalyzerView.swift | 211 ++++++++---- .../Features/Processes/ProcessRow.swift | 82 +++++ .../Features/Processes/ProcessesView.swift | 8 +- .../Features/Settings/AppSettings.swift | 12 +- .../Features/Settings/SettingsView.swift | 56 ++++ .../StartupServices/StartupServicesView.swift | 126 +++++-- .../Uninstaller/AIExplanationService.swift | 179 ++++++++++ .../Uninstaller/UninstallerView.swift | 311 ++++++++++++++---- .../MacOSCleaner.xcodeproj/project.pbxproj | 16 +- .../AIExplanationServiceTests.swift | 114 +++++++ .../MacOSCleanerTests/AppSettingsTests.swift | 17 +- .../Resources/en.lproj/Localizable.strings | 17 + .../Resources/es.lproj/Localizable.strings | 17 + .../Resources/ru.lproj/Localizable.strings | 17 + .../Resources/uk.lproj/Localizable.strings | 17 + MacOSCleaner/project.yml | 4 +- README.md | 29 +- 21 files changed, 1189 insertions(+), 236 deletions(-) create mode 100644 MacOSCleaner/Features/Uninstaller/AIExplanationService.swift create mode 100644 MacOSCleaner/MacOSCleanerTests/AIExplanationServiceTests.swift diff --git a/MacOSCleaner/App/RootView.swift b/MacOSCleaner/App/RootView.swift index c67aeae..376ab1b 100644 --- a/MacOSCleaner/App/RootView.swift +++ b/MacOSCleaner/App/RootView.swift @@ -48,11 +48,11 @@ struct RootView: View { case .cleanup: CleanupView(viewModel: cleanupViewModel) case .diskSpace: - DiskAnalyzerView() + DiskAnalyzerView(settings: appSettings) case .processes: - ProcessesView() + ProcessesView(settings: appSettings) case .startupServices: - StartupServicesView() + StartupServicesView(settings: appSettings) case .uninstaller: UninstallerView(settings: appSettings, navigateToCleanup: { selectedItem = .cleanup }) case .settings: diff --git a/MacOSCleaner/Features/Cleanup/CleanupView.swift b/MacOSCleaner/Features/Cleanup/CleanupView.swift index 5110d82..2236abc 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupView.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupView.swift @@ -549,8 +549,10 @@ public struct CleanupView: View { set: { _ in viewModel.toggleCategoryExpansion(category.id) } )) { ForEach(viewModel.visibleItems(for: category.id)) { item in - fileRow(item) - .padding(.leading, 8) + CleanupFileRow(item: item, settings: viewModel.settings) { + viewModel.toggleSelection(for: item.id) + } + .padding(.leading, 8) } if viewModel.hasMoreItems(category.id) { Button { @@ -573,49 +575,7 @@ public struct CleanupView: View { ) } - // MARK: - File Row - - private func fileRow(_ item: CleanupPreviewItem) -> some View { - HStack(spacing: 8) { - if item.isDeletable { - Image(systemName: item.isSelected ? "checkmark.square.fill" : "square") - .font(.system(size: 12)) - .foregroundColor(item.isSelected ? .accentColor : .secondary) - .frame(width: 18, height: 18) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.toggleSelection(for: item.id) - } - } else { - Image(systemName: "info.circle") - .font(.system(size: 12)) - .foregroundColor(.secondary.opacity(0.5)) - .frame(width: 18, height: 18) - } - - VStack(alignment: .leading, spacing: 2) { - Text(item.path ?? item.label) - .font(.system(.caption, design: .monospaced)) - .foregroundColor(.primary) - .lineLimit(1) - .truncationMode(.middle) - - if let date = item.modificationDate { - Text(date.formatted(.dateTime.day().month().year().locale(LanguageManager.shared.currentLocale))) - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - } - - Spacer() - - Text(item.sizeBytes.formattedByteCount()) - .font(.system(.caption, design: .monospaced)) - .foregroundColor(.secondary) - } - .padding(.vertical, 2) - } - + // MARK: - Risk Badge private func riskBadge(for risk: OperationRisk) -> some View { Text(risk.localizedTitle.uppercased()) .font(.system(size: 9, weight: .bold)) @@ -795,3 +755,132 @@ public struct CleanupView: View { .glassEffect() } } + +struct CleanupFileRow: View { + let item: CleanupPreviewItem + let settings: AppSettings + let onToggleSelection: () -> Void + + @State private var isExpanded = false + @State private var aiExplanation = "" + @State private var isGenerating = false + @State private var errorMessage: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + if item.isDeletable { + Image(systemName: item.isSelected ? "checkmark.square.fill" : "square") + .font(.system(size: 12)) + .foregroundColor(item.isSelected ? .accentColor : .secondary) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + .onTapGesture { + onToggleSelection() + } + } else { + Image(systemName: "info.circle") + .font(.system(size: 12)) + .foregroundColor(.secondary.opacity(0.5)) + .frame(width: 18, height: 18) + } + + VStack(alignment: .leading, spacing: 2) { + Text(item.path ?? item.label) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.middle) + + if let date = item.modificationDate { + Text(date.formatted(.dateTime.day().month().year().locale(LanguageManager.shared.currentLocale))) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + + Spacer() + + if settings.enableAI && AIExplanationService.shared.isAvailable, let path = item.path { + Button { + withAnimation(.spring()) { + isExpanded.toggle() + } + if isExpanded && aiExplanation.isEmpty && !isGenerating { + generateAIExplanation(path: path) + } + } label: { + Image(systemName: "sparkles") + .foregroundColor(isExpanded ? .purple : .secondary) + } + .buttonStyle(.plain) + .help("uninstaller_explain_with_ai".localized) + } + + Text(item.sizeBytes.formattedByteCount()) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + } + .padding(.vertical, 2) + + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + Divider().padding(.vertical, 4) + HStack(alignment: .top, spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(.purple) + .font(.caption) + + if isGenerating { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("uninstaller_ai_explaining".localized) + .font(.caption) + .foregroundColor(.secondary) + } + } else if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundColor(.red) + } else { + Text(aiExplanation) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.leading, 26) + .padding(.bottom, 4) + } + } + } + + private func generateAIExplanation(path: String) { + isGenerating = true + errorMessage = nil + + let lang = settings.language + Task { + do { + let result = try await AIExplanationService.shared.explainCleanupFile( + fileName: item.label, + filePath: path, + category: item.category ?? "Cache/Temporary Data", + sizeFormatted: item.sizeBytes.formattedByteCount(), + language: lang + ) + + await MainActor.run { + self.aiExplanation = result + self.isGenerating = false + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isGenerating = false + } + } + } + } +} diff --git a/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift b/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift index 2823646..094b7dc 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupViewModel.swift @@ -29,7 +29,10 @@ public final class CleanupViewModel { public var selectedItem: CleanupPreviewItem? { itemManager.selectedItem } public var expandedCategoryIds: Set { itemManager.expandedCategoryIds } + public let settings: AppSettings + public init(engine: CleanupEngine, journal: TransactionJournal, settings: AppSettings, trashManager: TrashManager = TrashManager()) { + self.settings = settings let itemManager = CleanupItemManager() self.itemManager = itemManager self.coordinator = CleanupCoordinator( diff --git a/MacOSCleaner/Features/Dashboard/DashboardView.swift b/MacOSCleaner/Features/Dashboard/DashboardView.swift index 45e7444..336c6f0 100644 --- a/MacOSCleaner/Features/Dashboard/DashboardView.swift +++ b/MacOSCleaner/Features/Dashboard/DashboardView.swift @@ -13,10 +13,6 @@ struct DashboardView: View { GlassEffectContainer { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text("dashboard_title".localized) - .font(.largeTitle) - .fontWeight(.bold) - HStack(alignment: .top, spacing: 20) { diskUsageCard rightColumn diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift index f2a4f97..9d3f7ce 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift @@ -1,10 +1,13 @@ import SwiftUI public struct DiskAnalyzerView: View { + let settings: AppSettings @State private var viewModel = DiskAnalyzerViewModel() @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - public init() {} + public init(settings: AppSettings) { + self.settings = settings + } public var body: some View { GlassEffectContainer { @@ -134,7 +137,7 @@ public struct DiskAnalyzerView: View { ScrollView { LazyVStack(spacing: 1) { ForEach(viewModel.filteredItems) { item in - DiskItemRow(item: item, onNavigate: { + DiskItemRow(item: item, settings: settings, onNavigate: { withAnimation { viewModel.navigateTo(item: item) } @@ -157,86 +160,168 @@ public struct DiskAnalyzerView: View { struct DiskItemRow: View { let item: DiskItem + let settings: AppSettings let onNavigate: () -> Void let onShowInFinder: () -> Void let onDelete: () -> Void @State private var isHovered = false @State private var showingDeleteConfirmation = false + @State private var isExpanded = false + @State private var aiExplanation = "" + @State private var isGenerating = false + @State private var errorMessage: String? = nil var body: some View { - HStack(spacing: 12) { - 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: 0) { + HStack(spacing: 12) { + Image(systemName: iconName) + .font(.title3) + .foregroundColor(iconColor) + .frame(width: 24, height: 24) - if item.isDirectory { - Text("folder".localized) - .font(.caption2) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + + if item.isDirectory { + Text("folder".localized) + .font(.caption2) + .foregroundColor(.secondary) + } } - } - - Spacer() - - Text(item.size.formattedByteCount()) - .font(.system(.body, design: .monospaced)) - .foregroundColor(.secondary) - .padding(.trailing, 8) - - if isHovered { - HStack(spacing: 4) { - Button(action: onShowInFinder) { - Image(systemName: "magnifyingglass") + + Spacer() + + if settings.enableAI && AIExplanationService.shared.isAvailable { + Button { + withAnimation(.spring()) { + isExpanded.toggle() + } + if isExpanded && aiExplanation.isEmpty && !isGenerating { + generateAIExplanation() + } + } label: { + Image(systemName: "sparkles") + .foregroundColor(isExpanded ? .purple : .secondary) } .buttonStyle(.plain) - .help("disk_analyzer_show_in_finder".localized) - - Button(action: { - showingDeleteConfirmation = true - }) { - Image(systemName: "trash") - .foregroundColor(.red) + .help("uninstaller_explain_with_ai".localized) + } + + Text(item.size.formattedByteCount()) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + .padding(.trailing, 8) + + if isHovered { + HStack(spacing: 4) { + Button(action: onShowInFinder) { + Image(systemName: "magnifyingglass") + } + .buttonStyle(.plain) + .help("disk_analyzer_show_in_finder".localized) + + Button(action: { + showingDeleteConfirmation = true + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.plain) + .help("disk_analyzer_move_to_trash".localized) } - .buttonStyle(.plain) - .help("disk_analyzer_move_to_trash".localized) + .transition(.opacity) } - .transition(.opacity) } - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(isHovered ? Color.secondary.opacity(0.1) : Color.clear) - ) - .onHover { hover in - withAnimation(.easeInOut(duration: 0.1)) { - isHovered = hover + .padding(.vertical, 8) + .padding(.horizontal, 12) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.secondary.opacity(0.1) : Color.clear) + ) + .onHover { hover in + withAnimation(.easeInOut(duration: 0.1)) { + isHovered = hover + } } - } - .onTapGesture { - if item.isDirectory { - onNavigate() + .onTapGesture { + if item.isDirectory { + onNavigate() + } + } + .confirmationDialog( + "disk_analyzer_delete_confirm".localized, + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("delete_action".localized, role: .destructive) { + onDelete() + } + Button("cancel".localized, role: .cancel) {} + } + + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + Divider().padding(.vertical, 4) + HStack(alignment: .top, spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(.purple) + .font(.caption) + + if isGenerating { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("uninstaller_ai_explaining".localized) + .font(.caption) + .foregroundColor(.secondary) + } + } else if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundColor(.red) + } else { + Text(aiExplanation) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.leading, 36) + .padding(.bottom, 4) } } - .confirmationDialog( - "disk_analyzer_delete_confirm".localized, - isPresented: $showingDeleteConfirmation, - titleVisibility: .visible - ) { - Button("delete_action".localized, role: .destructive) { - onDelete() + } + + private func generateAIExplanation() { + isGenerating = true + errorMessage = nil + + let lang = settings.language + Task { + do { + let result = try await AIExplanationService.shared.explainDiskFile( + fileName: item.name, + filePath: item.url.path, + sizeFormatted: item.size.formattedByteCount(), + fileType: item.fileType.localizedName, + language: lang + ) + + await MainActor.run { + self.aiExplanation = result + self.isGenerating = false + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isGenerating = false + } } - Button("cancel".localized, role: .cancel) {} } } diff --git a/MacOSCleaner/Features/Processes/ProcessRow.swift b/MacOSCleaner/Features/Processes/ProcessRow.swift index aadbc41..82fb15c 100644 --- a/MacOSCleaner/Features/Processes/ProcessRow.swift +++ b/MacOSCleaner/Features/Processes/ProcessRow.swift @@ -4,11 +4,16 @@ struct ProcessRow: View { let process: RunningProcess let permission: KillPermission let isSelected: Bool + let settings: AppSettings let onTerminate: () -> Void let onForceKill: () -> Void let onToggleSelection: () -> Void @State private var appIcon: NSImage? + @State private var isExpanded = false + @State private var aiExplanation = "" + @State private var isGenerating = false + @State private var errorMessage: String? = nil var body: some View { HStack(alignment: .center, spacing: 12) { @@ -119,6 +124,22 @@ struct ProcessRow: View { } } + if settings.enableAI && AIExplanationService.shared.isAvailable { + Button { + withAnimation(.spring()) { + isExpanded.toggle() + } + if isExpanded && aiExplanation.isEmpty && !isGenerating { + generateAIExplanation() + } + } label: { + Image(systemName: "sparkles") + .foregroundColor(isExpanded ? .purple : .secondary) + } + .buttonStyle(.plain) + .help("uninstaller_explain_with_ai".localized) + } + if case .blocked(let reason) = permission { Text(reason) .font(.caption) @@ -143,6 +164,37 @@ struct ProcessRow: View { .frame(width: 30) } } + + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + Divider().padding(.vertical, 4) + HStack(alignment: .top, spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(.purple) + .font(.caption) + + if isGenerating { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("uninstaller_ai_explaining".localized) + .font(.caption) + .foregroundColor(.secondary) + } + } else if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundColor(.red) + } else { + Text(aiExplanation) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.leading, 64) + .padding(.bottom, 4) + } } .padding(.vertical, 10) .task { @@ -170,4 +222,34 @@ struct ProcessRow: View { case .allowed: return .accentColor } } + + private func generateAIExplanation() { + isGenerating = true + errorMessage = nil + + let lang = settings.language + Task { + do { + let result = try await AIExplanationService.shared.explainProcess( + processName: process.name, + pid: process.pid, + filePath: process.path ?? "Unknown path", + cpuPercent: process.cpuPercent, + memoryFormatted: process.memoryFormatted, + uptimeFormatted: process.uptimeFormatted ?? "Unknown uptime", + language: lang + ) + + await MainActor.run { + self.aiExplanation = result + self.isGenerating = false + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isGenerating = false + } + } + } + } } diff --git a/MacOSCleaner/Features/Processes/ProcessesView.swift b/MacOSCleaner/Features/Processes/ProcessesView.swift index b76f780..e204606 100644 --- a/MacOSCleaner/Features/Processes/ProcessesView.swift +++ b/MacOSCleaner/Features/Processes/ProcessesView.swift @@ -1,10 +1,15 @@ import SwiftUI public struct ProcessesView: View { + let settings: AppSettings @State private var viewModel = ProcessesViewModel() @State private var isEditMode = false @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + public init(settings: AppSettings) { + self.settings = settings + } + public var body: some View { GlassEffectContainer { VStack(spacing: 0) { @@ -343,6 +348,7 @@ public struct ProcessesView: View { process: process, permission: viewModel.checkPermission(process), isSelected: viewModel.selection.contains(process.pid), + settings: settings, onTerminate: { viewModel.confirmKill = process }, onForceKill: { viewModel.confirmForceKill = process }, onToggleSelection: { viewModel.toggleSelection(process) } @@ -512,5 +518,5 @@ private struct AppIconView: View { } #Preview { - ProcessesView() + ProcessesView(settings: AppSettings()) } diff --git a/MacOSCleaner/Features/Settings/AppSettings.swift b/MacOSCleaner/Features/Settings/AppSettings.swift index 4da995c..5ffac14 100644 --- a/MacOSCleaner/Features/Settings/AppSettings.swift +++ b/MacOSCleaner/Features/Settings/AppSettings.swift @@ -1,7 +1,7 @@ import SwiftUI import Observation -public enum AppLanguage: String, CaseIterable, Identifiable { +public enum AppLanguage: String, CaseIterable, Identifiable, Sendable { case english = "en" case russian = "ru" case ukrainian = "uk" @@ -127,6 +127,7 @@ public final class AppSettings { static let processRefreshInterval = "settings_processRefreshInterval" static let processSortOption = "settings_processSortOption" static let uninstallerScanMode = "settings_uninstallerScanMode" + static let enableAI = "settings_enableAI" } // MARK: - General @@ -162,6 +163,10 @@ public final class AppSettings { didSet { UserDefaults.standard.set(autoScanOnStartup, forKey: Keys.autoScanOnStartup) } } + public var enableAI: Bool { + didSet { UserDefaults.standard.set(enableAI, forKey: Keys.enableAI) } + } + // MARK: - Cleanup public var emptyTrashDuringCleanup: Bool { @@ -220,6 +225,7 @@ public final class AppSettings { self.processRefreshInterval = RefreshInterval(rawValue: defaults.string(forKey: Keys.processRefreshInterval) ?? "") ?? .manual self.processSortOption = ProcessSortOption(rawValue: defaults.string(forKey: Keys.processSortOption) ?? "") ?? .cpu self.uninstallerScanMode = ScanMode(rawValue: defaults.string(forKey: Keys.uninstallerScanMode) ?? "") ?? .balanced + self.enableAI = defaults.object(forKey: Keys.enableAI) as? Bool ?? true LanguageManager.shared.setLanguage(lang) @@ -236,7 +242,8 @@ public final class AppSettings { Keys.language, Keys.theme, Keys.showNotifications, Keys.showTooltips, Keys.autoScanOnStartup, Keys.emptyTrashDuringCleanup, Keys.bypassTrashOnUninstall, Keys.showRelatedFiles, Keys.emptyTrashImmediately, - Keys.processRefreshInterval, Keys.processSortOption, Keys.uninstallerScanMode + Keys.processRefreshInterval, Keys.processSortOption, Keys.uninstallerScanMode, + Keys.enableAI ] for key in allKeys { defaults.removeObject(forKey: key) @@ -254,6 +261,7 @@ public final class AppSettings { processRefreshInterval = .manual processSortOption = .cpu uninstallerScanMode = .balanced + enableAI = true LanguageManager.shared.setLanguage(.english) } diff --git a/MacOSCleaner/Features/Settings/SettingsView.swift b/MacOSCleaner/Features/Settings/SettingsView.swift index 60dca86..ec96cae 100644 --- a/MacOSCleaner/Features/Settings/SettingsView.swift +++ b/MacOSCleaner/Features/Settings/SettingsView.swift @@ -1,5 +1,6 @@ import SwiftUI import UserNotifications +import FoundationModels // MARK: - SettingsView @@ -20,6 +21,7 @@ struct SettingsView: View { generalSection processesSection uninstallerSection + aiSection startupSection trashDeletionSection advancedSection @@ -267,6 +269,60 @@ struct SettingsView: View { } } + // MARK: - Apple Intelligence + + private var aiSection: some View { + Section { + Toggle("settings_enable_ai".localized, isOn: $settings.enableAI) + .tooltip("settings_tooltip_enable_ai".localized, enabled: settings.showTooltips) + + HStack { + Text("settings_ai_status".localized) + .foregroundStyle(.secondary) + Spacer() + aiStatusLabel + } + } header: { + Label("settings_ai_title".localized, systemImage: "sparkles") + } + } + + @ViewBuilder + private var aiStatusLabel: some View { + if !settings.enableAI { + Label("settings_ai_status_disabled".localized, systemImage: "slash.circle") + .foregroundStyle(.secondary) + .font(.caption) + } else { + let status = SystemLanguageModel.default.availability + switch status { + case .available: + Label("settings_ai_status_ready".localized, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption) + case .unavailable(let reason): + switch reason { + case .deviceNotEligible: + Label("settings_ai_status_unsupported_device".localized, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + case .appleIntelligenceNotEnabled: + Label("settings_ai_status_not_enabled".localized, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + .font(.caption) + case .modelNotReady: + Label("settings_ai_status_downloading".localized, systemImage: "arrow.down.circle.fill") + .foregroundStyle(.blue) + .font(.caption) + @unknown default: + Label("settings_ai_status_unavailable".localized, systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.caption) + } + } + } + } + // MARK: - Advanced private var advancedSection: some View { diff --git a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift index bf6aa49..abae309 100644 --- a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift +++ b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift @@ -1,6 +1,7 @@ import SwiftUI public struct StartupServicesView: View { + let settings: AppSettings @State private var viewModel = StartupServicesViewModel() @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @@ -141,7 +142,7 @@ public struct StartupServicesView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(viewModel.filteredServices) { service in - ServiceRow(service: service) { + ServiceRow(service: service, settings: settings) { Task { await viewModel.toggle(service: service) } } if service.id != viewModel.filteredServices.last?.id { @@ -197,36 +198,119 @@ public struct StartupServicesView: View { struct ServiceRow: View { let service: StartupService + let settings: AppSettings let onToggle: () -> Void + @State private var isExpanded = false + @State private var aiExplanation = "" + @State private var isGenerating = false + @State private var errorMessage: String? = nil + var body: some View { - HStack(alignment: .center, spacing: 12) { - CategoryBadge(category: service.category) + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 12) { + CategoryBadge(category: service.category) - VStack(alignment: .leading, spacing: 4) { - Text(service.name) - .font(.system(.body, design: .monospaced)) - .fontWeight(.medium) - Text(service.path) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } + VStack(alignment: .leading, spacing: 4) { + Text(service.name) + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + Text(service.path) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } - Spacer() + Spacer() - StatusBadge(isEnabled: service.isEnabled) + if settings.enableAI && AIExplanationService.shared.isAvailable { + Button { + withAnimation(.spring()) { + isExpanded.toggle() + } + if isExpanded && aiExplanation.isEmpty && !isGenerating { + generateAIExplanation() + } + } label: { + Image(systemName: "sparkles") + .foregroundColor(isExpanded ? .purple : .secondary) + } + .buttonStyle(.plain) + .help("uninstaller_explain_with_ai".localized) + } - Button(service.isEnabled ? "startup_disable".localized : "startup_enable".localized) { - onToggle() + StatusBadge(isEnabled: service.isEnabled) + + Button(service.isEnabled ? "startup_disable".localized : "startup_enable".localized) { + onToggle() + } + .buttonStyle(.bordered) + .tint(service.isEnabled ? .red : .accentColor) + .controlSize(.small) + } + + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + Divider().padding(.vertical, 4) + HStack(alignment: .top, spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(.purple) + .font(.caption) + + if isGenerating { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("uninstaller_ai_explaining".localized) + .font(.caption) + .foregroundColor(.secondary) + } + } else if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundColor(.red) + } else { + Text(aiExplanation) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.leading, 102) + .padding(.bottom, 4) } - .buttonStyle(.bordered) - .tint(service.isEnabled ? .red : .accentColor) - .controlSize(.small) } .padding(.vertical, 12) } + + private func generateAIExplanation() { + isGenerating = true + errorMessage = nil + + let lang = settings.language + Task { + do { + let result = try await AIExplanationService.shared.explainStartupService( + serviceName: service.name, + filePath: service.path, + category: service.category.displayName, + isEnabled: service.isEnabled, + language: lang + ) + + await MainActor.run { + self.aiExplanation = result + self.isGenerating = false + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isGenerating = false + } + } + } + } } struct CategoryBadge: View { @@ -273,5 +357,5 @@ struct StatusBadge: View { } #Preview { - StartupServicesView() + StartupServicesView(settings: AppSettings()) } diff --git a/MacOSCleaner/Features/Uninstaller/AIExplanationService.swift b/MacOSCleaner/Features/Uninstaller/AIExplanationService.swift new file mode 100644 index 0000000..2c939df --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/AIExplanationService.swift @@ -0,0 +1,179 @@ +import Foundation +import FoundationModels +import OSLog + +private extension Logger { + static let aiExplanation = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.macos-cleaner", category: "AIExplanationService") +} + +public actor AIExplanationService { + public static let shared = AIExplanationService() + + private init() {} + + public nonisolated var isAvailable: Bool { + SystemLanguageModel.default.availability == .available + } + + private func targetLanguageName(for language: AppLanguage) -> String { + switch language { + case .english: return "English" + case .russian: return "Russian" + case .ukrainian: return "Ukrainian" + case .spanish: return "Spanish" + } + } + + private func runSession(instructions: String, prompt: String) async throws -> String { + let session = LanguageModelSession(instructions: instructions) + do { + let response = try await session.respond(to: prompt) + return response.content.trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + Logger.aiExplanation.error("AI Generation failed: \(error.localizedDescription, privacy: .public)") + throw AIError.generationFailed(error.localizedDescription) + } + } + + public func explainRelation( + appName: String, + filePath: String, + evidence: [String], + deletionRisk: String, + language: AppLanguage + ) async throws -> String { + guard isAvailable else { throw AIError.notAvailable } + + let targetLanguage = targetLanguageName(for: language) + let instructions = "You are Antigravity AI, a macOS cleanup expert. Explain briefly (1-2 sentences) why this file belongs to the application and if it is safe to delete. You MUST respond in \(targetLanguage)." + let prompt = """ + Application: \(appName) + File Path: \(filePath) + Evidence: \(evidence.joined(separator: ", ")) + Deletion Risk: \(deletionRisk) + """ + + return try await runSession(instructions: instructions, prompt: prompt) + } + + public func explainApp( + appName: String, + bundleID: String, + sizeFormatted: String, + language: AppLanguage + ) async throws -> String { + guard isAvailable else { throw AIError.notAvailable } + + let targetLanguage = targetLanguageName(for: language) + let instructions = "You are Antigravity AI, a macOS expert. Explain briefly (1-2 sentences) what this application is, what it is typically used for, and if it is a core system application or third-party. You MUST respond in \(targetLanguage)." + let prompt = """ + Application: \(appName) + Bundle ID: \(bundleID) + Size: \(sizeFormatted) + """ + + return try await runSession(instructions: instructions, prompt: prompt) + } + + public func explainStartupService( + serviceName: String, + filePath: String, + category: String, + isEnabled: Bool, + language: AppLanguage + ) async throws -> String { + guard isAvailable else { throw AIError.notAvailable } + + let targetLanguage = targetLanguageName(for: language) + let instructions = "You are Antigravity AI, a macOS optimization expert. Explain briefly (1-2 sentences) what this startup service does and if it is safe to disable. You MUST respond in \(targetLanguage)." + let prompt = """ + Service Name: \(serviceName) + File Path: \(filePath) + Category: \(category) + Is Enabled: \(isEnabled ? "Yes" : "No") + """ + + return try await runSession(instructions: instructions, prompt: prompt) + } + + public func explainCleanupFile( + fileName: String, + filePath: String, + category: String, + sizeFormatted: String, + language: AppLanguage + ) async throws -> String { + guard isAvailable else { throw AIError.notAvailable } + + let targetLanguage = targetLanguageName(for: language) + let instructions = "You are Antigravity AI, a macOS cleanup expert. Explain briefly (1-2 sentences) what this cache or temporary file/folder is, its purpose, and if it is safe to delete. You MUST respond in \(targetLanguage)." + let prompt = """ + Item Name: \(fileName) + Path: \(filePath) + Category: \(category) + Size: \(sizeFormatted) + """ + + return try await runSession(instructions: instructions, prompt: prompt) + } + + public func explainProcess( + processName: String, + pid: Int32, + filePath: String, + cpuPercent: Double, + memoryFormatted: String, + uptimeFormatted: String, + language: AppLanguage + ) async throws -> String { + guard isAvailable else { throw AIError.notAvailable } + + let targetLanguage = targetLanguageName(for: language) + let instructions = "You are Antigravity AI, a macOS process optimization expert. Explain briefly (1-2 sentences) what this running process is, what application it belongs to, and if it is safe to terminate. You MUST respond in \(targetLanguage)." + let prompt = """ + Process Name: \(processName) + PID: \(pid) + Path: \(filePath) + CPU Usage: \(String(format: "%.1f", cpuPercent))% + Memory: \(memoryFormatted) + Uptime: \(uptimeFormatted) + """ + + return try await runSession(instructions: instructions, prompt: prompt) + } + + public func explainDiskFile( + fileName: String, + filePath: String, + sizeFormatted: String, + fileType: String, + language: AppLanguage + ) async throws -> String { + guard isAvailable else { throw AIError.notAvailable } + + let targetLanguage = targetLanguageName(for: language) + let instructions = "You are Antigravity AI, a macOS cleanup expert. Explain briefly (1-2 sentences) what this large file or folder is, why it might occupy space, and if it is safe to delete. You MUST respond in \(targetLanguage)." + let prompt = """ + File/Folder Name: \(fileName) + Path: \(filePath) + Size: \(sizeFormatted) + Category/Type: \(fileType) + """ + + return try await runSession(instructions: instructions, prompt: prompt) + } +} + +public enum AIError: Error, LocalizedError { + case notAvailable + case generationFailed(String) + + public var errorDescription: String? { + switch self { + case .notAvailable: + return "AI model is not available on this device" + case .generationFailed(let message): + return "Failed to generate explanation: \(message)" + } + } +} diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift index 20cf281..2305200 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift @@ -287,42 +287,17 @@ struct UninstallerView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { // Header - HStack(alignment: .top, spacing: 20) { - if let iconData = app.iconData, let nsImage = NSImage(data: iconData) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 80, height: 80) - } else { - RoundedRectangle(cornerRadius: 16) - .fill(Color.secondary.opacity(0.2)) - .frame(width: 80, height: 80) - .overlay(Image(systemName: "app").foregroundColor(.secondary)) - } - - VStack(alignment: .leading, spacing: 8) { - Text(app.name) - .font(.system(size: 28, weight: .bold)) - .lineLimit(2) - .minimumScaleFactor(0.8) - - Text(app.bundleID ?? "uninstaller_unknown_bundle".localized) - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(1) - - // Adaptive Badges + AppDetailHeaderView( + app: app, + settings: settings, + badges: { ViewThatFits(in: .horizontal) { - HStack(spacing: 8) { - badges(for: app) - } - VStack(alignment: .leading, spacing: 8) { - badges(for: app) - } + HStack(spacing: 8) { badges(for: app) } + VStack(alignment: .leading, spacing: 8) { badges(for: app) } } - .padding(.top, 4) } - } + ) + .id(app.id) Divider() @@ -421,6 +396,8 @@ struct UninstallerView: View { ForEach(files) { file in RelatedFileRow( file: file, + appName: app.name, + settings: settings, formatter: formatter, onToggle: { toggleSelection(file, in: app) } ) @@ -595,9 +572,16 @@ struct DetailBadge: View { struct RelatedFileRow: View { let file: UninstallerService.RelatedFile + let appName: String + let settings: AppSettings let formatter: ByteCountFormatter let onToggle: () -> Void + @State private var isExpanded = false + @State private var aiExplanation = "" + @State private var isGenerating = false + @State private var errorMessage: String? = nil + var riskColor: Color { let path = file.url.path if path.contains("Preferences") { return .orange } @@ -607,43 +591,250 @@ struct RelatedFileRow: View { } var body: some View { - HStack { - Toggle("", isOn: Binding(get: { file.isSelected }, set: { _ in onToggle() })) - .toggleStyle(.checkbox) + VStack(alignment: .leading, spacing: 0) { + HStack { + Toggle("", isOn: Binding(get: { file.isSelected }, set: { _ in onToggle() })) + .toggleStyle(.checkbox) - Image(systemName: "folder.fill") - .foregroundColor(riskColor.opacity(0.8)) + Image(systemName: "folder.fill") + .foregroundColor(riskColor.opacity(0.8)) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(file.url.lastPathComponent) - .font(.subheadline) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(file.url.lastPathComponent) + .font(.subheadline) + .lineLimit(1) + } + Text(file.url.path) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) .lineLimit(1) + .truncationMode(.middle) } - Text(file.url.path) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() + Spacer() - Button { - NSWorkspace.shared.activateFileViewerSelecting([file.url]) - } label: { - Image(systemName: "arrow.up.forward.app") - .foregroundColor(.accentColor) - } - .buttonStyle(.plain) - .help("uninstaller_show_in_finder".localized) + if settings.enableAI && AIExplanationService.shared.isAvailable { + Button { + withAnimation(.spring()) { + isExpanded.toggle() + } + if isExpanded && aiExplanation.isEmpty && !isGenerating { + generateAIExplanation() + } + } label: { + Image(systemName: "sparkles") + .foregroundColor(isExpanded ? .purple : .secondary) + } + .buttonStyle(.plain) + .help("uninstaller_explain_with_ai".localized) + } - Text(ByteCountFormatter.localizedString(fromByteCount: file.size, countStyle: .file)) - .font(.caption) - .foregroundColor(.secondary) + Button { + NSWorkspace.shared.activateFileViewerSelecting([file.url]) + } label: { + Image(systemName: "arrow.up.forward.app") + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .help("uninstaller_show_in_finder".localized) + + Text(ByteCountFormatter.localizedString(fromByteCount: file.size, countStyle: .file)) + .font(.caption) + .foregroundColor(.secondary) + } + + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + Divider().padding(.vertical, 4) + HStack(alignment: .top, spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(.purple) + .font(.caption) + + if isGenerating { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("uninstaller_ai_explaining".localized) + .font(.caption) + .foregroundColor(.secondary) + } + } else if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundColor(.red) + } else { + Text(aiExplanation) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.leading, 32) + .padding(.bottom, 4) + } } .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) } + + private func generateAIExplanation() { + isGenerating = true + errorMessage = nil + + let lang = settings.language + Task { + do { + let evidenceStrings = file.evidence.map { evidence -> String in + let explanation = EvidenceExplanations.explanation(for: evidence, args: appName) + return "\(explanation.title): \(explanation.description)" + } + + let result = try await AIExplanationService.shared.explainRelation( + appName: appName, + filePath: file.url.path, + evidence: evidenceStrings, + deletionRisk: file.deletionRisk.rawValue, + language: lang + ) + + await MainActor.run { + self.aiExplanation = result + self.isGenerating = false + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isGenerating = false + } + } + } + } +} + +struct AppDetailHeaderView: View { + let app: UninstallerService.AppInfo + let settings: AppSettings + @ViewBuilder let badges: BadgeContent + + @State private var isExpanded = false + @State private var aiExplanation = "" + @State private var isGenerating = false + @State private var errorMessage: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 20) { + if let iconData = app.iconData, let nsImage = NSImage(data: iconData) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 80, height: 80) + } else { + RoundedRectangle(cornerRadius: 16) + .fill(Color.secondary.opacity(0.2)) + .frame(width: 80, height: 80) + .overlay(Image(systemName: "app").foregroundColor(.secondary)) + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(app.name) + .font(.system(size: 28, weight: .bold)) + .lineLimit(2) + .minimumScaleFactor(0.8) + + if settings.enableAI && AIExplanationService.shared.isAvailable { + Button { + withAnimation(.spring()) { + isExpanded.toggle() + } + if isExpanded && aiExplanation.isEmpty && !isGenerating { + generateAIExplanation() + } + } label: { + Image(systemName: "sparkles") + .foregroundColor(isExpanded ? .purple : .secondary) + .font(.title2) + } + .buttonStyle(.plain) + .help("uninstaller_explain_with_ai".localized) + } + } + + Text(app.bundleID ?? "uninstaller_unknown_bundle".localized) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + + badges + .padding(.top, 4) + } + } + + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(.purple) + .font(.subheadline) + .padding(.top, 2) + + if isGenerating { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("uninstaller_ai_explaining".localized) + .font(.subheadline) + .foregroundColor(.secondary) + } + } else if let error = errorMessage { + Text(error) + .font(.subheadline) + .foregroundColor(.red) + } else { + Text(aiExplanation) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.all, 12) + .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) + .cornerRadius(12) + .padding(.top, 8) + } + } + } + + private func generateAIExplanation() { + isGenerating = true + errorMessage = nil + + let lang = settings.language + let sizeString = ByteCountFormatter.localizedString(fromByteCount: app.size, countStyle: .file) + + Task { + do { + let result = try await AIExplanationService.shared.explainApp( + appName: app.name, + bundleID: app.bundleID ?? "Unknown", + sizeFormatted: sizeString, + language: lang + ) + + await MainActor.run { + self.aiExplanation = result + self.isGenerating = false + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isGenerating = false + } + } + } + } } diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index a731094..de5c63b 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 513AE7B833D9E160B63C5761 /* ProbeCaches.swift in Sources */ = {isa = PBXBuildFile; fileRef = F107148C0A34FAB65F2D6093 /* ProbeCaches.swift */; }; 51555D0F365275801D2D72F6 /* UninstallerServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8008E88C0D03BB15C65998D /* UninstallerServiceTests.swift */; }; 546A45B9B4E4879BAD95CB97 /* CleanupEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBBE8FE6EC8F248C9C4E6B3 /* CleanupEngineTests.swift */; }; + 563EDE1CCFEABCA331B73FE8 /* AIExplanationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28BFF3789AF359896177DB28 /* AIExplanationServiceTests.swift */; }; 5665E32E91F5C6C213D3AAEA /* CleanupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF9A4348B3774A855AC83B1B /* CleanupViewModel.swift */; }; 56FB37504DA9E64D73AD4B3A /* DatabaseToolsRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11E52499AE453EB667A3BE9 /* DatabaseToolsRule.swift */; }; 5805C9625FA33C2762194266 /* MockCommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F77DB5F928748036C709D4 /* MockCommandRunner.swift */; }; @@ -136,6 +137,7 @@ BC7D6B5A297C83B50F760387 /* LaunchServiceManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F86A55A2FB7771CD468CEC /* LaunchServiceManagerTests.swift */; }; BC7F5459F452CC98A955FAEB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A917B441A9E588D76A1B96E /* SettingsView.swift */; }; BD2107BAF6691B18A67B1297 /* MicrosoftOfficeRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61AC424333F1C8F21BE5E13A /* MicrosoftOfficeRule.swift */; }; + BE001A911CF544C6C3749874 /* AIExplanationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80737533F19438ECBAE806ED /* AIExplanationService.swift */; }; BE3FBA22FA22D8E3A69F5A34 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 280CD515ADC8F97A79AD7B12 /* DashboardView.swift */; }; C11A27392992E4545E99913A /* BackgroundItemsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2204402B7AA52282DCCA42CA /* BackgroundItemsCache.swift */; }; C29BE4B2467ED58C79E26C05 /* EvidenceSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */; }; @@ -231,6 +233,7 @@ 26A12A1159CFA31A969B390C /* EpicGames.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = EpicGames.json; sourceTree = ""; }; 280CD515ADC8F97A79AD7B12 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 2854706813F7B138A3E1A17C /* EvidenceCategoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceCategoryTests.swift; sourceTree = ""; }; + 28BFF3789AF359896177DB28 /* AIExplanationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExplanationServiceTests.swift; sourceTree = ""; }; 29B6468D52060304776E6A48 /* CloudStorageRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudStorageRule.swift; sourceTree = ""; }; 2AEFAD66300B87C16924F4D7 /* CleanupModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupModels.swift; sourceTree = ""; }; 3245FE8F18E51D3347743E37 /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = ""; }; @@ -294,6 +297,7 @@ 78F806B7312BFC1297D8E172 /* DirectorySizeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectorySizeCache.swift; sourceTree = ""; }; 7B1DD590DCE0D34A3129D242 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = ""; }; 7BA3A02A464DC643AB220B58 /* JetBrainsRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetBrainsRule.swift; sourceTree = ""; }; + 80737533F19438ECBAE806ED /* AIExplanationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExplanationService.swift; sourceTree = ""; }; 817EB9AE4524A850962213CB /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 829209F31C2C64485ADD1F74 /* DiskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskItem.swift; sourceTree = ""; }; 8329098CAFECCA528C771F2F /* Unity.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Unity.json; sourceTree = ""; }; @@ -456,6 +460,7 @@ 4205BD74C801701EFF72A5ED /* MacOSCleanerTests */ = { isa = PBXGroup; children = ( + 28BFF3789AF359896177DB28 /* AIExplanationServiceTests.swift */, 50C1C6C0C2F943213529B9BC /* AppDiscoveryTests.swift */, 51B066CCFCE8479C82161FDF /* AppIdentityTests.swift */, 8526E6E523335A441DE17858 /* AppSettingsTests.swift */, @@ -567,6 +572,7 @@ 6BD0CE8DB3A2303D2E44D9CA /* Uninstaller */ = { isa = PBXGroup; children = ( + 80737533F19438ECBAE806ED /* AIExplanationService.swift */, 197DA9FDB4A034B4100A2AFB /* AppDiscovery.swift */, A3BC06C9BBE41099A9D54344 /* AppIdentity.swift */, 90B1C1E9B0FFC93988C69561 /* ApplicationRuleRegistry.swift */, @@ -886,6 +892,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 563EDE1CCFEABCA331B73FE8 /* AIExplanationServiceTests.swift in Sources */, 7D40740FE6271009DC9CE325 /* AppDiscoveryTests.swift in Sources */, E8FCD36679805EF63D4D8C94 /* AppIdentityTests.swift in Sources */, 3A9F6B68130F3DAF5916234B /* AppSettingsTests.swift in Sources */, @@ -930,6 +937,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + BE001A911CF544C6C3749874 /* AIExplanationService.swift in Sources */, 5D2C3AC6D520ED512CFDE893 /* AboutView.swift in Sources */, 338946DFC3F61C72FCC52D5E /* AdobeRule.swift in Sources */, E2A1FB396CE5BDACF63C9D9E /* AlfredRule.swift in Sources */, @@ -1109,7 +1117,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 15.5; + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; @@ -1204,7 +1212,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 15.5; + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleaner; SDKROOT = macosx; @@ -1228,7 +1236,7 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 15.5; + MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleanerTests; SDKROOT = macosx; SWIFT_VERSION = 6.0; @@ -1304,7 +1312,7 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 15.5; + MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = input.MacOSCleanerTests; SDKROOT = macosx; SWIFT_VERSION = 6.0; diff --git a/MacOSCleaner/MacOSCleanerTests/AIExplanationServiceTests.swift b/MacOSCleaner/MacOSCleanerTests/AIExplanationServiceTests.swift new file mode 100644 index 0000000..5344e6f --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/AIExplanationServiceTests.swift @@ -0,0 +1,114 @@ +import XCTest +import FoundationModels +@testable import MacOSCleaner + +final class AIExplanationServiceTests: XCTestCase { + + func testAvailabilityCheck() async { + let isAvailable = await AIExplanationService.shared.isAvailable + // Since we are running in a test suite, we check if availability returns the expected boolean + XCTAssertEqual(isAvailable, SystemLanguageModel.default.availability == .available) + } + + func testUnavailableThrowsError() async { + let isAvailable = await AIExplanationService.shared.isAvailable + if !isAvailable { + // Test explainRelation + do { + _ = try await AIExplanationService.shared.explainRelation( + appName: "TestApp", + filePath: "/tmp/test", + evidence: ["Test evidence"], + deletionRisk: "safe", + language: .english + ) + XCTFail("Should have thrown an error") + } catch let error as AIError { + XCTAssertEqual(error.localizedDescription, AIError.notAvailable.localizedDescription) + } catch { + XCTFail("Unexpected error: \(error)") + } + + // Test explainStartupService + do { + _ = try await AIExplanationService.shared.explainStartupService( + serviceName: "com.test.agent", + filePath: "/Library/LaunchAgents/com.test.agent.plist", + category: "system", + isEnabled: true, + language: .english + ) + XCTFail("Should have thrown an error") + } catch let error as AIError { + XCTAssertEqual(error.localizedDescription, AIError.notAvailable.localizedDescription) + } catch { + XCTFail("Unexpected error") + } + + // Test explainCleanupFile + do { + _ = try await AIExplanationService.shared.explainCleanupFile( + fileName: "Cache.db", + filePath: "/Users/test/Library/Caches/Cache.db", + category: "Caches", + sizeFormatted: "12 MB", + language: .english + ) + XCTFail("Should have thrown an error") + } catch let error as AIError { + XCTAssertEqual(error.localizedDescription, AIError.notAvailable.localizedDescription) + } catch { + XCTFail("Unexpected error") + } + + // Test explainProcess + do { + _ = try await AIExplanationService.shared.explainProcess( + processName: "testproc", + pid: 1234, + filePath: "/usr/local/bin/testproc", + cpuPercent: 1.5, + memoryFormatted: "45 MB", + uptimeFormatted: "10m", + language: .english + ) + XCTFail("Should have thrown an error") + } catch let error as AIError { + XCTAssertEqual(error.localizedDescription, AIError.notAvailable.localizedDescription) + } catch { + XCTFail("Unexpected error") + } + + // Test explainDiskFile + do { + _ = try await AIExplanationService.shared.explainDiskFile( + fileName: "Movie.mp4", + filePath: "/Users/test/Movies/Movie.mp4", + sizeFormatted: "1.2 GB", + fileType: "Video", + language: .english + ) + XCTFail("Should have thrown an error") + } catch let error as AIError { + XCTAssertEqual(error.localizedDescription, AIError.notAvailable.localizedDescription) + } catch { + XCTFail("Unexpected error") + } + + // Test explainApp + do { + _ = try await AIExplanationService.shared.explainApp( + appName: "TestApp", + bundleID: "com.test.app", + sizeFormatted: "150 MB", + language: .english + ) + XCTFail("Should have thrown an error") + } catch let error as AIError { + XCTAssertEqual(error.localizedDescription, AIError.notAvailable.localizedDescription) + } catch { + XCTFail("Unexpected error") + } + } + } +} diff --git a/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift b/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift index ce58041..7b54fd5 100644 --- a/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/AppSettingsTests.swift @@ -8,7 +8,8 @@ final class AppSettingsTests: XCTestCase { "settings_language", "settings_theme", "settings_showNotifications", "settings_showTooltips", "settings_autoScanOnStartup", "settings_emptyTrashDuringCleanup", "settings_bypassTrashOnUninstall", - "settings_showRelatedFiles", "settings_emptyTrashImmediately" + "settings_showRelatedFiles", "settings_emptyTrashImmediately", + "settings_enableAI" ] keysToReset.forEach { UserDefaults.standard.removeObject(forKey: $0) } } @@ -18,7 +19,8 @@ final class AppSettingsTests: XCTestCase { "settings_language", "settings_theme", "settings_showNotifications", "settings_showTooltips", "settings_autoScanOnStartup", "settings_emptyTrashDuringCleanup", "settings_bypassTrashOnUninstall", - "settings_showRelatedFiles", "settings_emptyTrashImmediately" + "settings_showRelatedFiles", "settings_emptyTrashImmediately", + "settings_enableAI" ] keysToReset.forEach { UserDefaults.standard.removeObject(forKey: $0) } } @@ -36,6 +38,7 @@ final class AppSettingsTests: XCTestCase { XCTAssertFalse(settings.bypassTrashOnUninstall) XCTAssertTrue(settings.showRelatedFiles) XCTAssertFalse(settings.emptyTrashImmediately) + XCTAssertTrue(settings.enableAI) } // MARK: - Persistence @@ -61,12 +64,14 @@ final class AppSettingsTests: XCTestCase { settings.bypassTrashOnUninstall = true settings.showRelatedFiles = false settings.emptyTrashImmediately = true - + settings.enableAI = false + XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_autoScanOnStartup")) XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_emptyTrashDuringCleanup")) XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_bypassTrashOnUninstall")) XCTAssertFalse(UserDefaults.standard.bool(forKey: "settings_showRelatedFiles")) XCTAssertTrue(UserDefaults.standard.bool(forKey: "settings_emptyTrashImmediately")) + XCTAssertFalse(UserDefaults.standard.bool(forKey: "settings_enableAI")) } // MARK: - Reset @@ -82,9 +87,10 @@ final class AppSettingsTests: XCTestCase { settings.bypassTrashOnUninstall = true settings.showRelatedFiles = false settings.emptyTrashImmediately = true - + settings.enableAI = false + settings.resetAll() - + XCTAssertEqual(settings.language, .english) XCTAssertEqual(settings.theme, .system) XCTAssertFalse(settings.showNotifications) @@ -94,5 +100,6 @@ final class AppSettingsTests: XCTestCase { XCTAssertFalse(settings.bypassTrashOnUninstall) XCTAssertTrue(settings.showRelatedFiles) XCTAssertFalse(settings.emptyTrashImmediately) + XCTAssertTrue(settings.enableAI) } } \ No newline at end of file diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index 2192ac5..b8fb273 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -602,3 +602,20 @@ /* Uninstaller Tooltips */ "uninstaller_show_in_finder" = "Show in Finder"; "uninstaller_used_by" = "Used by %@"; + +/* Apple Intelligence settings */ +"settings_ai_title" = "Apple Intelligence"; +"settings_enable_ai" = "Enable Local AI Explanations"; +"settings_tooltip_enable_ai" = "Use local on-device language models to explain associated files and startup services."; +"settings_ai_status" = "AI Status"; +"settings_ai_status_disabled" = "Disabled"; +"settings_ai_status_ready" = "Ready"; +"settings_ai_status_unsupported_device" = "Device Not Eligible"; +"settings_ai_status_not_enabled" = "Not Enabled in System Settings"; +"settings_ai_status_downloading" = "Downloading Model Assets..."; +"settings_ai_status_unavailable" = "Unavailable"; + +/* AI Explanations */ +"uninstaller_explain_with_ai" = "Explain with AI"; +"uninstaller_ai_explaining" = "Generating explanation..."; +"uninstaller_ai_failed" = "AI is not available or failed to generate description."; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index a8e6940..debbe8e 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -601,3 +601,20 @@ // OS Version "os_version_format" = "macOS %lld.%lld.%lld"; + +/* Apple Intelligence settings */ +"settings_ai_title" = "Apple Intelligence"; +"settings_enable_ai" = "Explicaciones locales de IA"; +"settings_tooltip_enable_ai" = "Utiliza modelos de lenguaje locales en el dispositivo para explicar archivos asociados y servicios de arranque."; +"settings_ai_status" = "Estado de IA"; +"settings_ai_status_disabled" = "Desactivado"; +"settings_ai_status_ready" = "Listo"; +"settings_ai_status_unsupported_device" = "Dispositivo no compatible"; +"settings_ai_status_not_enabled" = "No activado en Ajustes del Sistema"; +"settings_ai_status_downloading" = "Descargando modelo..."; +"settings_ai_status_unavailable" = "No disponible"; + +/* AI Explanations */ +"uninstaller_explain_with_ai" = "Explicar con IA"; +"uninstaller_ai_explaining" = "Generando explicación..."; +"uninstaller_ai_failed" = "La IA no está disponible o no pudo generar la explicación."; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index e3ca7d5..88349f5 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -610,3 +610,20 @@ /* OS Version */ "os_version_format" = "macOS %lld.%lld.%lld"; + +/* Apple Intelligence settings */ +"settings_ai_title" = "Apple Intelligence"; +"settings_enable_ai" = "Локальные ИИ-объяснения"; +"settings_tooltip_enable_ai" = "Использовать локальные языковые модели на устройстве для объяснения связанных файлов и служб автозагрузки."; +"settings_ai_status" = "Статус ИИ"; +"settings_ai_status_disabled" = "Отключен"; +"settings_ai_status_ready" = "Готов"; +"settings_ai_status_unsupported_device" = "Устройство не поддерживает"; +"settings_ai_status_not_enabled" = "Не включен в системных настройках"; +"settings_ai_status_downloading" = "Скачивание модели..."; +"settings_ai_status_unavailable" = "Недоступен"; + +/* AI Explanations */ +"uninstaller_explain_with_ai" = "Объяснить с помощью ИИ"; +"uninstaller_ai_explaining" = "Генерация объяснения..."; +"uninstaller_ai_failed" = "ИИ недоступен или не удалось создать описание."; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index b50f7aa..7bbf39f 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -605,3 +605,20 @@ /* OS Version */ "os_version_format" = "macOS %lld.%lld.%lld"; + +/* Apple Intelligence settings */ +"settings_ai_title" = "Apple Intelligence"; +"settings_enable_ai" = "Локальні ШІ-пояснення"; +"settings_tooltip_enable_ai" = "Використовувати локальні мовні моделі на пристрої для пояснення пов'язаних файлів і служб автозапуску."; +"settings_ai_status" = "Статус ШІ"; +"settings_ai_status_disabled" = "Вимкнено"; +"settings_ai_status_ready" = "Готовий"; +"settings_ai_status_unsupported_device" = "Пристрій не підтримує"; +"settings_ai_status_not_enabled" = "Не увімкнено в системних параметрах"; +"settings_ai_status_downloading" = "Завантаження моделі..."; +"settings_ai_status_unavailable" = "Недоступний"; + +/* AI Explanations */ +"uninstaller_explain_with_ai" = "Пояснити за допомогою ШІ"; +"uninstaller_ai_explaining" = "Генерація пояснення..."; +"uninstaller_ai_failed" = "ШІ недоступний або не вдалося створити опис."; diff --git a/MacOSCleaner/project.yml b/MacOSCleaner/project.yml index efdc98b..6c9febf 100644 --- a/MacOSCleaner/project.yml +++ b/MacOSCleaner/project.yml @@ -7,7 +7,7 @@ targets: MacOSCleaner: type: application platform: macOS - deploymentTarget: "15.5" + deploymentTarget: "26.0" sources: - App - Domains @@ -47,7 +47,7 @@ targets: MacOSCleanerTests: type: bundle.unit-test platform: macOS - deploymentTarget: "15.5" + deploymentTarget: "26.0" sources: - MacOSCleanerTests dependencies: diff --git a/README.md b/README.md index 5288a20..32130bd 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ ## Features +🧠 **Apple Intelligence** — Native local AI explanations powered by `FoundationModels` (requires macOS 26.0+). Operates fully offline, executing on the Neural Engine (Apple Silicon) with zero cost and maximum privacy. Explains files, caches, running processes, and startup agents in detail to help you make informed decisions. Prompts are optimized in English for higher model reasoning, with explanations output in your preferred UI language (English, Русский, Українська, Español). Fully toggleable in settings, featuring real-time model status tracking. + 🌍 **Fully Localized** — English, Русский, Українська, Español. All UI, errors, logs, and system info translated dynamically. Dates and byte counts format automatically for your language. **Dashboard** 📊 — redesigned with native macOS aesthetics: `controlBackgroundColor`, rounded cards, and SF Symbols. Disk usage chart, system info (model, CPU, RAM, macOS version), cleanup history and stats. @@ -144,34 +146,9 @@ Runs on Apple Silicon (M1–M5) with full parallelism — cleanup categories exe --- -## Project Structure - -``` -MacOSCleaner/ -├ App/ # Entry point, RootView, sidebar navigation -├ Domains/ -│ ├ Cleanup/ # Coordinator, Engine, StateMachine, ItemManager, Notifier, Models, EmbeddedCleanupPaths -│ ├ ProcessManagement/ # ProcessManager, ProcessSafetyPolicy -│ └ StartupServices/ # LaunchServiceManager -├ Features/ -│ ├ Dashboard/ # DashboardView + ViewModel -│ ├ Cleanup/ # CleanupView + ViewModel, AnimatedScanView -│ ├ Processes/ # ProcessesView + ViewModel, ProcessRow -│ ├ Settings/ # SettingsView, AppSettings, StartupVendorSettings -│ ├ Uninstaller/ # 30 application rules, forensics engine, caches, UI -│ ├ StartupServices/ # StartupServicesView + ViewModel -│ ├ Permissions/ # PermissionsView -│ └ About/ # AboutView -├ Infrastructure/ # CommandRunner, SafetyManager, TrashManager, LanguageManager, PosixScanner, actors -├ Models/ # CleanupItem, OperationRisk, RunningProcess, StartupService, etc. -└ Resources/ # Localizable.strings (en/ru/uk/es), assets -``` - ---- - ## Quick Start -**Requirements:** macOS 15.5+, Xcode 16+, [XcodeGen](https://github.com/yonaskolb/XcodeGen) +**Requirements:** macOS 26.0+, Xcode 18+, [XcodeGen](https://github.com/yonaskolb/XcodeGen) ```bash cd MacOSCleaner From 40c0c23bec1d398e0e5e82d98cb8319e81d5627e Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Thu, 16 Jul 2026 16:32:12 +0300 Subject: [PATCH 07/23] refactor: optimize disk scanning to perform flattened file lookup and update category models --- .../DiskAnalyzer/DiskAnalyzerView.swift | 50 ++--- .../DiskAnalyzer/DiskAnalyzerViewModel.swift | 70 +------ .../Features/DiskAnalyzer/DiskItem.swift | 12 +- .../Features/DiskAnalyzer/DiskScanner.swift | 171 ++++++------------ .../Permissions/PermissionsView.swift | 14 +- .../Resources/en.lproj/Localizable.strings | 11 +- .../Resources/es.lproj/Localizable.strings | 11 +- .../Resources/ru.lproj/Localizable.strings | 11 +- .../Resources/uk.lproj/Localizable.strings | 11 +- 9 files changed, 119 insertions(+), 242 deletions(-) diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift index 9d3f7ce..9188085 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift @@ -18,7 +18,7 @@ public struct DiskAnalyzerView: View { if viewModel.isScanning { scanningView - } else if viewModel.items.isEmpty { + } else if viewModel.filteredItems.isEmpty { emptyView } else { itemsListView @@ -35,20 +35,6 @@ public struct DiskAnalyzerView: View { private var headerView: some View { HStack(spacing: 12) { - if !viewModel.pathStack.isEmpty { - Button(action: { - withAnimation { - viewModel.navigateBack() - } - }) { - Image(systemName: "chevron.left") - .font(.headline) - .padding(8) - } - .buttonStyle(.plain) - .background(Circle().fill(Color.secondary.opacity(0.2))) - } - VStack(alignment: .leading, spacing: 4) { Text("disk_analyzer_title".localized) .font(.title2) @@ -125,9 +111,15 @@ public struct DiskAnalyzerView: View { .font(.system(size: 48)) .foregroundColor(.secondary) - Text("disk_analyzer_empty".localized) - .font(.headline) - .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) + } Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -137,11 +129,7 @@ public struct DiskAnalyzerView: View { ScrollView { LazyVStack(spacing: 1) { ForEach(viewModel.filteredItems) { item in - DiskItemRow(item: item, settings: settings, onNavigate: { - withAnimation { - viewModel.navigateTo(item: item) - } - }, onShowInFinder: { + DiskItemRow(item: item, settings: settings, onShowInFinder: { viewModel.showInFinder(item: item) }, onDelete: { viewModel.moveToTrash(item: item) @@ -161,7 +149,6 @@ public struct DiskAnalyzerView: View { struct DiskItemRow: View { let item: DiskItem let settings: AppSettings - let onNavigate: () -> Void let onShowInFinder: () -> Void let onDelete: () -> Void @@ -190,6 +177,12 @@ struct DiskItemRow: View { Text("folder".localized) .font(.caption2) .foregroundColor(.secondary) + } else { + Text(item.url.path) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.head) } } @@ -248,11 +241,6 @@ struct DiskItemRow: View { isHovered = hover } } - .onTapGesture { - if item.isDirectory { - onNavigate() - } - } .confirmationDialog( "disk_analyzer_delete_confirm".localized, isPresented: $showingDeleteConfirmation, @@ -332,10 +320,10 @@ struct DiskItemRow: View { switch item.fileType { case .video: return "play.rectangle.fill" case .audio: return "music.note" + case .photo: return "photo.fill" case .apps: return "app.badge.fill" case .docs: return "doc.text.fill" case .archives: return "doc.zip.fill" - case .dev: return "curlybraces" default: return "doc.fill" } } @@ -347,10 +335,10 @@ struct DiskItemRow: View { switch item.fileType { case .video: return .purple case .audio: return .pink + case .photo: return .cyan case .apps: return .orange case .docs: return .green case .archives: return .yellow - case .dev: return .teal default: return .secondary } } diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift index 18703e3..9e92eba 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerViewModel.swift @@ -18,22 +18,13 @@ public final class DiskAnalyzerViewModel { public var selectedCategory: FileCategory = .all private var scanTask: Task? - public var pathStack: [URL] = [] - - // Cache to prevent scanning the same directory twice - private var scanCache: [URL: [DiskItem]] = [:] public init() {} public var filteredItems: [DiskItem] { guard selectedCategory != .all else { return items } return items.filter { item in - if item.isDirectory { - // Directories are kept to allow navigation down the tree - return true - } else { - return item.fileType == selectedCategory - } + return item.fileType == selectedCategory } } @@ -42,7 +33,7 @@ public final class DiskAnalyzerViewModel { panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false - panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser + panel.directoryURL = URL(fileURLWithPath: "/") panel.message = "disk_analyzer_select_folder".localized panel.prompt = "disk_analyzer_scan".localized @@ -56,11 +47,9 @@ public final class DiskAnalyzerViewModel { rootURL = url currentURL = url - pathStack = [] isScanning = true currentScanningName = "" items = [] - scanCache.removeAll() scanTask = Task { do { @@ -72,7 +61,6 @@ public final class DiskAnalyzerViewModel { } self.items = scannedItems - self.scanCache[url] = scannedItems self.isScanning = false } catch { self.logger.error("Scan failed: \(error.localizedDescription)") @@ -80,54 +68,7 @@ public final class DiskAnalyzerViewModel { } } } - - public func navigateTo(item: DiskItem) { - guard item.isDirectory, let currentURL = currentURL else { return } - - pathStack.append(currentURL) - self.currentURL = item.url - - if let cached = scanCache[item.url] { - self.items = cached - } else if let children = item.children { - self.items = children - self.scanCache[item.url] = children - } else { - // Scan subdirectory - isScanning = true - items = [] - - scanTask?.cancel() - scanTask = Task { - do { - let scannedItems = try await scanner.scan(directoryURL: item.url) { [weak self] folderName in - guard let self else { return } - Task { @MainActor in - self.currentScanningName = folderName - } - } - self.items = scannedItems - self.scanCache[item.url] = scannedItems - self.isScanning = false - } catch { - self.logger.error("Subdirectory scan failed: \(error.localizedDescription)") - self.isScanning = false - } - } - } - } - - public func navigateBack() { - guard !pathStack.isEmpty else { return } - let prevURL = pathStack.removeLast() - self.currentURL = prevURL - - if let cached = scanCache[prevURL] { - self.items = cached - } else { - startScan(for: prevURL) - } - } + public func moveToTrash(item: DiskItem) { Task { @@ -136,11 +77,6 @@ public final class DiskAnalyzerViewModel { // Remove from local list self.items.removeAll { $0.url == item.url } - - // Update cache - if let currentURL = currentURL { - scanCache[currentURL] = self.items - } } catch { self.logger.error("Failed to move item to trash: \(error.localizedDescription)") } diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift index 0e310f6..dd51501 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskItem.swift @@ -4,22 +4,20 @@ public enum FileCategory: String, CaseIterable, Sendable { case all case video case audio + case photo case apps case docs case archives - case dev - case other public var localizedName: String { switch self { case .all: return "disk_analyzer_category_all".localized case .video: return "disk_analyzer_category_video".localized case .audio: return "disk_analyzer_category_audio".localized + case .photo: return "disk_analyzer_category_photo".localized case .apps: return "disk_analyzer_category_apps".localized case .docs: return "disk_analyzer_category_docs".localized case .archives: return "disk_analyzer_category_archives".localized - case .dev: return "disk_analyzer_category_dev".localized - case .other: return "disk_analyzer_category_other".localized } } @@ -30,16 +28,16 @@ public enum FileCategory: String, CaseIterable, Sendable { return .video case "mp3", "wav", "flac", "m4a", "aac", "ogg", "wma": return .audio + case "jpg", "jpeg", "png", "gif", "heic", "heif", "tiff", "bmp", "raw", "svg", "webp": + return .photo case "app", "dmg", "pkg", "ipa": return .apps case "pdf", "docx", "doc", "txt", "xlsx", "xls", "pptx", "ppt", "pages", "numbers", "key", "rtf", "odt": return .docs case "zip", "tar", "gz", "rar", "7z", "bz2", "xz": return .archives - case "swift", "py", "java", "c", "cpp", "h", "js", "ts", "json", "yaml", "yml", "sh", "html", "css", "go", "rs", "kt": - return .dev default: - return .other + return .all } } } diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift index 566c8e8..80d5752 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskScanner.swift @@ -6,7 +6,7 @@ public actor DiskScanner { public init() {} - /// Scans a directory and returns its immediate children with calculated sizes. + /// Scans a directory and returns its files (flattened) with calculated sizes. public func scan( directoryURL: URL, onProgress: @Sendable @escaping (String) -> Void @@ -20,84 +20,80 @@ public actor DiskScanner { } let fm = FileManager.default - var isDir: ObjCBool = false - guard fm.fileExists(atPath: directoryURL.path, isDirectory: &isDir), isDir.boolValue else { - return [] - } + let keys: [URLResourceKey] = [.fileSizeKey, .isDirectoryKey] - // 1. Get immediate children - let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .totalFileAllocatedSizeKey] - let childrenURLs = try fm.contentsOfDirectory( + guard let enumerator = fm.enumerator( at: directoryURL, includingPropertiesForKeys: keys, - options: [.skipsHiddenFiles] - ) + options: [.skipsHiddenFiles], + errorHandler: { url, error in + return true // Skip access errors and continue + } + ) else { + return [] + } var results: [DiskItem] = [] - var dirsToScan: [URL] = [] + var appsToCalculate: [URL] = [] + var count = 0 + let minSize: Int64 = 1024 * 1024 // 1 MB - for url in childrenURLs { + while let fileURL = enumerator.nextObject() as? URL { if Task.isCancelled { break } - // Check exclusion - if FileManager.shouldExclude(url: url) { + if FileManager.shouldExclude(url: fileURL) { + if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { + enumerator.skipDescendants() + } continue } - guard let resourceValues = try? url.resourceValues(forKeys: Set(keys)) else { - continue - } + guard let values = try? fileURL.resourceValues(forKeys: Set(keys)) else { continue } + let isDir = values.isDirectory ?? false - let isDirectory = resourceValues.isDirectory ?? false - if isDirectory { - // If it's a bundle like .app, we treat it as a file/app category - if url.pathExtension.lowercased() == "app" { - let size = await calculateDirectorySize(url: url) + if isDir { + if fileURL.pathExtension.lowercased() == "app" { + enumerator.skipDescendants() + appsToCalculate.append(fileURL) + } + } else { + let size = Int64(values.fileSize ?? 0) + if size > minSize { results.append(DiskItem( - url: url, - name: url.lastPathComponent, + url: fileURL, + name: fileURL.lastPathComponent, isDirectory: false, size: size, - fileType: .apps + fileType: FileCategory.from(url: fileURL) )) - } else { - dirsToScan.append(url) } - } else { - let size = Int64(resourceValues.fileSize ?? 0) - results.append(DiskItem( - url: url, - name: url.lastPathComponent, - isDirectory: false, - size: size, - fileType: FileCategory.from(url: url) - )) + } + + count += 1 + if count % 1000 == 0 { + onProgress(fileURL.lastPathComponent) + await Task.yield() } } if Task.isCancelled { return results } - // 2. Scan directories in parallel using TaskGroup - let scannedDirs = try await withThrowingTaskGroup(of: DiskItem?.self) { group in - for dirURL in dirsToScan { + // 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 name = dirURL.lastPathComponent - onProgress(name) - - let size = await self.calculateDirectorySize(url: dirURL) - - // Also recursively scan 1 level deeper to show children inside if expanded - let children = await self.scanSubdirectoryOneLevel(url: dirURL) - - return DiskItem( - url: dirURL, - name: name, - isDirectory: true, - size: size, - children: children, - fileType: .other - ) + 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 } } @@ -110,66 +106,12 @@ public actor DiskScanner { return list } - results.append(contentsOf: scannedDirs) + results.append(contentsOf: appItems) // Sort by size descending return results.sorted { $0.size > $1.size } } - private func scanSubdirectoryOneLevel(url: URL) async -> [DiskItem] { - let fm = FileManager.default - let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey] - guard let childrenURLs = try? fm.contentsOfDirectory( - at: url, - includingPropertiesForKeys: keys, - options: [.skipsHiddenFiles] - ) else { - return [] - } - - var list: [DiskItem] = [] - for child in childrenURLs { - if Task.isCancelled { break } - if FileManager.shouldExclude(url: child) { continue } - - guard let values = try? child.resourceValues(forKeys: Set(keys)) else { continue } - let isDir = values.isDirectory ?? false - - if isDir { - if child.pathExtension.lowercased() == "app" { - let size = await calculateDirectorySize(url: child) - list.append(DiskItem( - url: child, - name: child.lastPathComponent, - isDirectory: false, - size: size, - fileType: .apps - )) - } else { - let size = await calculateDirectorySize(url: child) - list.append(DiskItem( - url: child, - name: child.lastPathComponent, - isDirectory: true, - size: size, - fileType: .other - )) - } - } else { - let size = Int64(values.fileSize ?? 0) - list.append(DiskItem( - url: child, - name: child.lastPathComponent, - isDirectory: false, - size: size, - fileType: FileCategory.from(url: child) - )) - } - } - - return list.sorted { $0.size > $1.size } - } - private func calculateDirectorySize(url: URL) async -> Int64 { if Task.isCancelled { return 0 } let fm = FileManager.default @@ -189,13 +131,6 @@ public actor DiskScanner { while let fileURL = enumerator.nextObject() as? URL { if Task.isCancelled { break } - if FileManager.shouldExclude(url: fileURL) { - if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { - enumerator.skipDescendants() - } - continue - } - guard let values = try? fileURL.resourceValues(forKeys: Set(keys)) else { continue } let isDir = values.isDirectory ?? false @@ -204,7 +139,7 @@ public actor DiskScanner { } count += 1 - if count % 500 == 0 { + if count % 1000 == 0 { await Task.yield() } } diff --git a/MacOSCleaner/Features/Permissions/PermissionsView.swift b/MacOSCleaner/Features/Permissions/PermissionsView.swift index 4adbf56..0fcd4a2 100644 --- a/MacOSCleaner/Features/Permissions/PermissionsView.swift +++ b/MacOSCleaner/Features/Permissions/PermissionsView.swift @@ -126,8 +126,8 @@ struct PermissionsView: View { } } - // MARK: - Dismiss - + @State private var showingWarning = false + private var dismissBar: some View { HStack { Button("permissions_dismiss_temp".localized) { @@ -139,11 +139,19 @@ struct PermissionsView: View { Spacer() Button("permissions_dismiss_permanent".localized) { - permissionsManager.dismissGuidancePermanently() + showingWarning = true } .buttonStyle(.plain) .foregroundColor(.secondary) .font(.caption) + .alert("permissions_warning_title".localized, isPresented: $showingWarning) { + Button("permissions_warning_confirm".localized, role: .destructive) { + permissionsManager.dismissGuidancePermanently() + } + Button("cancel".localized, role: .cancel) { } + } message: { + Text("permissions_warning_message".localized) + } } .padding(.horizontal, 24) .padding(.vertical, 12) diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index b8fb273..e91fcda 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -35,14 +35,14 @@ "disk_analyzer_empty" = "Folder is empty or not scanned yet"; "disk_analyzer_no_permissions" = "No access permissions for this folder"; "folder" = "Folder"; +"disk_analyzer_category_empty" = "No files found in the '%@' category"; "disk_analyzer_category_all" = "All"; "disk_analyzer_category_video" = "Video"; "disk_analyzer_category_audio" = "Audio"; -"disk_analyzer_category_apps" = "Apps"; +"disk_analyzer_category_photo" = "Photos"; +"disk_analyzer_category_apps" = "Dev-Builds"; "disk_analyzer_category_docs" = "Documents"; "disk_analyzer_category_archives" = "Archives"; -"disk_analyzer_category_dev" = "Developer"; -"disk_analyzer_category_other" = "Other"; /* About View */ @@ -343,8 +343,11 @@ "permissions_step4" = "Return to MacOSCleaner and click 'Check Status'."; "permissions_open_settings" = "Open System Settings"; "permissions_check_status" = "Check Status"; -"permissions_dismiss_temp" = "Skip for now"; +"permissions_dismiss_temp" = "Remind me later"; "permissions_dismiss_permanent" = "Don't show again"; +"permissions_warning_title" = "Are you sure?"; +"permissions_warning_message" = "Without Full Disk Access, the app will not be able to find and clean many unnecessary files. The cleanup quality will be significantly reduced."; +"permissions_warning_confirm" = "Never Allow"; "permissions_status_granted" = "Granted"; "permissions_status_required" = "Required"; "permissions_window_title" = "Permissions"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index debbe8e..7ac07b9 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -35,14 +35,14 @@ "disk_analyzer_empty" = "La carpeta está vacía o no ha sido escaneada"; "disk_analyzer_no_permissions" = "Sin permisos de acceso para esta carpeta"; "folder" = "Carpeta"; +"disk_analyzer_category_empty" = "No se encontraron archivos en la categoría '%@'"; "disk_analyzer_category_all" = "Todo"; "disk_analyzer_category_video" = "Vídeo"; "disk_analyzer_category_audio" = "Audio"; -"disk_analyzer_category_apps" = "Aplicaciones"; +"disk_analyzer_category_photo" = "Fotos"; +"disk_analyzer_category_apps" = "Dev-Builds"; "disk_analyzer_category_docs" = "Documentos"; "disk_analyzer_category_archives" = "Archivos"; -"disk_analyzer_category_dev" = "Desarrollo"; -"disk_analyzer_category_other" = "Otro"; // About View @@ -346,8 +346,11 @@ "permissions_step4" = "Vuelva a MacOSCleaner y haga clic en 'Verificar estado'."; "permissions_open_settings" = "Abrir Ajustes del sistema"; "permissions_check_status" = "Verificar estado"; -"permissions_dismiss_temp" = "Saltar por ahora"; +"permissions_dismiss_temp" = "Omitir por ahora"; "permissions_dismiss_permanent" = "No mostrar de nuevo"; +"permissions_warning_title" = "¿Estás seguro?"; +"permissions_warning_message" = "Sin el Acceso Total al Disco, la aplicación no podrá encontrar ni limpiar muchos archivos innecesarios. La calidad de la limpieza se verá afectada considerablemente."; +"permissions_warning_confirm" = "Nunca permitir"; "permissions_status_granted" = "Concedido"; "permissions_status_required" = "Requerido"; "permissions_window_title" = "Permisos"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index 88349f5..de5ea45 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -35,14 +35,14 @@ "disk_analyzer_empty" = "Папка пуста или еще не просканирована"; "disk_analyzer_no_permissions" = "Нет прав доступа к этой папке"; "folder" = "Папка"; +"disk_analyzer_category_empty" = "Файлов категории «%@» не найдено на вашем Mac"; "disk_analyzer_category_all" = "Все"; "disk_analyzer_category_video" = "Видео"; "disk_analyzer_category_audio" = "Аудио"; -"disk_analyzer_category_apps" = "Программы"; +"disk_analyzer_category_photo" = "Фото"; +"disk_analyzer_category_apps" = "Dev-сборки"; "disk_analyzer_category_docs" = "Документы"; "disk_analyzer_category_archives" = "Архивы"; -"disk_analyzer_category_dev" = "Разработка"; -"disk_analyzer_category_other" = "Другое"; /* About View */ @@ -355,8 +355,11 @@ "permissions_step4" = "Вернитесь в MacOSCleaner и нажмите 'Проверить статус'."; "permissions_open_settings" = "Открыть System Settings"; "permissions_check_status" = "Проверить статус"; -"permissions_dismiss_temp" = "Пропустить"; +"permissions_dismiss_temp" = "Пропустить пока"; "permissions_dismiss_permanent" = "Больше не показывать"; +"permissions_warning_title" = "Вы уверены?"; +"permissions_warning_message" = "Без полного доступа к диску приложение не сможет найти и удалить множество ненужных файлов. Качество очистки сильно пострадает."; +"permissions_warning_confirm" = "Никогда не разрешать"; "permissions_status_granted" = "Предоставлено"; "permissions_status_required" = "Требуется"; "permissions_window_title" = "Разрешения"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index 7bbf39f..f9c175a 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -35,14 +35,14 @@ "disk_analyzer_empty" = "Папка порожня або ще не просканована"; "disk_analyzer_no_permissions" = "Немає прав доступу до цієї папки"; "folder" = "Папка"; +"disk_analyzer_category_empty" = "Файлів категорії «%@» не знайдено на вашому Mac"; "disk_analyzer_category_all" = "Все"; "disk_analyzer_category_video" = "Відео"; "disk_analyzer_category_audio" = "Аудіо"; -"disk_analyzer_category_apps" = "Програми"; +"disk_analyzer_category_photo" = "Фото"; +"disk_analyzer_category_apps" = "Dev-збірки"; "disk_analyzer_category_docs" = "Документи"; "disk_analyzer_category_archives" = "Архів"; -"disk_analyzer_category_dev" = "Розробка"; -"disk_analyzer_category_other" = "Інше"; /* About View */ @@ -350,8 +350,11 @@ "permissions_step4" = "Поверніться до MacOSCleaner і натисніть 'Перевірити статус'."; "permissions_open_settings" = "Відкрити System Settings"; "permissions_check_status" = "Перевірити статус"; -"permissions_dismiss_temp" = "Пропустити"; +"permissions_dismiss_temp" = "Пропустити поки"; "permissions_dismiss_permanent" = "Більше не показувати"; +"permissions_warning_title" = "Ви впевнені?"; +"permissions_warning_message" = "Без повного доступу до диску додаток не зможе знайти та видалити багато непотрібних файлів. Якість очистки сильно постраждає."; +"permissions_warning_confirm" = "Ніколи не дозволяти"; "permissions_status_granted" = "Надано"; "permissions_status_required" = "Потрібно"; "permissions_window_title" = "Дозволи"; From 60081864e63902864b610091c71422fc3633416d Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Thu, 16 Jul 2026 17:25:54 +0300 Subject: [PATCH 08/23] fix: update disk analyzer category label from Dev-Builds to Apps across all locales --- MacOSCleaner/Resources/en.lproj/Localizable.strings | 2 +- MacOSCleaner/Resources/es.lproj/Localizable.strings | 2 +- MacOSCleaner/Resources/ru.lproj/Localizable.strings | 2 +- MacOSCleaner/Resources/uk.lproj/Localizable.strings | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index e91fcda..a72c6a1 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -40,7 +40,7 @@ "disk_analyzer_category_video" = "Video"; "disk_analyzer_category_audio" = "Audio"; "disk_analyzer_category_photo" = "Photos"; -"disk_analyzer_category_apps" = "Dev-Builds"; +"disk_analyzer_category_apps" = "Apps"; "disk_analyzer_category_docs" = "Documents"; "disk_analyzer_category_archives" = "Archives"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index 7ac07b9..cb2dcb5 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -40,7 +40,7 @@ "disk_analyzer_category_video" = "Vídeo"; "disk_analyzer_category_audio" = "Audio"; "disk_analyzer_category_photo" = "Fotos"; -"disk_analyzer_category_apps" = "Dev-Builds"; +"disk_analyzer_category_apps" = "Aplicaciones"; "disk_analyzer_category_docs" = "Documentos"; "disk_analyzer_category_archives" = "Archivos"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index de5ea45..faf6a3b 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -40,7 +40,7 @@ "disk_analyzer_category_video" = "Видео"; "disk_analyzer_category_audio" = "Аудио"; "disk_analyzer_category_photo" = "Фото"; -"disk_analyzer_category_apps" = "Dev-сборки"; +"disk_analyzer_category_apps" = "Приложения"; "disk_analyzer_category_docs" = "Документы"; "disk_analyzer_category_archives" = "Архивы"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index f9c175a..f04aac5 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -40,7 +40,7 @@ "disk_analyzer_category_video" = "Відео"; "disk_analyzer_category_audio" = "Аудіо"; "disk_analyzer_category_photo" = "Фото"; -"disk_analyzer_category_apps" = "Dev-збірки"; +"disk_analyzer_category_apps" = "Програми"; "disk_analyzer_category_docs" = "Документи"; "disk_analyzer_category_archives" = "Архів"; From 0f9aa7774238fb37e9319114dfc4b5fc9c904bdc Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Thu, 16 Jul 2026 20:31:26 +0300 Subject: [PATCH 09/23] refactor: implement VSplitView for log panel, adjust cleanup timeouts, and update cleanup item tracking logic --- .../Domains/Cleanup/CleanupCoordinator.swift | 39 ++++++++- .../Domains/Cleanup/CleanupEngine.swift | 4 +- .../Features/Cleanup/CleanupView.swift | 85 +++++++++---------- .../Features/Uninstaller/AppDiscovery.swift | 20 +++-- .../Infrastructure/FileCleanupActor.swift | 21 ++--- .../Infrastructure/FileManager+Size.swift | 2 + .../Infrastructure/SafetyManager.swift | 3 + .../Infrastructure/String+Localization.swift | 7 +- .../Resources/ru.lproj/Localizable.strings | 26 +----- .../Resources/uk.lproj/Localizable.strings | 27 +----- 10 files changed, 117 insertions(+), 117 deletions(-) diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift index 9623745..c2f979c 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift @@ -29,6 +29,9 @@ public final class CleanupCoordinator: @unchecked Sendable { public var lastError: String? = nil public var scriptLogs: [String] = [] + private var pendingLogs: [String] = [] + private var isLogFlushScheduled = false + public init( engine: CleanupEngine, journal: TransactionJournal, @@ -70,6 +73,8 @@ public final class CleanupCoordinator: @unchecked Sendable { self.itemManager.clear() self.lastError = nil self.scriptLogs = [] + self.pendingLogs = [] + self.isLogFlushScheduled = false self.skippedItems = [] // Close running apps before scan for better cache cleanup @@ -84,6 +89,10 @@ public final class CleanupCoordinator: @unchecked Sendable { } } + // Allow final main-actor logs to process, then flush + try? await Task.sleep(for: .milliseconds(100)) + self.flushLogs() + if self.settings.emptyTrashDuringCleanup { let trashURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash") let trashSizeBytes = FileManager.default.getDirectorySize(url: trashURL) @@ -111,6 +120,7 @@ public final class CleanupCoordinator: @unchecked Sendable { showNotifications: self.settings.showNotifications ) } catch let error { + self.flushLogs() Logger.coordinator.error("startScan failed: \(error.localizedDescription, privacy: .public)") self.lastError = error.localizedDescription do { @@ -136,6 +146,8 @@ public final class CleanupCoordinator: @unchecked Sendable { self.cleanedItems = [] self.lastError = nil self.scriptLogs = [] + self.pendingLogs = [] + self.isLogFlushScheduled = false let isTrashSelected = self.itemManager.items.contains { item in item.isSelected && item.label == "trash_user_label".localized @@ -155,6 +167,10 @@ public final class CleanupCoordinator: @unchecked Sendable { } } + // Allow final main-actor logs to process, then flush + try? await Task.sleep(for: .milliseconds(100)) + self.flushLogs() + for result in results { self.totalFreedMB += result.freedMB self.totalFreedBytes += result.freedBytes @@ -200,6 +216,7 @@ public final class CleanupCoordinator: @unchecked Sendable { try self.stateMachine.transition(to: .completed) } catch let error { + self.flushLogs() Logger.coordinator.error("executeCleanup failed: \(error.localizedDescription, privacy: .public)") self.lastError = error.localizedDescription do { @@ -356,10 +373,30 @@ public final class CleanupCoordinator: @unchecked Sendable { self.itemManager.appendPreviewItem(label, size: freedMB, deletable: true, parentName: category, description: nil) } case .log(let message): - self.scriptLogs.append(message) + self.pendingLogs.append(message) + self.scheduleLogFlushIfNeeded() case .fileItem(let path, let sizeBytes, let modificationDate, let isDirectory, let category, let parentName): let effectiveParent = parentName ?? category self.itemManager.appendFileItem(path: path, sizeBytes: sizeBytes, modificationDate: modificationDate, isDirectory: isDirectory, category: category, parentName: effectiveParent) } } + + @MainActor + private func scheduleLogFlushIfNeeded() { + guard !isLogFlushScheduled else { return } + isLogFlushScheduled = true + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + self.flushLogs() + } + } + + @MainActor + private func flushLogs() { + if !pendingLogs.isEmpty { + self.scriptLogs.append(contentsOf: self.pendingLogs) + self.pendingLogs.removeAll() + } + isLogFlushScheduled = false + } } diff --git a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift index 5876beb..84f93a7 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift @@ -323,9 +323,9 @@ public actor CleanupEngine { private static func timeoutForCategory(_ category: CleanupCategory) -> Duration { switch category { - case .packageManagers, .docker, .languageCaches, .androidSDK, .timeMachineSnapshots, .ideOldVersions: + case .packageManagers, .docker, .languageCaches, .androidSDK, .timeMachineSnapshots, .ideOldVersions, .userLogs: return .seconds(120) - case .xcode, .iosSimulators: + case .xcode, .iosSimulators, .appCaches, .ideCaches, .flutterDart, .systemCaches, .androidCaches, .gradleMaven, .dotfileCaches, .browserCaches, .chromeExtraCaches, .steamCache, .teamsCache, .adobeCaches, .cloudKitCache, .swiftPMCache, .carthageCache, .photosCache: return .seconds(300) case .scatteredJunk: return .seconds(600) diff --git a/MacOSCleaner/Features/Cleanup/CleanupView.swift b/MacOSCleaner/Features/Cleanup/CleanupView.swift index 2236abc..77c0de0 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupView.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupView.swift @@ -4,7 +4,7 @@ public struct CleanupView: View { let viewModel: CleanupViewModel @State private var showLogs = false @State private var showCopiedHint = false - @State private var logHeight: CGFloat = 160 + @State private var scrollTaskBox = ScrollTaskBox() @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @@ -15,8 +15,20 @@ public struct CleanupView: View { public var body: some View { GlassEffectContainer { VStack(spacing: 0) { - content - .frame(maxWidth: .infinity, maxHeight: .infinity) + if showLogs && !viewModel.scriptLogs.isEmpty && viewModel.state != .failed { + VSplitView { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minHeight: 200) + + logPanel + .frame(maxWidth: .infinity) + .frame(minHeight: 120, idealHeight: 180, maxHeight: 400) + } + } else { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } Divider() @@ -394,12 +406,6 @@ public struct CleanupView: View { totalSteps: viewModel.totalSteps, onCancel: { viewModel.cancel() } ) - - if showLogs && !viewModel.scriptLogs.isEmpty { - Divider() - .padding(.horizontal) - logPanel - } } .padding(.top, 20) } @@ -490,12 +496,6 @@ public struct CleanupView: View { alignment: .top ) } - - if showLogs && !viewModel.scriptLogs.isEmpty { - Divider() - logPanel - .frame(maxWidth: .infinity) - } } } @@ -626,7 +626,11 @@ public struct CleanupView: View { Spacer() - Button(action: { showLogs = false }) { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + showLogs = false + } + }) { Image(systemName: "xmark.circle.fill") .foregroundColor(.secondary) } @@ -641,28 +645,6 @@ public struct CleanupView: View { .foregroundColor(Color(NSColor.separatorColor)), alignment: .bottom ) - .overlay( - // Resize Handle Area - Rectangle() - .fill(Color.clear) - .frame(height: 4) - .contentShape(Rectangle()) - .onHover { inside in - if inside { - NSCursor.resizeUpDown.set() - } else { - NSCursor.arrow.set() - } - } - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let delta = value.location.y - value.startLocation.y - logHeight = max(100, min(600, logHeight - delta)) - } - ), - alignment: .top - ) ScrollViewReader { proxy in ScrollView { @@ -678,28 +660,39 @@ public struct CleanupView: View { } .padding(8) } - .frame(height: logHeight) .background(Color.black.opacity(0.12)) .onChange(of: viewModel.scriptLogs.count) { _, _ in - if let last = viewModel.scriptLogs.indices.last { - proxy.scrollTo(last, anchor: .bottom) + scrollTaskBox.task?.cancel() + scrollTaskBox.task = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + guard !Task.isCancelled else { return } + if let last = viewModel.scriptLogs.indices.last { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(last, anchor: .bottom) + } + } } } } } + .background(Color(NSColor.windowBackgroundColor)) } private var footer: some View { HStack(spacing: 12) { if viewModel.state == .preview { - Text(String(format: "cleanup_selected".localized, viewModel.selectedSizeBytes.formattedByteCount())) + Text(String(format: "cleanup_selected".localized, viewModel.selectedSizeBytes.formattedByteCount(forceGB: true))) .fontWeight(.semibold) .foregroundColor(.accentColor) } if !viewModel.scriptLogs.isEmpty { HStack(spacing: 16) { - Button(action: { showLogs.toggle() }) { + Button(action: { + withAnimation(.easeInOut(duration: 0.25)) { + showLogs.toggle() + } + }) { HStack(spacing: 4) { Image(systemName: showLogs ? "chevron.down.square" : "chevron.up.square") Text(showLogs ? "cleanup_hide_logs".localized : "cleanup_show_logs".localized) @@ -833,7 +826,7 @@ struct CleanupFileRow: View { if isGenerating { HStack(spacing: 8) { - ProgressView().controlSize(.small) + ProgressView().controlSize(.small).frame(width: 16, height: 16) Text("uninstaller_ai_explaining".localized) .font(.caption) .foregroundColor(.secondary) @@ -884,3 +877,7 @@ struct CleanupFileRow: View { } } } + +private final class ScrollTaskBox { + var task: Task? +} diff --git a/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift b/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift index 1ccd736..558fd9c 100644 --- a/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift +++ b/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift @@ -26,13 +26,19 @@ public actor AppDiscovery { } // Application Support (Google Updater, etc.) - let appSupport = "\(NSHomeDirectory())/Library/Application Support" - if let result = try? await commandRunner.run( - command: "/usr/bin/find", - arguments: [appSupport, "-maxdepth", "4", "-name", "*.app", "-type", "d", "-prune"] - ) { - for path in result.stdout.components(separatedBy: .newlines) where !path.isEmpty { - urls.append(URL(fileURLWithPath: path)) + let appSupportDirs = [ + "\(NSHomeDirectory())/Library/Application Support", + "/Library/Application Support" + ] + + for appSupport in appSupportDirs { + if let result = try? await commandRunner.run( + command: "/usr/bin/find", + arguments: [appSupport, "-maxdepth", "4", "-name", "*.app", "-type", "d", "-prune"] + ) { + for path in result.stdout.components(separatedBy: .newlines) where !path.isEmpty { + urls.append(URL(fileURLWithPath: path)) + } } } diff --git a/MacOSCleaner/Infrastructure/FileCleanupActor.swift b/MacOSCleaner/Infrastructure/FileCleanupActor.swift index 459788d..a879b58 100644 --- a/MacOSCleaner/Infrastructure/FileCleanupActor.swift +++ b/MacOSCleaner/Infrastructure/FileCleanupActor.swift @@ -35,7 +35,7 @@ public actor FileCleanupActor { let size = (try? fm.attributesOfItem(atPath: path)[.size] as? Int64) ?? 0 if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(Self.formatBytes(size))")) - return (size, Self.fileItemForPath(path)) + return (size, Self.fileItemForPath(path, size: size, isDirectory: false)) } try? fm.removeItem(at: url) progress?(.log(" \(Self.shortPath(path)) — removed, freed \(Self.formatBytes(size))")) @@ -45,7 +45,7 @@ public actor FileCleanupActor { let before = await getDirectorySize(path) if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(Self.formatBytes(before))")) - return (before, Self.fileItemForPath(path)) + return (before, Self.fileItemForPath(path, size: before, isDirectory: true)) } let contents = try fm.contentsOfDirectory(atPath: path) @@ -77,7 +77,7 @@ public actor FileCleanupActor { if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(Self.formatBytes(before))")) - return (before, Self.fileItemForPath(path)) + return (before, Self.fileItemForPath(path, size: before, isDirectory: true)) } try? fm.removeItem(atPath: path) @@ -138,7 +138,7 @@ public actor FileCleanupActor { } if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(removedCount) items older than \(days) days (\(Self.formatBytes(freed)))")) - return (freed, Self.fileItemForPath(path)) + return (freed, Self.fileItemForPath(path, size: freed, isDirectory: true)) } else { progress?(.log(" \(Self.shortPath(path)) — removed \(removedCount) old items, freed \(Self.formatBytes(freed))")) } @@ -177,7 +177,7 @@ public actor FileCleanupActor { if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(removedCount) items older than \(days) days (\(Self.formatBytes(freed)))")) - return (freed, Self.fileItemForPath(path)) + return (freed, Self.fileItemForPath(path, size: freed, isDirectory: true)) } else { progress?(.log(" \(Self.shortPath(path)) — removed \(removedCount) old items, freed \(Self.formatBytes(freed))")) } @@ -207,18 +207,11 @@ public actor FileCleanupActor { )) } - static func fileItemForPath(_ path: String) -> CleanupFileItem? { + static func fileItemForPath(_ path: String, size: Int64, isDirectory: Bool) -> CleanupFileItem? { let fm = FileManager.default let attrs = try? fm.attributesOfItem(atPath: path) let modDate = attrs?[.modificationDate] as? Date - let isDir = (attrs?[.type] as? FileAttributeType) == .typeDirectory - let size: Int64 - if isDir { - size = fm.getDirectorySize(url: URL(fileURLWithPath: path)) - } else { - size = (attrs?[.size] as? Int64) ?? 0 - } - return CleanupFileItem(path: path, sizeBytes: size, modificationDate: modDate, isDirectory: isDir) + return CleanupFileItem(path: path, sizeBytes: size, modificationDate: modDate, isDirectory: isDirectory) } static func formatBytes(_ bytes: Int64) -> String { diff --git a/MacOSCleaner/Infrastructure/FileManager+Size.swift b/MacOSCleaner/Infrastructure/FileManager+Size.swift index 08dbe30..1267fc9 100644 --- a/MacOSCleaner/Infrastructure/FileManager+Size.swift +++ b/MacOSCleaner/Infrastructure/FileManager+Size.swift @@ -69,6 +69,8 @@ extension FileManager { /// 2. `lastPathComponent` matches `excludedFileNames` /// 3. Any ancestor directory name matches `excludedDirectoryNames` public static func shouldExclude(url: URL) -> Bool { + if url.path.hasPrefix("/System") { return true } + let ext = url.pathExtension.lowercased() if excludedExtensions.contains(ext) { return true } diff --git a/MacOSCleaner/Infrastructure/SafetyManager.swift b/MacOSCleaner/Infrastructure/SafetyManager.swift index e67642f..dc849b8 100644 --- a/MacOSCleaner/Infrastructure/SafetyManager.swift +++ b/MacOSCleaner/Infrastructure/SafetyManager.swift @@ -45,8 +45,11 @@ public struct SafetyManager: Sendable { let defaultExceptions = [ "\(home)/Library", + "/Library/Application Support", "/Library/LaunchAgents", "/Library/LaunchDaemons", + "/Library/Receipts", + "/Library/Internet Plug-Ins", "/private/var/folders", "/private/tmp", "/tmp", diff --git a/MacOSCleaner/Infrastructure/String+Localization.swift b/MacOSCleaner/Infrastructure/String+Localization.swift index 9011824..f9986c8 100644 --- a/MacOSCleaner/Infrastructure/String+Localization.swift +++ b/MacOSCleaner/Infrastructure/String+Localization.swift @@ -30,7 +30,12 @@ public extension ByteCountFormatter { } public extension Int64 { - func formattedByteCount(style: ByteCountFormatStyle.Style = .file) -> String { + func formattedByteCount(style: ByteCountFormatStyle.Style = .file, forceGB: Bool = false) -> String { + if forceGB { + let formatter = ByteCountFormatter.makeLocalized(countStyle: style == .memory ? .memory : .file) + formatter.allowedUnits = .useGB + return formatter.string(fromByteCount: self) + } if abs(self) < 1000 { let isRussian = LanguageManager.shared.currentLocale.language.languageCode?.identifier == "ru" || LanguageManager.shared.currentLocale.language.languageCode?.identifier == "uk" diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index faf6a3b..f9e0e4d 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -187,29 +187,7 @@ /* Startup Category Help */ "startup_help_user" = "Пользовательский сервис из ~/Library/. Безопасно отключать."; "startup_help_third_party" = "Сторонний сервис из /Library/. Безопасно отключать."; -"cleanup_summary" = "Сводка удаленных файлов"; -"cleanup_skipped" = "Не удалось очистить"; -"cleanup_selected" = "Выбрано: %@"; -"cleanup_hide_logs" = "Скрыть логи"; -"cleanup_show_logs" = "Показать логи"; -"cleanup_copy" = "Копировать"; -"cleanup_copy_logs" = "Копировать логи"; -"cleanup_now" = "Очистить сейчас"; -"cleanup_manual_instructions" = "Инструкции для ручной очистки"; -"cleanup_scan_results" = "Результаты сканирования"; -"cleanup_scan_results_sub" = "Выберите элементы для удаления и нажмите 'Очистить сейчас'."; -"cleanup_recommended" = "Рекомендуется к удалению"; -"cleanup_deselect_all" = "Снять выделение"; -"cleanup_select_all" = "Выбрать все"; -"cleanup_show_all_count" = "Показать все (ещё %lld)"; -"cleanup_debug_log" = "Отладочный лог (%lld строк)"; -/* Cleanup Notifications */ -"cleanup_scan_complete_title" = "Сканирование завершено"; -"cleanup_scan_complete_body" = "Найдено файлов для удаления: %@."; -"cleanup_emptying_trash" = "Очистка корзины..."; -"cleanup_complete_title" = "Очистка завершена"; -"cleanup_complete_body" = "Успешно освобождено %@."; /* Cleanup View */ "cleanup_scanning" = "Сканирование системы..."; @@ -247,7 +225,7 @@ "cleanup_complete_sub" = "Успешно освобождено %@ дискового пространства."; "cleanup_summary" = "Сводка удаленных файлов"; "cleanup_skipped" = "Не удалось очистить"; -"cleanup_selected" = "Выбрано: %lld МБ"; +"cleanup_selected" = "Выбрано: %@"; "cleanup_hide_logs" = "Скрыть логи"; "cleanup_show_logs" = "Показать логи"; "cleanup_copy" = "Копировать"; @@ -264,7 +242,7 @@ /* Cleanup Notifications */ "cleanup_scan_complete_title" = "Сканирование завершено"; -"cleanup_scan_complete_body" = "Найдено файлов для удаления: %lld МБ."; +"cleanup_scan_complete_body" = "Найдено файлов для удаления: %@."; "cleanup_emptying_trash" = "Очистка корзины..."; "cleanup_complete_title" = "Очистка завершена"; "cleanup_complete_body" = "Успешно освобождено %@."; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index f04aac5..bed5061 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -185,29 +185,8 @@ /* Startup Category Help */ "startup_help_user" = "Служба користувача з ~/Library/. Безпечно вимикати."; "startup_help_third_party" = "Стороння служба з /Library/. Безпечно вимикати."; -"cleanup_summary" = "Зведення видалених файлів"; -"cleanup_skipped" = "Не вдалося очистити"; -"cleanup_selected" = "Вибрано: %@"; -"cleanup_hide_logs" = "Приховати логи"; -"cleanup_show_logs" = "Показати логи"; -"cleanup_copy" = "Копіювати"; -"cleanup_copy_logs" = "Копиювати логи"; -"cleanup_now" = "Очистити зараз"; -"cleanup_manual_instructions" = "Інструкції для ручного очищення"; -"cleanup_scan_results" = "Результати сканирування"; -"cleanup_scan_results_sub" = "Виберіть елементи для видалення та натисніть 'Очистити зараз'."; -"cleanup_recommended" = "Рекомендується до видалення"; -"cleanup_deselect_all" = "Зняти виділення"; -"cleanup_select_all" = "Вибрати все"; -"cleanup_show_all_count" = "Показати все (ще %lld)"; -"cleanup_debug_log" = "Блокнот відладки (%lld рядків)"; -/* Cleanup Notifications */ -"cleanup_scan_complete_title" = "Сканування завершено"; -"cleanup_scan_complete_body" = "Знайдено файлів для видалення: %@."; -"cleanup_emptying_trash" = "Очищення смітника..."; -"cleanup_complete_title" = "Очищення завершено"; -"cleanup_complete_body" = "Успішно звільнено %@."; +"cleanup_scanning" = "Сканування системи..."; "cleanup_clean" = "Система чиста"; "cleanup_clean_sub" = "Під час сканування не було знайдено непотрібних файлів."; "cleanup_rescan" = "Повторити сканування"; @@ -242,7 +221,7 @@ "cleanup_complete_sub" = "Успішно звільнено %@ дискового простору."; "cleanup_summary" = "Зведення видалених файлів"; "cleanup_skipped" = "Не вдалося очистити"; -"cleanup_selected" = "Вибрано: %lld МБ"; +"cleanup_selected" = "Вибрано: %@"; "cleanup_hide_logs" = "Приховати логи"; "cleanup_show_logs" = "Показати логи"; "cleanup_copy" = "Копіювати"; @@ -259,7 +238,7 @@ /* Cleanup Notifications */ "cleanup_scan_complete_title" = "Сканування завершено"; -"cleanup_scan_complete_body" = "Знайдено файлів для видалення: %lld МБ."; +"cleanup_scan_complete_body" = "Знайдено файлів для видалення: %@."; "cleanup_emptying_trash" = "Очищення смітника..."; "cleanup_complete_title" = "Очищення завершено"; "cleanup_complete_body" = "Успішно звільнено %@."; From e49545593a2c3aa715d7e1c7d704579bcd0073dc Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Thu, 16 Jul 2026 20:35:09 +0300 Subject: [PATCH 10/23] feat: add show-in-finder button to cleanup items for direct path navigation --- MacOSCleaner/Features/Cleanup/CleanupView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/MacOSCleaner/Features/Cleanup/CleanupView.swift b/MacOSCleaner/Features/Cleanup/CleanupView.swift index 77c0de0..7cd9162 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupView.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupView.swift @@ -794,6 +794,19 @@ struct CleanupFileRow: View { Spacer() + if let path = item.path { + Button { + let resolvedPath = (path as NSString).expandingTildeInPath + let url = URL(fileURLWithPath: resolvedPath) + NSWorkspace.shared.activateFileViewerSelecting([url]) + } label: { + Image(systemName: "arrow.up.forward.app") + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .help("uninstaller_show_in_finder".localized) + } + if settings.enableAI && AIExplanationService.shared.isAvailable, let path = item.path { Button { withAnimation(.spring()) { From 72bc0fe1e19a343089fb0001c08f86a16918330c Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Thu, 16 Jul 2026 20:50:55 +0300 Subject: [PATCH 11/23] feat: implement global glass overlay system with toast notifications, alerts, and animated liquid loader component --- MacOSCleaner/App/RootView.swift | 100 ++++++++-- .../Features/Dashboard/DashboardView.swift | 3 +- .../DiskAnalyzer/DiskAnalyzerView.swift | 50 +++-- .../Permissions/PermissionsView.swift | 20 +- .../Features/Processes/ProcessesView.swift | 68 +++---- .../Infrastructure/GlassOverlayManager.swift | 147 ++++++++++++++ .../Infrastructure/GlassOverlayView.swift | 181 ++++++++++++++++++ .../LiquidGlassLoaderView.swift | 90 +++++++++ .../MacOSCleaner.xcodeproj/project.pbxproj | 12 ++ 9 files changed, 577 insertions(+), 94 deletions(-) create mode 100644 MacOSCleaner/Infrastructure/GlassOverlayManager.swift create mode 100644 MacOSCleaner/Infrastructure/GlassOverlayView.swift create mode 100644 MacOSCleaner/Infrastructure/LiquidGlassLoaderView.swift diff --git a/MacOSCleaner/App/RootView.swift b/MacOSCleaner/App/RootView.swift index 376ab1b..adbacdc 100644 --- a/MacOSCleaner/App/RootView.swift +++ b/MacOSCleaner/App/RootView.swift @@ -9,27 +9,40 @@ struct RootView: View { @Binding var availableUpdate: String? var body: some View { - NavigationSplitView { - List(NavigationItem.allCases, selection: $selectedItem) { item in - NavigationLink(value: item) { - Label(item.localizedTitle, systemImage: item.systemImage) - .padding(.vertical, 2) + ZStack { + NavigationSplitView { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(NavigationItem.allCases) { item in + SidebarItemRow( + item: item, + isSelected: selectedItem == item, + action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + selectedItem = item + } + } + ) + } + } + .padding(.horizontal, 8) + .padding(.top, 12) + } + .navigationTitle("app_title".localized) + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280) + } detail: { + if let selectedItem { + contentView(for: selectedItem) + .frame(minWidth: 800, minHeight: 600) + } else { + Text("sidebar_select_item".localized) + .foregroundColor(.secondary) + .frame(minWidth: 800, minHeight: 600) } } - .listStyle(.sidebar) - .navigationTitle("app_title".localized) - .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280) - } detail: { - if let selectedItem { - contentView(for: selectedItem) - .frame(minWidth: 800, minHeight: 600) - } else { - Text("sidebar_select_item".localized) - .foregroundColor(.secondary) - .frame(minWidth: 800, minHeight: 600) - } + + GlassOverlayView(manager: GlassOverlayManager.shared) } - .sheet(isPresented: $permissionsManager.showGuidance) { PermissionsView(permissionsManager: permissionsManager) } @@ -86,3 +99,54 @@ struct RootView: View { availableUpdate: .constant(nil) ) } + +struct SidebarItemRow: View { + let item: NavigationItem + let isSelected: Bool + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: item.systemImage) + .font(.title3) + .foregroundColor(isSelected ? .accentColor : .secondary) + .frame(width: 24, height: 24) + + Text(item.localizedTitle) + .font(.body) + .fontWeight(isSelected ? .semibold : .medium) + .foregroundColor(isSelected ? .primary : .secondary) + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + .background( + Group { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.12)) + .glassEffect() + } else if isHovered { + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.04)) + } else { + Color.clear + } + } + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .animation(.easeInOut(duration: 0.15), value: isSelected) + .animation(.easeInOut(duration: 0.1), value: isHovered) + } + .buttonStyle(.plain) + .onHover { hover in + isHovered = hover + } + } +} + diff --git a/MacOSCleaner/Features/Dashboard/DashboardView.swift b/MacOSCleaner/Features/Dashboard/DashboardView.swift index 336c6f0..85c3af2 100644 --- a/MacOSCleaner/Features/Dashboard/DashboardView.swift +++ b/MacOSCleaner/Features/Dashboard/DashboardView.swift @@ -61,8 +61,7 @@ struct DashboardView: View { if viewModel.isCategoriesLoading { VStack(spacing: 12) { - ProgressView() - .scaleEffect(1.2) + LiquidGlassLoaderView(size: 48) Text("disk_analyzer_scanning".localized) .font(.subheadline) .foregroundColor(.secondary) diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift index 9188085..4e7868d 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift @@ -60,33 +60,49 @@ public struct DiskAnalyzerView: View { } private var categoryFilterView: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach(FileCategory.allCases, id: \.self) { category in - Button(action: { - withAnimation { - viewModel.selectedCategory = category + HStack { + Spacer() + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(FileCategory.allCases, id: \.self) { category in + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.selectedCategory = category + } + }) { + Text(category.localizedName) + .font(.callout) + .foregroundColor(viewModel.selectedCategory == category ? .white : .primary) + .padding(.horizontal, 10) + .padding(.vertical, 4) } - }) { - Text(category.localizedName) - .font(.callout) - .padding(.horizontal, 8) - .padding(.vertical, 3) + .buttonStyle(.plain) + .background( + Group { + if viewModel.selectedCategory == category { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.04)) + .glassEffect() + } + } + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(viewModel.selectedCategory == category ? .accentColor : nil) } + .padding(.vertical, 4) + .padding(.horizontal, 4) } - .padding(.horizontal, 4) + Spacer() } } private var scanningView: some View { VStack(spacing: 20) { Spacer() - ProgressView() - .scaleEffect(1.2) + LiquidGlassLoaderView(size: 56) Text("disk_analyzer_scanning".localized) .font(.headline) diff --git a/MacOSCleaner/Features/Permissions/PermissionsView.swift b/MacOSCleaner/Features/Permissions/PermissionsView.swift index 0fcd4a2..ed4ce58 100644 --- a/MacOSCleaner/Features/Permissions/PermissionsView.swift +++ b/MacOSCleaner/Features/Permissions/PermissionsView.swift @@ -126,8 +126,6 @@ struct PermissionsView: View { } } - @State private var showingWarning = false - private var dismissBar: some View { HStack { Button("permissions_dismiss_temp".localized) { @@ -139,19 +137,19 @@ struct PermissionsView: View { Spacer() Button("permissions_dismiss_permanent".localized) { - showingWarning = true + GlassOverlayManager.shared.showAlert( + title: "permissions_warning_title".localized, + message: "permissions_warning_message".localized, + type: .warning, + primaryButtonTitle: "permissions_warning_confirm".localized, + primaryAction: { + permissionsManager.dismissGuidancePermanently() + } + ) } .buttonStyle(.plain) .foregroundColor(.secondary) .font(.caption) - .alert("permissions_warning_title".localized, isPresented: $showingWarning) { - Button("permissions_warning_confirm".localized, role: .destructive) { - permissionsManager.dismissGuidancePermanently() - } - Button("cancel".localized, role: .cancel) { } - } message: { - Text("permissions_warning_message".localized) - } } .padding(.horizontal, 24) .padding(.vertical, 12) diff --git a/MacOSCleaner/Features/Processes/ProcessesView.swift b/MacOSCleaner/Features/Processes/ProcessesView.swift index e204606..9038ebc 100644 --- a/MacOSCleaner/Features/Processes/ProcessesView.swift +++ b/MacOSCleaner/Features/Processes/ProcessesView.swift @@ -54,50 +54,6 @@ public struct ProcessesView: View { } .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15)) } - .alert( - "processes_confirm_terminate".localized, - isPresented: Binding( - get: { viewModel.confirmKill != nil }, - set: { if !$0 { viewModel.confirmKill = nil } } - ) - ) { - Button("cancel".localized, role: .cancel) { viewModel.confirmKill = nil } - Button("processes_terminate".localized, role: .destructive) { - if let proc = viewModel.confirmKill { - Task { await viewModel.terminate(proc) } - } - } - } message: { - if let proc = viewModel.confirmKill { - Text(String( - format: "processes_confirm_terminate_message".localized, - proc.name, - proc.pid - )) - } - } - .alert( - "processes_confirm_force".localized, - isPresented: Binding( - get: { viewModel.confirmForceKill != nil }, - set: { if !$0 { viewModel.confirmForceKill = nil } } - ) - ) { - Button("cancel".localized, role: .cancel) { viewModel.confirmForceKill = nil } - Button("processes_force_kill".localized, role: .destructive) { - if let proc = viewModel.confirmForceKill { - Task { await viewModel.forceKill(proc) } - } - } - } message: { - if let proc = viewModel.confirmForceKill { - Text(String( - format: "processes_confirm_force_message".localized, - proc.name, - proc.pid - )) - } - } .sheet(isPresented: $viewModel.showBlacklistAlert) { blacklistSheet } @@ -349,8 +305,28 @@ public struct ProcessesView: View { permission: viewModel.checkPermission(process), isSelected: viewModel.selection.contains(process.pid), settings: settings, - onTerminate: { viewModel.confirmKill = process }, - onForceKill: { viewModel.confirmForceKill = process }, + onTerminate: { + GlassOverlayManager.shared.showAlert( + title: "processes_confirm_terminate".localized, + message: String(format: "processes_confirm_terminate_message".localized, process.name, process.pid), + type: .warning, + primaryButtonTitle: "processes_terminate".localized, + primaryAction: { + Task { await viewModel.terminate(process) } + } + ) + }, + onForceKill: { + GlassOverlayManager.shared.showAlert( + title: "processes_confirm_force".localized, + message: String(format: "processes_confirm_force_message".localized, process.name, process.pid), + type: .critical, + primaryButtonTitle: "processes_force_kill".localized, + primaryAction: { + Task { await viewModel.forceKill(process) } + } + ) + }, onToggleSelection: { viewModel.toggleSelection(process) } ) } diff --git a/MacOSCleaner/Infrastructure/GlassOverlayManager.swift b/MacOSCleaner/Infrastructure/GlassOverlayManager.swift new file mode 100644 index 0000000..d835096 --- /dev/null +++ b/MacOSCleaner/Infrastructure/GlassOverlayManager.swift @@ -0,0 +1,147 @@ +import SwiftUI +import Observation + +public enum ToastType: Sendable { + case info + case success + case warning + case error + + var systemImage: String { + switch self { + case .info: return "info.circle.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + var color: Color { + switch self { + case .info: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } +} + +public enum GlassAlertType: Sendable { + case info + case warning + case critical + + var systemImage: String { + switch self { + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .critical: return "exclamationmark.octagon.fill" + } + } + + var color: Color { + switch self { + case .info: return .blue + case .warning: return .orange + case .critical: return .red + } + } +} + +public struct ToastMessage: Identifiable, Sendable { + public let id = UUID() + public let title: String + public let message: String + public let type: ToastType + public let duration: Double +} + +public struct GlassAlertMessage: Identifiable { + public let id = UUID() + public let title: String + public let message: String + public let type: GlassAlertType + public let primaryButtonTitle: String + public let primaryAction: @MainActor () -> Void + public let secondaryButtonTitle: String? + public let secondaryAction: (@MainActor () -> Void)? + + public init( + title: String, + message: String, + type: GlassAlertType = .warning, + primaryButtonTitle: String, + primaryAction: @escaping @MainActor () -> Void, + secondaryButtonTitle: String? = nil, + secondaryAction: (@MainActor () -> Void)? = nil + ) { + self.title = title + self.message = message + self.type = type + self.primaryButtonTitle = primaryButtonTitle + self.primaryAction = primaryAction + self.secondaryButtonTitle = secondaryButtonTitle + self.secondaryAction = secondaryAction + } +} + +@Observable +@MainActor +public final class GlassOverlayManager { + public static let shared = GlassOverlayManager() + + public var activeToast: ToastMessage? + public var activeAlert: GlassAlertMessage? + + private var toastTask: Task? + + private init() {} + + public func showToast(title: String, message: String, type: ToastType = .info, duration: Double = 3.5) { + toastTask?.cancel() + + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + activeToast = ToastMessage(title: title, message: message, type: type, duration: duration) + } + + toastTask = Task { + try? await Task.sleep(for: .seconds(duration)) + guard !Task.isCancelled else { return } + dismissToast() + } + } + + public func dismissToast() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + activeToast = nil + } + } + + public func showAlert( + title: String, + message: String, + type: GlassAlertType = .warning, + primaryButtonTitle: String, + primaryAction: @escaping @MainActor () -> Void, + secondaryButtonTitle: String? = "cancel".localized, + secondaryAction: (@MainActor () -> Void)? = nil + ) { + withAnimation(.spring(response: 0.38, dampingFraction: 0.82)) { + activeAlert = GlassAlertMessage( + title: title, + message: message, + type: type, + primaryButtonTitle: primaryButtonTitle, + primaryAction: primaryAction, + secondaryButtonTitle: secondaryButtonTitle, + secondaryAction: secondaryAction + ) + } + } + + public func dismissAlert() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + activeAlert = nil + } + } +} diff --git a/MacOSCleaner/Infrastructure/GlassOverlayView.swift b/MacOSCleaner/Infrastructure/GlassOverlayView.swift new file mode 100644 index 0000000..b89f1b0 --- /dev/null +++ b/MacOSCleaner/Infrastructure/GlassOverlayView.swift @@ -0,0 +1,181 @@ +import SwiftUI + +public struct GlassOverlayView: View { + @Bindable var manager: GlassOverlayManager + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + public init(manager: GlassOverlayManager) { + self.manager = manager + } + + public var body: some View { + ZStack { + // Backdrop when alert is shown + if manager.activeAlert != nil { + Color.black.opacity(reduceTransparency ? 0.65 : 0.25) + .ignoresSafeArea() + .transition(.opacity.animation(.easeInOut(duration: 0.25))) + .onTapGesture { + // Dismiss alert on clicking background optionally, + // but for critical alerts we might want to force button click. + } + + alertContainer + .transition(.asymmetric( + insertion: .scale(scale: 0.92).combined(with: .opacity).animation(.spring(response: 0.35, dampingFraction: 0.8)), + removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.25, dampingFraction: 0.85)) + )) + } + + // Toast container (aligned to top right) + VStack { + if let toast = manager.activeToast { + toastView(toast) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity).animation(.spring(response: 0.35, dampingFraction: 0.78)), + removal: .move(edge: .top).combined(with: .opacity).animation(.spring(response: 0.25, dampingFraction: 0.85)) + )) + } + Spacer() + } + .padding(.top, 16) + .padding(.trailing, 16) + .frame(maxWidth: .infinity, alignment: .topTrailing) + } + } + + @ViewBuilder + private var alertContainer: some View { + if let alert = manager.activeAlert { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 18) { + // Pulsing Warning Icon + Image(systemName: alert.type.systemImage) + .font(.system(size: 38)) + .foregroundColor(alert.type.color) + .shadow(color: alert.type.color.opacity(0.3), radius: 8) + + VStack(alignment: .leading, spacing: 8) { + Text(alert.title) + .font(.headline) + .foregroundColor(.primary) + + Text(alert.message) + .font(.subheadline) + .foregroundColor(.secondary) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 20) + + Divider() + + // Action Buttons + HStack(spacing: 12) { + Spacer() + + if let secondaryTitle = alert.secondaryButtonTitle { + Button { + let action = alert.secondaryAction + manager.dismissAlert() + if let action { + action() + } + } label: { + Text(secondaryTitle) + .fontWeight(.medium) + .frame(width: 80, height: 22) + } + .buttonStyle(.plain) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(Color.primary.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .glassEffect() + } + + Button { + let action = alert.primaryAction + manager.dismissAlert() + action() + } label: { + Text(alert.primaryButtonTitle) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(width: 90, height: 22) + } + .buttonStyle(.plain) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(alert.type == .critical ? Color.red : Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.primary.opacity(0.015)) + } + .frame(width: 440) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(NSColor.windowBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.8)) + .glassEffect() + ) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.25), radius: 24, y: 12) + } + } + + @ViewBuilder + private func toastView(_ toast: ToastMessage) -> some View { + HStack(spacing: 12) { + Image(systemName: toast.type.systemImage) + .font(.title2) + .foregroundColor(toast.type.color) + + VStack(alignment: .leading, spacing: 2) { + Text(toast.title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text(toast.message) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 16) + + Button { + manager.dismissToast() + } label: { + Image(systemName: "xmark") + .font(.caption) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(4) + } + .padding(.vertical, 10) + .padding(.horizontal, 14) + .frame(width: 320) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.75)) + .glassEffect() + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(toast.type.color.opacity(0.15), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.12), radius: 10, y: 4) + } +} diff --git a/MacOSCleaner/Infrastructure/LiquidGlassLoaderView.swift b/MacOSCleaner/Infrastructure/LiquidGlassLoaderView.swift new file mode 100644 index 0000000..8ff25ea --- /dev/null +++ b/MacOSCleaner/Infrastructure/LiquidGlassLoaderView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +public struct LiquidGlassLoaderView: View { + let size: CGFloat + let text: String? + + @State private var isAnimating = false + @State private var dotPulse = false + + public init(size: CGFloat = 40, text: String? = nil) { + self.size = size + self.text = text + } + + public var body: some View { + VStack(spacing: 12) { + ZStack { + // Background glass disk + Circle() + .fill(Color.primary.opacity(0.03)) + .glassEffect() + .frame(width: size, height: size) + + // Outer tracking border + Circle() + .stroke(Color.secondary.opacity(0.1), lineWidth: size * 0.08) + .frame(width: size * 0.8, height: size * 0.8) + + // Spinning gradient arc + Circle() + .trim(from: 0.0, to: 0.6) + .stroke( + LinearGradient( + colors: [.accentColor, .accentColor.opacity(0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + style: StrokeStyle(lineWidth: size * 0.08, lineCap: .round) + ) + .frame(width: size * 0.8, height: size * 0.8) + .rotationEffect(Angle(degrees: isAnimating ? 360 : 0)) + .animation( + Animation.linear(duration: 1.0) + .repeatForever(autoreverses: false), + value: isAnimating + ) + + // Glowing glass core dot + Circle() + .fill( + RadialGradient( + colors: [.accentColor, .accentColor.opacity(0.2)], + center: .center, + startRadius: 0, + endRadius: size * 0.15 + ) + ) + .frame(width: size * 0.3, height: size * 0.3) + .scaleEffect(dotPulse ? 1.2 : 0.85) + .opacity(dotPulse ? 0.9 : 0.6) + .animation( + Animation.easeInOut(duration: 0.8) + .repeatForever(autoreverses: true), + value: dotPulse + ) + } + .frame(width: size, height: size) + .shadow(color: .accentColor.opacity(0.15), radius: size * 0.25) + + if let text { + Text(text) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .onAppear { + isAnimating = true + dotPulse = true + } + } +} + +#Preview { + VStack(spacing: 30) { + LiquidGlassLoaderView(size: 40, text: "Scanning system...") + LiquidGlassLoaderView(size: 80) + } + .padding(50) + .background(Color.black.opacity(0.2)) +} diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index de5c63b..30fe013 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 370555A1E46A82B05D6374B0 /* CleanupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16400AE1862B76A844B01CFC /* CleanupView.swift */; }; 3922CCC01F73A38F68A94C2A /* PosixScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F514077EE94AA6AF042FE5B0 /* PosixScanner.swift */; }; 3A9F6B68130F3DAF5916234B /* AppSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8526E6E523335A441DE17858 /* AppSettingsTests.swift */; }; + 410FDB9E38FA3E5F873F1D0B /* GlassOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2E95E11A06D80AF0E0F73 /* GlassOverlayView.swift */; }; 4117A3A7BD3835699732D21D /* MdfindCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9763DE94720F8AF4B9C0AE3 /* MdfindCache.swift */; }; 4189AEA27F9A79D1E00F7157 /* ArtifactClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F9B22B36D401D28509CF70 /* ArtifactClassifier.swift */; }; 42366FBE09FE46182F73C86C /* VerificationEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F655CB3931FC097B4A8836C /* VerificationEngineTests.swift */; }; @@ -85,6 +86,7 @@ 73B0F634314F9E8EE2D4C768 /* DirectorySizeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78F806B7312BFC1297D8E172 /* DirectorySizeCache.swift */; }; 775D5D2C093A529875939FC9 /* CommandRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB93BAD2C057E76B2E0DBD80 /* CommandRunnerTests.swift */; }; 77C8FDB4576E9419A7ACE1E6 /* CandidateCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E94A572C930CB883AFC67CB5 /* CandidateCollector.swift */; }; + 7B4D92F3753583A20718B914 /* LiquidGlassLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11E70859EF02D55B8D38476 /* LiquidGlassLoaderView.swift */; }; 7C4418AE4F14E22136AA7514 /* UnityRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE06D2D275499CE3A8A513EF /* UnityRule.swift */; }; 7C59F9EB60B6D24E70FCFB2D /* FileScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A11C06D6D12353C59E09EF /* FileScanner.swift */; }; 7D40740FE6271009DC9CE325 /* AppDiscoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C1C6C0C2F943213529B9BC /* AppDiscoveryTests.swift */; }; @@ -151,6 +153,7 @@ CF451E1D986FDEB59A0B0F05 /* Confidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61798A3E8B34E827E963D77 /* Confidence.swift */; }; CFA170D8C84D477F84A53303 /* AppDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197DA9FDB4A034B4100A2AFB /* AppDiscovery.swift */; }; D04C2D4589784A88C09C146A /* ProcessCleanupActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA3478B28127BDE2AAFCB8C /* ProcessCleanupActor.swift */; }; + D4E234A2498F162618A4B633 /* GlassOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFA9788CE80DD0C46A4DBCF /* GlassOverlayManager.swift */; }; D5274B5FCD758749A4974B7C /* CleanupTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D71F05F8569A43F8BF66A8 /* CleanupTransaction.swift */; }; D5736DBAF67A7BA78B39290F /* MacOSCleanerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90BF90F2E933654B06A18BA4 /* MacOSCleanerApp.swift */; }; D5883737207237A630360049 /* EvidenceExplanation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF3858BEEB1516C71EB65033 /* EvidenceExplanation.swift */; }; @@ -324,6 +327,7 @@ ADF1EDB5329DABAA20BCAED2 /* UninstallSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallSnapshot.swift; sourceTree = ""; }; AF3858BEEB1516C71EB65033 /* EvidenceExplanation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceExplanation.swift; sourceTree = ""; }; B0086F9F7800BFD540E5F25E /* StartupServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupServicesViewModel.swift; sourceTree = ""; }; + B11E70859EF02D55B8D38476 /* LiquidGlassLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassLoaderView.swift; sourceTree = ""; }; B1DABEFC1D64E9E06931BBF8 /* ScanResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanResult.swift; sourceTree = ""; }; B29CC70A6B8DA27FD15BB199 /* StartupVendorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupVendorSettingsView.swift; sourceTree = ""; }; B421C9DC6196E3977508E1CD /* NavigationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationItem.swift; sourceTree = ""; }; @@ -370,6 +374,7 @@ DF2C09A741A9F06D78858A0C /* EvidenceExplanationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceExplanationTests.swift; sourceTree = ""; }; E11E52499AE453EB667A3BE9 /* DatabaseToolsRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseToolsRule.swift; sourceTree = ""; }; E18410CA889BB8667BE08176 /* VerificationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationEngine.swift; sourceTree = ""; }; + E1A2E95E11A06D80AF0E0F73 /* GlassOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassOverlayView.swift; sourceTree = ""; }; E71786FFCFB9821335C9B9E8 /* CleanupNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupNotifier.swift; sourceTree = ""; }; E7479AAE9D83844B12D74B02 /* TerminalRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRule.swift; sourceTree = ""; }; E7DBFF8A61FF1E6AB77A7B98 /* RunningProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningProcess.swift; sourceTree = ""; }; @@ -389,6 +394,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 = ""; }; FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceSource.swift; sourceTree = ""; }; + FDFA9788CE80DD0C46A4DBCF /* GlassOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassOverlayManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -729,8 +735,11 @@ BCD1414A4103E729B6946047 /* FileCleanupActor.swift */, 78EEF9CB2BE762A51083464F /* FileManager+Size.swift */, 91A11C06D6D12353C59E09EF /* FileScanner.swift */, + FDFA9788CE80DD0C46A4DBCF /* GlassOverlayManager.swift */, + E1A2E95E11A06D80AF0E0F73 /* GlassOverlayView.swift */, 08402BF5E1C6101310B2B476 /* LanguageManager.swift */, 4F30EB8C917D9A5DB59EB765 /* LiquidGlass+Compatibility.swift */, + B11E70859EF02D55B8D38476 /* LiquidGlassLoaderView.swift */, 9E5214D1AD87C67377076C08 /* NotificationManager.swift */, 70E3A139737CC8A7ECBD1900 /* PermissionsManager.swift */, F514077EE94AA6AF042FE5B0 /* PosixScanner.swift */, @@ -999,6 +1008,8 @@ 7C59F9EB60B6D24E70FCFB2D /* FileScanner.swift in Sources */, 597C46E020148F9810D9763B /* FinalCutProRule.swift in Sources */, 4249D6B3C9C4B39B14606220 /* GitClientsRule.swift in Sources */, + D4E234A2498F162618A4B633 /* GlassOverlayManager.swift in Sources */, + 410FDB9E38FA3E5F873F1D0B /* GlassOverlayView.swift in Sources */, 98E6580503F96F10F73A07C3 /* HomebrewRule.swift in Sources */, A579926287D201B899842A4D /* IdentityCache.swift in Sources */, 7E95E7129D7B44DCFE10E2C6 /* JetBrainsRule.swift in Sources */, @@ -1008,6 +1019,7 @@ DDF1A3BD13DF7AA1C39EA144 /* LaunchServiceManager.swift in Sources */, 36CFC6F9A71EE6AA66E178C5 /* LaunchctlCache.swift in Sources */, 89BAEC96A8994A5092900B77 /* LiquidGlass+Compatibility.swift in Sources */, + 7B4D92F3753583A20718B914 /* LiquidGlassLoaderView.swift in Sources */, 0428CB5B74191FAFF78C6F48 /* LittleSnitchRule.swift in Sources */, 9CEF93EA4AC1719CDF985A58 /* LogicProRule.swift in Sources */, D5736DBAF67A7BA78B39290F /* MacOSCleanerApp.swift in Sources */, From 73fbab84a7c9ed71719d0e8ec7b24eec63703381 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 11:25:55 +0300 Subject: [PATCH 12/23] feat: enhance UI with glass card effects, improve cleanup category management, and add Full Disk Access guidance --- .cursor/settings.json | 7 ++ MacOSCleaner/App/RootView.swift | 26 ++-- .../CleanupCategory+FixtureMapping.swift | 119 ++++++++++++++++++ .../Domains/Cleanup/CleanupCoordinator.swift | 2 +- .../Domains/Cleanup/CleanupEngine.swift | 16 +-- .../Domains/Cleanup/CleanupItemManager.swift | 38 +++--- MacOSCleaner/Features/About/AboutView.swift | 12 +- .../Features/Cleanup/CleanupView.swift | 26 ++-- .../Features/Dashboard/DashboardView.swift | 12 +- .../DiskAnalyzer/DiskAnalyzerView.swift | 16 +-- .../Permissions/PermissionsView.swift | 20 ++- .../Features/Processes/ProcessesView.swift | 12 +- .../Features/Settings/SettingsView.swift | 10 ++ .../Settings/StartupVendorSettingsView.swift | 2 +- .../StartupServices/StartupServicesView.swift | 10 +- .../Features/Uninstaller/AppDiscovery.swift | 11 +- .../Features/Uninstaller/AppIdentity.swift | 6 +- .../Uninstaller/UninstallerView.swift | 4 +- .../LiquidGlass+Compatibility.swift | 81 ++++++++++++ .../Infrastructure/PermissionsManager.swift | 33 ++++- .../Infrastructure/SafetyManager.swift | 14 +++ .../MacOSCleanerTests/DiskScannerTests.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/ru.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + 26 files changed, 379 insertions(+), 104 deletions(-) create mode 100644 .cursor/settings.json diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..84542c7 --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "figma": { + "enabled": true + } + } +} diff --git a/MacOSCleaner/App/RootView.swift b/MacOSCleaner/App/RootView.swift index adbacdc..b541250 100644 --- a/MacOSCleaner/App/RootView.swift +++ b/MacOSCleaner/App/RootView.swift @@ -31,14 +31,16 @@ struct RootView: View { .navigationTitle("app_title".localized) .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280) } detail: { - if let selectedItem { - contentView(for: selectedItem) - .frame(minWidth: 800, minHeight: 600) - } else { - Text("sidebar_select_item".localized) - .foregroundColor(.secondary) - .frame(minWidth: 800, minHeight: 600) + Group { + if let selectedItem { + contentView(for: selectedItem) + } else { + Text("sidebar_select_item".localized) + .foregroundColor(.secondary) + } } + .frame(minWidth: 800, minHeight: 600) + .macOS27ScreenBackground() } GlassOverlayView(manager: GlassOverlayManager.shared) @@ -48,6 +50,8 @@ struct RootView: View { } .onAppear { appSettings.applyTheme() + } + .task { permissionsManager.refresh() permissionsManager.showGuidanceIfNeeded() } @@ -128,18 +132,18 @@ struct SidebarItemRow: View { .background( Group { if isSelected { - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 10) .fill(Color.accentColor.opacity(0.12)) .glassEffect() } else if isHovered { - RoundedRectangle(cornerRadius: 8) - .fill(Color.primary.opacity(0.04)) + RoundedRectangle(cornerRadius: 10) + .fill(Color.primary.opacity(0.05)) } else { Color.clear } } ) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: 10)) .animation(.easeInOut(duration: 0.15), value: isSelected) .animation(.easeInOut(duration: 0.1), value: isHovered) } diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift b/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift index 0cc7e4a..60cb68b 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift @@ -6,6 +6,125 @@ extension CleanupCategory { "category.\(rawValue)".localized } + public var previewLabels: Set { + var labels: Set = [localizedTitle] + + switch self { + case .appCaches: + labels.insert("App caches") + labels.insert("Selected app caches") + case .packageManagers: + labels.insert("Package managers") + labels.formUnion(["Homebrew cache", "npm cache", "yarn cache", "pnpm store", "CocoaPods cache"]) + case .gradleMaven: + labels.insert("Gradle caches + wrapper + daemon") + case .flutterDart: + labels.insert("Dart/Flutter package caches") + case .xcode: + labels.insert("Xcode cleanup") + case .iosSimulators: + labels.insert("Simulator caches") + case .androidCaches: + labels.insert("Android build caches") + case .androidSDK: + labels.insert("Android SDK cleanup") + case .ideCaches: + labels.insert("IDE / Electron caches") + case .browserCaches: + labels.insert("Browser caches") + case .messagingMedia: + labels.insert("Messaging / media caches") + case .docker: + labels.insert("Docker") + case .languageCaches: + labels.insert("Language caches") + case .userLogs: + labels.insert("User logs") + case .systemCaches: + labels.insert("System caches") + case .appContainers: + labels.insert("App container caches") + case .dotfileCaches: + labels.insert("Dotfile caches") + case .scatteredJunk: + labels.insert("Scattered junk") + case .orphanedRemnants: + labels.insert("Orphaned remnants") + case .orphanedFiles: + labels.insert("Orphaned files") + case .largeFiles: + labels.insert("Large files") + case .dynamicCacheDiscovery: + labels.formUnion(["Dynamic cache discovery", "Auto-cleanable", "Review manually"]) + case .timeMachineSnapshots: + labels.insert("Time Machine Snapshots") + case .iosBackups: + labels.insert("iOS Backups") + case .mailDownloads: + labels.insert("Mail Downloads") + case .savedAppState: + labels.insert("Saved Application State") + case .crashReporter: + labels.insert("Crash Reporter") + case .assetsV2: + labels.insert("AssetsV2 / iWork Templates") + case .cloudKitCache: + labels.insert("CloudKit Cache") + case .swiftPMCache: + labels.insert("Swift Package Manager Cache") + case .carthageCache: + labels.insert("Carthage Cache") + case .steamCache: + labels.insert("Steam Cache") + case .teamsCache: + labels.insert("Microsoft Teams Cache") + case .adobeCaches: + labels.insert("Adobe Caches") + case .chromeExtraCaches: + labels.insert("Chrome Extra Caches") + case .ideOldVersions: + labels.insert("Old IDE versions") + case .launchAgents: + labels.insert("Launch Agents") + case .launchDaemons: + labels.insert("Launch Daemons") + case .privilegedHelpers: + labels.insert("Privileged Helper Tools") + case .pkgReceipts: + labels.insert("Package Receipts") + case .internetPlugins: + labels.insert("Internet Plugins") + case .sharedFileLists: + labels.insert("Shared File Lists") + case .cloudDocs: + labels.insert("Cloud Docs") + case .photosCache: + labels.insert("Photos Cache") + case .voiceMemos: + labels.insert("Voice Memos") + case .garageBandLogic: + labels.insert("GarageBand / Logic Pro") + case .iMovieFinalCut: + labels.insert("iMovie / Final Cut") + case .garminFitbit: + labels.insert("Garmin / Fitbit") + case .oldBackups: + labels.insert("Old Backups") + case .dnsFlush: + labels.insert("DNS Cache") + case .fontCache: + labels.insert("Font Cache") + case .sleepImage: + labels.insert("Sleep Image") + case .duplicateFiles: + labels.insert("Duplicate Files") + case .unusedApps: + labels.insert("Unused Apps") + } + + return labels + } + public static func fromFixturePathType(_ pathType: CleanupPathType) -> [CleanupCategory] { switch pathType { case .caches: diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift index c2f979c..b862cee 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift @@ -157,7 +157,7 @@ public final class CleanupCoordinator: @unchecked Sendable { try await self.trashManager.requestTrashAccess() } - let categories = options.categories() + let categories = self.itemManager.selectedCleanupCategories(from: options.categories()) var records: [OperationRecord] = [] let results = try await self.engine.run(categories: categories, dryRun: false, options: options) { [weak self] event in diff --git a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift index 84f93a7..332973a 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift @@ -173,7 +173,7 @@ public actor CleanupEngine { var results: [CleanupEngineResult] = [] let total = categories.count - let maxConcurrency = ProcessInfo.processInfo.activeProcessorCount + let maxConcurrency = dryRun ? ProcessInfo.processInfo.activeProcessorCount : 1 var pending = Array(categories.enumerated()) var completedCount = 0 @@ -641,15 +641,10 @@ extension CleanupEngine { "\(home)/Library/Application Support/Google/GoogleUpdater", "\(home)/Library/Google/GoogleSoftwareUpdate", "\(home)/Library/HTTPStorages/com.google.GoogleUpdater", - "\(home)/Library/Caches/org.carthage.CarthageKit", - "\(home)/Library/Caches/CocoaPods", - "\(home)/Library/Caches/pip", - "\(home)/Library/Caches/Homebrew", "\(home)/Library/Caches/ms-playwright-go", "\(home)/Library/Caches/com.spotify.client", "\(home)/Library/Caches/com.apple.dt.Xcode", "\(home)/Library/Caches/com.apple.dt.instruments", - "\(home)/Library/Caches/org.swift.swiftpm", "\(home)/Library/Caches/com.plausiblelabs.crashreporter.data", "\(home)/Library/Caches/JetBrains", "\(home)/Library/Caches/@opencode-aidesktop-updater" @@ -1764,6 +1759,7 @@ extension CleanupEngine { // Python "\(home)/Library/Caches/pypoetry", "\(home)/Library/Caches/uv", + "\(home)/Library/Caches/pip", "\(home)/.cache/pip", "\(home)/.cache/pypoetry", "\(home)/.cache/uv", @@ -2665,7 +2661,13 @@ extension CleanupEngine { } else { reviewItems.append((entry, size)) progress?(.log(" ℹ \(entry) — \(Self.formatBytes(size)) (review manually)")) - emitFileItem(CleanupFileItem(path: entryPath, sizeBytes: size, modificationDate: nil, isDirectory: true), category: "Dynamic cache discovery", parentName: "Review manually", progress: progress) + progress?(.preview( + label: "\(entry) — \(Self.shortPath(entryPath))", + sizeMB: Int(size / (1024 * 1024)), + deletable: false, + parent: "Review manually", + description: "Review this cache manually before deleting it." + )) } } else { // In cleanup mode: only auto-clean safe patterns >= threshold diff --git a/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift b/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift index 230d290..94582be 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift @@ -28,23 +28,11 @@ public final class CleanupItemManager { } public var selectedSizeMB: Int { - var total = 0 - for item in items { - if item.isSelected { - total += item.sizeMB - } - } - return total + Int(selectedSizeBytes / (1024 * 1024)) } public var selectedSizeBytes: Int64 { - var total: Int64 = 0 - for item in items { - if item.isSelected { - total += item.sizeBytes - } - } - return total + items.reduce(0) { $0 + Self.selectedSizeBytes(for: $1) } } // MARK: - File Item Append (new hierarchical flow) @@ -167,6 +155,14 @@ public final class CleanupItemManager { } } + public func selectedCleanupCategories(from categories: [CleanupCategory]) -> [CleanupCategory] { + let selectedLabels = Set(items.filter { Self.selectedSizeBytes(for: $0) > 0 }.map(\.label)) + + return categories.filter { category in + !category.previewLabels.isDisjoint(with: selectedLabels) || !hasPreviewItem(for: category) + } + } + // MARK: - Expansion public func toggleCategoryExpansion(_ categoryId: UUID) { @@ -218,6 +214,20 @@ public final class CleanupItemManager { } } + private static func selectedSizeBytes(for item: CleanupPreviewItem) -> Int64 { + if item.children.isEmpty { + return item.isSelected ? item.sizeBytes : 0 + } + + return item.children.reduce(0) { $0 + selectedSizeBytes(for: $1) } + } + + private func hasPreviewItem(for category: CleanupCategory) -> Bool { + items.contains { item in + category.previewLabels.contains(item.label) + } + } + static func shortLabel(from path: String) -> String { let home = FileManager.default.homeDirectoryForCurrentUser.path let expanded = path.replacingOccurrences(of: "~", with: home) diff --git a/MacOSCleaner/Features/About/AboutView.swift b/MacOSCleaner/Features/About/AboutView.swift index 8af3f72..6c57249 100644 --- a/MacOSCleaner/Features/About/AboutView.swift +++ b/MacOSCleaner/Features/About/AboutView.swift @@ -46,6 +46,12 @@ struct AboutView: View { } private var content: some View { + GlassEffectContainer { + contentStack + } + } + + private var contentStack: some View { VStack(spacing: 16) { if let update = availableUpdate { updateBanner(version: update) @@ -111,8 +117,7 @@ struct AboutView: View { Spacer() } .padding(14) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .glassCard(cornerRadius: 10) } private var linksCard: some View { @@ -147,8 +152,7 @@ struct AboutView: View { .contentShape(Rectangle()) } } - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .glassCard(cornerRadius: 10) .buttonStyle(.plain) } } diff --git a/MacOSCleaner/Features/Cleanup/CleanupView.swift b/MacOSCleaner/Features/Cleanup/CleanupView.swift index 7cd9162..be87bd2 100644 --- a/MacOSCleaner/Features/Cleanup/CleanupView.swift +++ b/MacOSCleaner/Features/Cleanup/CleanupView.swift @@ -5,8 +5,6 @@ public struct CleanupView: View { @State private var showLogs = false @State private var showCopiedHint = false @State private var scrollTaskBox = ScrollTaskBox() - @Environment(\.colorScheme) private var colorScheme - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency public init(viewModel: CleanupViewModel) { self.viewModel = viewModel @@ -34,18 +32,15 @@ public struct CleanupView: View { footer } - .background( - ZStack { - Color(NSColor.windowBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15) - if viewModel.state == .scanning || viewModel.state == .executing { - LinearGradient( - colors: [.accentColor.opacity(0.08), .accentColor.opacity(0.02), .clear], - startPoint: .top, - endPoint: .bottom - ) - } + .background { + if viewModel.state == .scanning || viewModel.state == .executing { + LinearGradient( + colors: [.accentColor.opacity(0.08), .accentColor.opacity(0.02), .clear], + startPoint: .top, + endPoint: .bottom + ) } - ) + } } } @@ -157,9 +152,8 @@ public struct CleanupView: View { } } .padding() - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.1), lineWidth: 0.5)) + .glassCard(cornerRadius: 12) + .padding(.horizontal, 32) // Start button Button(action: { viewModel.startScan() }) { diff --git a/MacOSCleaner/Features/Dashboard/DashboardView.swift b/MacOSCleaner/Features/Dashboard/DashboardView.swift index 85c3af2..115b8b5 100644 --- a/MacOSCleaner/Features/Dashboard/DashboardView.swift +++ b/MacOSCleaner/Features/Dashboard/DashboardView.swift @@ -3,7 +3,6 @@ import Charts struct DashboardView: View { @StateObject private var viewModel: DashboardViewModel - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency init(journal: TransactionJournal) { _viewModel = StateObject(wrappedValue: DashboardViewModel(journal: journal)) @@ -22,7 +21,6 @@ struct DashboardView: View { } .padding(24) } - .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15)) } .task { await viewModel.refresh() @@ -51,7 +49,7 @@ struct DashboardView: View { } .padding() .frame(maxWidth: .infinity, alignment: .leading) - .glassEffect() + .glassCard() } private var diskUsageCard: some View { @@ -85,7 +83,7 @@ struct DashboardView: View { } } .padding() - .glassEffect() + .glassCard() } private var statsCard: some View { @@ -101,7 +99,7 @@ struct DashboardView: View { } .padding() .frame(maxWidth: .infinity, alignment: .leading) - .glassEffect() + .glassCard() } private var recentOperationsSection: some View { @@ -114,7 +112,7 @@ struct DashboardView: View { .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 40) - .glassEffect() + .glassCard() } else { VStack(spacing: 0) { ForEach(viewModel.recentTransactions) { transaction in @@ -124,7 +122,7 @@ struct DashboardView: View { } } } - .glassEffect() + .glassCard() } } } diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift index 4e7868d..99b9de6 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift @@ -3,7 +3,6 @@ import SwiftUI public struct DiskAnalyzerView: View { let settings: AppSettings @State private var viewModel = DiskAnalyzerViewModel() - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency public init(settings: AppSettings) { self.settings = settings @@ -73,23 +72,23 @@ public struct DiskAnalyzerView: View { Text(category.localizedName) .font(.callout) .foregroundColor(viewModel.selectedCategory == category ? .white : .primary) - .padding(.horizontal, 10) + .padding(.horizontal, 12) .padding(.vertical, 4) } .buttonStyle(.plain) .background( Group { if viewModel.selectedCategory == category { - RoundedRectangle(cornerRadius: 8) + Capsule() .fill(Color.accentColor) } else { - RoundedRectangle(cornerRadius: 8) + Capsule() .fill(Color.primary.opacity(0.04)) - .glassEffect() + .glassCapsule() } } ) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(Capsule()) } } .padding(.vertical, 4) @@ -155,10 +154,7 @@ public struct DiskAnalyzerView: View { .padding(.horizontal, 4) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.2)) - ) + .glassCard(cornerRadius: 12) } } diff --git a/MacOSCleaner/Features/Permissions/PermissionsView.swift b/MacOSCleaner/Features/Permissions/PermissionsView.swift index ed4ce58..8f16f55 100644 --- a/MacOSCleaner/Features/Permissions/PermissionsView.swift +++ b/MacOSCleaner/Features/Permissions/PermissionsView.swift @@ -9,12 +9,14 @@ struct PermissionsView: View { headerSection ScrollView { - VStack(alignment: .leading, spacing: 20) { - statusCard - instructionsCard - actionButtons + GlassEffectContainer { + VStack(alignment: .leading, spacing: 20) { + statusCard + instructionsCard + actionButtons + } + .padding(24) } - .padding(24) } dismissBar @@ -76,9 +78,7 @@ struct PermissionsView: View { } } .padding(16) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + .glassCard(cornerRadius: 12) } // MARK: - Instructions Card @@ -97,9 +97,7 @@ struct PermissionsView: View { } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.04), radius: 4, y: 1) + .glassCard(cornerRadius: 12) } // MARK: - Buttons diff --git a/MacOSCleaner/Features/Processes/ProcessesView.swift b/MacOSCleaner/Features/Processes/ProcessesView.swift index 9038ebc..62556fb 100644 --- a/MacOSCleaner/Features/Processes/ProcessesView.swift +++ b/MacOSCleaner/Features/Processes/ProcessesView.swift @@ -4,7 +4,6 @@ public struct ProcessesView: View { let settings: AppSettings @State private var viewModel = ProcessesViewModel() @State private var isEditMode = false - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency public init(settings: AppSettings) { self.settings = settings @@ -52,7 +51,6 @@ public struct ProcessesView: View { } } } - .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15)) } .sheet(isPresented: $viewModel.showBlacklistAlert) { blacklistSheet @@ -199,9 +197,9 @@ public struct ProcessesView: View { .textFieldStyle(.plain) .font(.system(size: 13)) } - .padding(8) - .background(Color(NSColor.textBackgroundColor)) - .cornerRadius(8) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .glassCapsule() } private var groupedProcessList: some View { @@ -214,7 +212,7 @@ public struct ProcessesView: View { } .padding(.horizontal) .padding(.vertical, 8) - .glassEffect() + .glassCard() .padding() } } @@ -229,7 +227,7 @@ public struct ProcessesView: View { } .padding(.horizontal) .padding(.vertical, 8) - .glassEffect() + .glassCard() .padding() } } diff --git a/MacOSCleaner/Features/Settings/SettingsView.swift b/MacOSCleaner/Features/Settings/SettingsView.swift index ec96cae..e6dc52a 100644 --- a/MacOSCleaner/Features/Settings/SettingsView.swift +++ b/MacOSCleaner/Features/Settings/SettingsView.swift @@ -28,6 +28,7 @@ struct SettingsView: View { resetSection } .formStyle(.grouped) + .scrollContentBackground(.hidden) .confirmationDialog( "settings_reset_confirm_title".localized, isPresented: $showResetConfirmation, @@ -82,6 +83,15 @@ struct SettingsView: View { Label("settings_check_permissions".localized, systemImage: "arrow.clockwise") } .buttonStyle(.plain) + + if !permissionsManager.hasFullDiskAccess { + Button { + permissionsManager.requestGuidanceAgain() + } label: { + Label("settings_show_permission_guide".localized, systemImage: "hand.raised") + } + .buttonStyle(.plain) + } } header: { Label("settings_permissions".localized, systemImage: "lock.shield") } diff --git a/MacOSCleaner/Features/Settings/StartupVendorSettingsView.swift b/MacOSCleaner/Features/Settings/StartupVendorSettingsView.swift index bee737f..03361b4 100644 --- a/MacOSCleaner/Features/Settings/StartupVendorSettingsView.swift +++ b/MacOSCleaner/Features/Settings/StartupVendorSettingsView.swift @@ -101,7 +101,7 @@ struct StartupVendorSettingsView: View { } } } - .background(Color(NSColor.textBackgroundColor)) + .background(Color(NSColor.textBackgroundColor).opacity(0.6)) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) diff --git a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift index abae309..c7e4305 100644 --- a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift +++ b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift @@ -3,7 +3,6 @@ import SwiftUI public struct StartupServicesView: View { let settings: AppSettings @State private var viewModel = StartupServicesViewModel() - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency public var body: some View { GlassEffectContainer { @@ -26,7 +25,6 @@ public struct StartupServicesView: View { serviceList } } - .background(Color(NSColor.controlBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.15)) } .onAppear { Task { await viewModel.scan() } } } @@ -120,16 +118,16 @@ public struct StartupServicesView: View { : Color.secondary.opacity(0.1)) ) } - .padding(.horizontal, 8) + .padding(.horizontal, 10) .padding(.vertical, 4) .background( - RoundedRectangle(cornerRadius: 6) + Capsule() .fill(viewModel.filter == tag ? (color ?? Color.accentColor).opacity(0.1) : Color.clear) ) .overlay( - RoundedRectangle(cornerRadius: 6) + Capsule() .stroke(viewModel.filter == tag ? (color ?? Color.accentColor).opacity(0.3) : Color.clear, lineWidth: 1) @@ -152,7 +150,7 @@ public struct StartupServicesView: View { } .padding(.horizontal) .padding(.vertical, 8) - .glassEffect() + .glassCard() .padding() } } diff --git a/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift b/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift index 558fd9c..403586b 100644 --- a/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift +++ b/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift @@ -56,6 +56,15 @@ public actor AppDiscovery { } } - return Array(Set(urls)) + return Array(Set(urls)).filter { !isSystemComponent($0) } + } + + /// Apple's own apps are uninstallable only when installed in /Applications (Xcode, GarageBand, etc.). + /// A `com.apple.*` bundle anywhere else (Application Support, DerivedData) is an OS component + /// (e.g. Script Editor templates in /Library/Application Support/Script Editor) and must not be listed. + private func isSystemComponent(_ url: URL) -> Bool { + guard !url.path.hasPrefix("/Applications/") else { return false } + guard let bundleID = Bundle(url: url)?.bundleIdentifier else { return false } + return bundleID.hasPrefix("com.apple.") } } diff --git a/MacOSCleaner/Features/Uninstaller/AppIdentity.swift b/MacOSCleaner/Features/Uninstaller/AppIdentity.swift index 8d89a9b..4283fef 100644 --- a/MacOSCleaner/Features/Uninstaller/AppIdentity.swift +++ b/MacOSCleaner/Features/Uninstaller/AppIdentity.swift @@ -150,7 +150,9 @@ private func scanHelpers(url: URL) async -> Set { return Set(helperNames.map { $0.deletingPathExtension().lastPathComponent }) } -private let vendorStopwords: Set = ["com", "org", "net", "io", "app", "co", "inc", "ltd", "llc", "uk", "us", "eu"] +// "apple" is a stopword: deriving vendor "Apple" from com.apple.* bundle IDs makes +// OS-owned dirs like /Library/Application Support/Apple match as residuals. +private let vendorStopwords: Set = ["com", "org", "net", "io", "app", "co", "inc", "ltd", "llc", "uk", "us", "eu", "apple"] private func deriveVendorNames(bundleID: String, appName: String, authority: String?) -> Set { var names = Set() @@ -161,7 +163,7 @@ private func deriveVendorNames(bundleID: String, appName: String, authority: Str names.insert(p.capitalized) names.insert(p.prefix(1).uppercased() + p.dropFirst()) } - if let first = parts.first(where: { !vendorStopwords.contains($0.lowercased()) }) { + if let first = parts.first(where: { $0.count >= 4 && !vendorStopwords.contains($0.lowercased()) }) { names.insert(first.capitalized) } diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift index 2305200..c2ba079 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift @@ -85,6 +85,7 @@ struct UninstallerView: View { ) } .listStyle(.inset) + .scrollContentBackground(.hidden) } } } @@ -106,7 +107,6 @@ struct UninstallerView: View { .layoutPriority(1) // Occupy remaining space } } - .background(Color(NSColor.windowBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.1)) } .navigationTitle("uninstaller_title".localized) .searchable(text: $searchText, placement: .toolbar, prompt: "uninstaller_search".localized) @@ -403,7 +403,7 @@ struct UninstallerView: View { ) } } - .glassEffect() + .glassCard(cornerRadius: 12) } } } diff --git a/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift b/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift index c3aa1f4..c34d5fd 100644 --- a/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift +++ b/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift @@ -72,6 +72,87 @@ public struct VisualEffectView: NSViewRepresentable { } #endif +// MARK: - macOS 27 Shared Surfaces + +public extension View { + /// Liquid Glass card surface with macOS 27 rounded chrome (hairline border + soft shadow). + func glassCard(cornerRadius: CGFloat = 16) -> some View { + modifier(GlassCardModifier(cornerRadius: cornerRadius)) + } + + /// Capsule Liquid Glass surface for chips and search fields. + func glassCapsule() -> some View { + // Explicit Glass type avoids ambiguity between the shim and SwiftUI.Glass (SDK 26+). + glassEffect(Glass.regular, in: Capsule()) + } + + /// Wallpaper-tinted screen background behind glass panels (macOS 27 look). + func macOS27ScreenBackground() -> some View { + modifier(MacOS27ScreenBackgroundModifier()) + } +} + +private struct GlassCardModifier: ViewModifier { + let cornerRadius: CGFloat + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + private static let borderColor: Color = Color.primary.opacity(0.07) + + func body(content: Content) -> some View { + Group { + if reduceTransparency { + content.background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color(NSColor.controlBackgroundColor)) + ) + } else { + content.glassEffect(Glass.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) + } + } + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(Self.borderColor, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.07), radius: 12, y: 4) + } +} + +private struct MacOS27ScreenBackgroundModifier: ViewModifier { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + content.background { + if reduceTransparency { + Color(NSColor.windowBackgroundColor).ignoresSafeArea() + } else { + ZStack { + Color(NSColor.windowBackgroundColor) + LinearGradient( + stops: [ + .init(color: warmTint, location: 0.0), + .init(color: .clear, location: 0.55), + .init(color: coolTint, location: 1.0), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + .ignoresSafeArea() + } + } + } + + // Warm champagne → cool lavender-grey, echoing the macOS 27 wallpaper palette. + private var warmTint: Color { + Color(red: 0.95, green: 0.86, blue: 0.70).opacity(colorScheme == .dark ? 0.10 : 0.26) + } + + private var coolTint: Color { + Color(red: 0.56, green: 0.58, blue: 0.72).opacity(colorScheme == .dark ? 0.14 : 0.18) + } +} + public extension View { @ViewBuilder func glassButtonStyle() -> some View { diff --git a/MacOSCleaner/Infrastructure/PermissionsManager.swift b/MacOSCleaner/Infrastructure/PermissionsManager.swift index cc6f2d0..9c2c8ef 100644 --- a/MacOSCleaner/Infrastructure/PermissionsManager.swift +++ b/MacOSCleaner/Infrastructure/PermissionsManager.swift @@ -27,15 +27,21 @@ public final class PermissionsManager { /// Whether the user has dismissed the guidance permanently. public private(set) var guidanceDismissed = false - + + /// Whether Full Disk Access was ever granted (persisted across launches). + public private(set) var fdaEverGranted = false + private let userDefaultsKey = "com.macoscleaner.guidanceDismissed" - + private let fdaGrantedKey = "com.macoscleaner.fdaGranted" + public init() { self.guidanceDismissed = UserDefaults.standard.bool(forKey: userDefaultsKey) + self.fdaEverGranted = UserDefaults.standard.bool(forKey: fdaGrantedKey) self.hasFullDiskAccess = Self.checkFullDiskAccess() self.hasAccessibility = Self.checkAccessibility() self.hasAutomation = Self.checkAutomation() self.hasTrashAccess = Self.checkTrashAccess() + persistFDAState() } /// Checks if the application has Full Disk Access by attempting to read protected paths. @@ -111,11 +117,20 @@ public final class PermissionsManager { hasAccessibility = Self.checkAccessibility() hasAutomation = Self.checkAutomation() hasTrashAccess = Self.checkTrashAccess() - + persistFDAState() + if hasFullDiskAccess && showGuidance { showGuidance = false } } + + /// Persists the Full Disk Access grant so the app remembers it across launches. + private func persistFDAState() { + if hasFullDiskAccess { + fdaEverGranted = true + UserDefaults.standard.set(true, forKey: fdaGrantedKey) + } + } /// Returns true if all critical permissions are granted. public var allCriticalPermissionsGranted: Bool { @@ -186,4 +201,16 @@ public final class PermissionsManager { public func dismissGuidanceTemporarily() { showGuidance = false } + + /// Re-enables the permission guidance after a permanent dismissal and shows it, + /// for when the user changes their mind (e.g. from Settings). + public func requestGuidanceAgain() { + guidanceDismissed = false + UserDefaults.standard.set(false, forKey: userDefaultsKey) + refresh() + if !hasFullDiskAccess { + showGuidance = true + } + Logger.permissions.info("Guidance re-enabled by user") + } } diff --git a/MacOSCleaner/Infrastructure/SafetyManager.swift b/MacOSCleaner/Infrastructure/SafetyManager.swift index dc849b8..582b6c0 100644 --- a/MacOSCleaner/Infrastructure/SafetyManager.swift +++ b/MacOSCleaner/Infrastructure/SafetyManager.swift @@ -10,6 +10,10 @@ public struct SafetyManager: Sendable { private let refuseList: [String] private let allowedExceptions: [String] + // OS-owned dirs living inside allowed exception roots (e.g. /Library/Application Support). + // Checked before exceptions, so they can never be deleted. + private let hardRefuseList: [String] + public init(allowedExceptions: [String] = []) { let home = NSHomeDirectory() self.refuseList = [ @@ -78,6 +82,12 @@ public struct SafetyManager: Sendable { ] self.allowedExceptions = defaultExceptions + allowedExceptions + + self.hardRefuseList = [ + "/Library/Application Support/Apple", + "/Library/Application Support/Script Editor", + "\(home)/Library/Application Support/Apple", + ] } public func validate(url: URL) throws { @@ -97,6 +107,10 @@ public struct SafetyManager: Sendable { let pathsToCheck = [path, resolvedPath] for p in pathsToCheck { + for refused in hardRefuseList where p == refused || p.hasPrefix(refused + "/") { + throw SafetyError.protectedPath(refused) + } + let isException = allowedExceptions.contains { exception in p == exception || p.hasPrefix(exception + "/") } diff --git a/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift b/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift index 5f9fbd4..187e78e 100644 --- a/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/DiskScannerTests.swift @@ -66,6 +66,6 @@ final class DiskScannerTests: XCTestCase { XCTAssertEqual(FileCategory.from(url: audioURL), .audio) XCTAssertEqual(FileCategory.from(url: appURL), .apps) XCTAssertEqual(FileCategory.from(url: docURL), .docs) - XCTAssertEqual(FileCategory.from(url: devURL), .dev) + XCTAssertEqual(FileCategory.from(url: devURL), .all) } } diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index a72c6a1..867364b 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -357,6 +357,7 @@ "settings_fda_description" = "Required for cleaning system caches and application data."; "settings_open_settings" = "Open Settings"; "settings_check_permissions" = "Check Permissions"; +"settings_show_permission_guide" = "Grant Full Disk Access"; /* Format strings */ "dashboard_used_percent_format" = "%lld%%"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index cb2dcb5..5ffad5a 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -360,6 +360,7 @@ "settings_fda_description" = "Necesario para limpiar cachés del sistema y datos de aplicaciones."; "settings_open_settings" = "Abrir ajustes"; "settings_check_permissions" = "Verificar permisos"; +"settings_show_permission_guide" = "Conceder acceso total al disco"; // Format strings "dashboard_used_percent_format" = "%lld%%"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index f9e0e4d..867e21a 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -347,6 +347,7 @@ "settings_fda_description" = "Необходим для очистки системных кэшей и данных приложений."; "settings_open_settings" = "Открыть настройки"; "settings_check_permissions" = "Проверить разрешения"; +"settings_show_permission_guide" = "Предоставить полный доступ к диску"; /* Format strings */ "dashboard_used_percent_format" = "%lld%%"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index bed5061..7019a7e 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -343,6 +343,7 @@ "settings_fda_description" = "Необхідний для очищення системних кешів та даних додатків."; "settings_open_settings" = "Відкрити налаштування"; "settings_check_permissions" = "Перевірити дозволи"; +"settings_show_permission_guide" = "Надати повний доступ до диска"; /* Format strings */ "dashboard_used_percent_format" = "%lld%%"; From fa60029316b17f36255fcd11896adb1d84675edb Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 12:00:12 +0300 Subject: [PATCH 13/23] feat: refactor sidebar navigation and enhance UI with new section layout, improved list styles, and localized titles --- MacOSCleaner/App/RootView.swift | 99 ++++------ .../DiskAnalyzer/DiskAnalyzerView.swift | 104 +++++----- .../Features/Processes/ProcessesView.swift | 178 +++++++----------- .../StartupServices/StartupServicesView.swift | 27 +-- .../LiquidGlass+Compatibility.swift | 41 ---- MacOSCleaner/Models/NavigationItem.swift | 15 ++ .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + 10 files changed, 179 insertions(+), 293 deletions(-) diff --git a/MacOSCleaner/App/RootView.swift b/MacOSCleaner/App/RootView.swift index b541250..fe58d8c 100644 --- a/MacOSCleaner/App/RootView.swift +++ b/MacOSCleaner/App/RootView.swift @@ -11,36 +11,34 @@ struct RootView: View { var body: some View { ZStack { NavigationSplitView { - ScrollView { - VStack(alignment: .leading, spacing: 4) { - ForEach(NavigationItem.allCases) { item in - SidebarItemRow( - item: item, - isSelected: selectedItem == item, - action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { - selectedItem = item - } - } - ) + List(selection: $selectedItem) { + ForEach(SidebarSection.all) { section in + Section { + ForEach(section.items) { item in + Label(item.localizedTitle, systemImage: item.systemImage) + .tag(item) + } + } header: { + if let titleKey = section.titleKey { + Text(titleKey.localized) + } } } - .padding(.horizontal, 8) - .padding(.top, 12) } - .navigationTitle("app_title".localized) - .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280) + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280) } detail: { Group { if let selectedItem { contentView(for: selectedItem) + .modifier(ScreenNavigationTitleModifier(item: selectedItem)) } else { Text("sidebar_select_item".localized) .foregroundColor(.secondary) } } + .scrollEdgeEffectStyle(.hard, for: .top) .frame(minWidth: 800, minHeight: 600) - .macOS27ScreenBackground() } GlassOverlayView(manager: GlassOverlayManager.shared) @@ -87,6 +85,23 @@ struct RootView: View { } } +private struct ScreenNavigationTitleModifier: ViewModifier { + let item: NavigationItem + + func body(content: Content) -> some View { + if item == .dashboard { + content + .navigationTitle("") + .toolbar(removing: .title) + .toolbarVisibility(.hidden, for: .windowToolbar) + } else { + content + .navigationTitle(item.localizedTitle) + .toolbarVisibility(.visible, for: .windowToolbar) + } + } +} + #Preview { let journal = TransactionJournal() let settings = AppSettings() @@ -104,53 +119,3 @@ struct RootView: View { ) } -struct SidebarItemRow: View { - let item: NavigationItem - let isSelected: Bool - let action: () -> Void - - @State private var isHovered = false - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - Image(systemName: item.systemImage) - .font(.title3) - .foregroundColor(isSelected ? .accentColor : .secondary) - .frame(width: 24, height: 24) - - Text(item.localizedTitle) - .font(.body) - .fontWeight(isSelected ? .semibold : .medium) - .foregroundColor(isSelected ? .primary : .secondary) - - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(Rectangle()) - .background( - Group { - if isSelected { - RoundedRectangle(cornerRadius: 10) - .fill(Color.accentColor.opacity(0.12)) - .glassEffect() - } else if isHovered { - RoundedRectangle(cornerRadius: 10) - .fill(Color.primary.opacity(0.05)) - } else { - Color.clear - } - } - ) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .animation(.easeInOut(duration: 0.15), value: isSelected) - .animation(.easeInOut(duration: 0.1), value: isHovered) - } - .buttonStyle(.plain) - .onHover { hover in - isHovered = hover - } - } -} - diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift index 99b9de6..718678d 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift @@ -11,8 +11,6 @@ public struct DiskAnalyzerView: View { public var body: some View { GlassEffectContainer { VStack(spacing: 16) { - headerView - categoryFilterView if viewModel.isScanning { @@ -25,6 +23,15 @@ public struct DiskAnalyzerView: View { } .padding() } + .navigationSubtitle(viewModel.currentURL?.path ?? "") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("disk_analyzer_scan".localized) { + viewModel.selectFolderAndScan() + } + .buttonStyle(.borderedProminent) + } + } .onAppear { if viewModel.rootURL == nil { viewModel.startScan(for: FileManager.default.homeDirectoryForCurrentUser) @@ -32,71 +39,54 @@ public struct DiskAnalyzerView: View { } } - private var headerView: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("disk_analyzer_title".localized) - .font(.title2) - .fontWeight(.bold) - - if let currentURL = viewModel.currentURL { - Text(currentURL.path) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.head) - } - } - - Spacer() - - Button("disk_analyzer_scan".localized) { - viewModel.selectFolderAndScan() - } - .buttonStyle(.borderedProminent) - } - .padding(.horizontal, 4) - } - private var categoryFilterView: some View { HStack { Spacer() - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(FileCategory.allCases, id: \.self) { category in - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - viewModel.selectedCategory = category - } - }) { - Text(category.localizedName) - .font(.callout) - .foregroundColor(viewModel.selectedCategory == category ? .white : .primary) - .padding(.horizontal, 12) - .padding(.vertical, 4) + GlassEffectContainer(spacing: 8) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(FileCategory.allCases, id: \.self) { category in + categoryFilterButton(for: category) } - .buttonStyle(.plain) - .background( - Group { - if viewModel.selectedCategory == category { - Capsule() - .fill(Color.accentColor) - } else { - Capsule() - .fill(Color.primary.opacity(0.04)) - .glassCapsule() - } - } - ) - .clipShape(Capsule()) } + .padding(.vertical, 4) + .padding(.horizontal, 4) } - .padding(.vertical, 4) - .padding(.horizontal, 4) } Spacer() } } + + private static let categoryFilterActiveBlue = Color(red: 0, green: 0.533, blue: 1) + + private func categoryFilterButton(for category: FileCategory) -> some View { + let isSelected = viewModel.selectedCategory == category + + return Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.selectedCategory = category + } + } label: { + Text(category.localizedName) + .font(.callout) + .fontWeight(.medium) + .foregroundStyle(isSelected ? .white : .primary) + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .background { + Capsule().fill( + isSelected ? Self.categoryFilterActiveBlue : Color.black.opacity(0.16) + ) + } + .glassEffect( + isSelected + ? Glass.regular.tint(Self.categoryFilterActiveBlue).interactive() + : Glass.regular, + in: Capsule() + ) + } private var scanningView: some View { VStack(spacing: 20) { diff --git a/MacOSCleaner/Features/Processes/ProcessesView.swift b/MacOSCleaner/Features/Processes/ProcessesView.swift index 62556fb..1e91a47 100644 --- a/MacOSCleaner/Features/Processes/ProcessesView.swift +++ b/MacOSCleaner/Features/Processes/ProcessesView.swift @@ -12,18 +12,6 @@ public struct ProcessesView: View { public var body: some View { GlassEffectContainer { VStack(spacing: 0) { - header - - HStack(spacing: 12) { - searchField - Spacer() - Text("\(viewModel.filteredProcesses.count)/\(viewModel.processes.count)") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal) - .padding(.vertical, 8) - if isEditMode { selectionToolbar .transition(.move(edge: .top).combined(with: .opacity)) @@ -52,106 +40,96 @@ public struct ProcessesView: View { } } } - .sheet(isPresented: $viewModel.showBlacklistAlert) { - blacklistSheet - } - .sheet(isPresented: $viewModel.showWhitelistAlert) { - whitelistSheet - } - .onAppear { - Task { await viewModel.scan() } - } - } - - private var header: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("processes_title".localized) - .font(.title2) - .fontWeight(.bold) - Text("processes_subtitle".localized) - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - + .navigationSubtitle("processes_subtitle".localized) + .searchable(text: $viewModel.searchText, placement: .toolbar, prompt: "processes_search".localized) + .toolbar { if !viewModel.memoryHogs.isEmpty { - HStack(spacing: 4) { - Image(systemName: "memorychip") - .font(.system(size: 12)) - Text(viewModel.totalMemoryFormatted) - .font(.system(size: 11, weight: .medium, design: .monospaced)) + ToolbarItem(placement: .status) { + HStack(spacing: 4) { + Image(systemName: "memorychip") + .font(.system(size: 12)) + Text(viewModel.totalMemoryFormatted) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color.red.opacity(0.1))) + .foregroundColor(.red) } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Capsule().fill(Color.red.opacity(0.1))) - .foregroundColor(.red) } - Menu { - Picker("view_mode".localized, selection: $viewModel.viewMode) { - ForEach(ProcessesViewModel.ViewMode.allCases) { mode in - Text(mode.localizedName).tag(mode) + ToolbarItem(placement: .automatic) { + Menu { + Picker("view_mode".localized, selection: $viewModel.viewMode) { + ForEach(ProcessesViewModel.ViewMode.allCases) { mode in + Text(mode.localizedName).tag(mode) + } } - } - Divider() - Picker("sort_by".localized, selection: $viewModel.sortOption) { - ForEach(ProcessSortOption.allCases) { option in - Text(option.localizedName).tag(option) + Divider() + Picker("sort_by".localized, selection: $viewModel.sortOption) { + ForEach(ProcessSortOption.allCases) { option in + Text(option.localizedName).tag(option) + } } - } - Divider() - Button(action: { - isEditMode.toggle() - if !isEditMode { - viewModel.deselectAll() + Divider() + Button(action: { + isEditMode.toggle() + if !isEditMode { + viewModel.deselectAll() + } + }) { + Label( + isEditMode ? "cancel_selection".localized : "select_multiple".localized, + systemImage: isEditMode ? "xmark.circle" : "checkmark.circle" + ) } - }) { - Label( - isEditMode ? "cancel_selection".localized : "select_multiple".localized, - systemImage: isEditMode ? "xmark.circle" : "checkmark.circle" - ) + } label: { + Image(systemName: "ellipsis.circle") } - } label: { - Image(systemName: "ellipsis.circle") - .font(.system(size: 14, weight: .semibold)) } - .glassButtonStyle() - Button(action: { viewModel.showBlacklistAlert = true }) { - HStack(spacing: 4) { - Image(systemName: "xmark.circle") - .font(.system(size: 14, weight: .semibold)) - if !viewModel.blacklist.isEmpty { - Text("\(viewModel.blacklist.count)") - .font(.system(size: 10, weight: .bold)) + ToolbarItem(placement: .automatic) { + Button(action: { viewModel.showBlacklistAlert = true }) { + HStack(spacing: 4) { + Image(systemName: "xmark.circle") + if !viewModel.blacklist.isEmpty { + Text("\(viewModel.blacklist.count)") + .font(.system(size: 10, weight: .bold)) + } } } + .help("processes_tooltip_blacklist".localized) } - .glassButtonStyle() - .help("processes_tooltip_blacklist".localized) - - Button(action: { viewModel.showWhitelistAlert = true }) { - HStack(spacing: 4) { - Image(systemName: "lock.circle") - .font(.system(size: 14, weight: .semibold)) - if !viewModel.whitelist.isEmpty { - Text("\(viewModel.whitelist.count)") - .font(.system(size: 10, weight: .bold)) + + ToolbarItem(placement: .automatic) { + Button(action: { viewModel.showWhitelistAlert = true }) { + HStack(spacing: 4) { + Image(systemName: "lock.circle") + if !viewModel.whitelist.isEmpty { + Text("\(viewModel.whitelist.count)") + .font(.system(size: 10, weight: .bold)) + } } } + .help("processes_tooltip_whitelist".localized) } - .glassButtonStyle() - .help("processes_tooltip_whitelist".localized) - Button(action: { Task { await viewModel.scan() } }) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 14, weight: .semibold)) + ToolbarItem(placement: .automatic) { + Button(action: { Task { await viewModel.scan() } }) { + Image(systemName: "arrow.clockwise") + } + .help("processes_tooltip_refresh".localized) } - .glassButtonStyle() - .help("processes_tooltip_refresh".localized) } - .padding() + .sheet(isPresented: $viewModel.showBlacklistAlert) { + blacklistSheet + } + .sheet(isPresented: $viewModel.showWhitelistAlert) { + whitelistSheet + } + .onAppear { + Task { await viewModel.scan() } + } } private var selectionToolbar: some View { @@ -188,20 +166,6 @@ public struct ProcessesView: View { .padding(.vertical, 8) } - private var searchField: some View { - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - .font(.system(size: 12)) - TextField("processes_search".localized, text: $viewModel.searchText) - .textFieldStyle(.plain) - .font(.system(size: 13)) - } - .padding(.horizontal, 12) - .padding(.vertical, 7) - .glassCapsule() - } - private var groupedProcessList: some View { ScrollView { LazyVStack(spacing: 0) { diff --git a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift index c7e4305..879eacc 100644 --- a/MacOSCleaner/Features/StartupServices/StartupServicesView.swift +++ b/MacOSCleaner/Features/StartupServices/StartupServicesView.swift @@ -7,7 +7,8 @@ public struct StartupServicesView: View { public var body: some View { GlassEffectContainer { VStack(spacing: 0) { - header + filterPicker + .padding() if viewModel.isLoading { AnimatedScanView( @@ -26,32 +27,16 @@ public struct StartupServicesView: View { } } } - .onAppear { Task { await viewModel.scan() } } - } - - private var header: some View { - VStack(spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("startup_title".localized) - .font(.title2) - .fontWeight(.bold) - Text("startup_subtitle".localized) - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() + .navigationSubtitle("startup_subtitle".localized) + .toolbar { + ToolbarItem(placement: .automatic) { Button(action: { Task { await viewModel.scan() } }) { Image(systemName: "arrow.clockwise") - .font(.system(size: 14, weight: .semibold)) } - .glassButtonStyle() .help("startup_refresh".localized) } - - filterPicker } - .padding() + .onAppear { Task { await viewModel.scan() } } } private var filterPicker: some View { diff --git a/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift b/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift index c34d5fd..4ac4d83 100644 --- a/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift +++ b/MacOSCleaner/Infrastructure/LiquidGlass+Compatibility.swift @@ -85,11 +85,6 @@ public extension View { // Explicit Glass type avoids ambiguity between the shim and SwiftUI.Glass (SDK 26+). glassEffect(Glass.regular, in: Capsule()) } - - /// Wallpaper-tinted screen background behind glass panels (macOS 27 look). - func macOS27ScreenBackground() -> some View { - modifier(MacOS27ScreenBackgroundModifier()) - } } private struct GlassCardModifier: ViewModifier { @@ -117,42 +112,6 @@ private struct GlassCardModifier: ViewModifier { } } -private struct MacOS27ScreenBackgroundModifier: ViewModifier { - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - @Environment(\.colorScheme) private var colorScheme - - func body(content: Content) -> some View { - content.background { - if reduceTransparency { - Color(NSColor.windowBackgroundColor).ignoresSafeArea() - } else { - ZStack { - Color(NSColor.windowBackgroundColor) - LinearGradient( - stops: [ - .init(color: warmTint, location: 0.0), - .init(color: .clear, location: 0.55), - .init(color: coolTint, location: 1.0), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - .ignoresSafeArea() - } - } - } - - // Warm champagne → cool lavender-grey, echoing the macOS 27 wallpaper palette. - private var warmTint: Color { - Color(red: 0.95, green: 0.86, blue: 0.70).opacity(colorScheme == .dark ? 0.10 : 0.26) - } - - private var coolTint: Color { - Color(red: 0.56, green: 0.58, blue: 0.72).opacity(colorScheme == .dark ? 0.14 : 0.18) - } -} - public extension View { @ViewBuilder func glassButtonStyle() -> some View { diff --git a/MacOSCleaner/Models/NavigationItem.swift b/MacOSCleaner/Models/NavigationItem.swift index 3c8e638..0d7f1a6 100644 --- a/MacOSCleaner/Models/NavigationItem.swift +++ b/MacOSCleaner/Models/NavigationItem.swift @@ -35,3 +35,18 @@ enum NavigationItem: String, CaseIterable, Identifiable, Hashable { } } } + +/// Sidebar grouping matching the macOS 27 sidebar layout (flat items + titled sections). +struct SidebarSection: Identifiable { + let titleKey: String? + let items: [NavigationItem] + + var id: String { titleKey ?? items.first?.rawValue ?? "" } + + static let all: [SidebarSection] = [ + SidebarSection(titleKey: nil, items: [.dashboard]), + SidebarSection(titleKey: "sidebar_section_tools", items: [.cleanup, .diskSpace, .uninstaller]), + SidebarSection(titleKey: "sidebar_section_system", items: [.processes, .startupServices]), + SidebarSection(titleKey: nil, items: [.settings]), + ] +} diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index 867364b..ed1c8fb 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -2,6 +2,8 @@ "welcome_msg" = "Welcome back!"; "app_title" = "Cleaner"; "sidebar_select_item" = "Select an item from the sidebar"; +"sidebar_section_tools" = "Cleaning"; +"sidebar_section_system" = "System"; "close" = "Close"; "cancel_description" = "The operation was cancelled by user."; "reset" = "Reset"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index 5ffad5a..b297168 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -2,6 +2,8 @@ "welcome_msg" = "¡Bienvenido de nuevo!"; "app_title" = "Limpiador"; "sidebar_select_item" = "Seleccione un elemento de la barra lateral"; +"sidebar_section_tools" = "Limpieza"; +"sidebar_section_system" = "Sistema"; "close" = "Cerrar"; "cancel_description" = "La operación fue cancelada por el usuario."; "reset" = "Restablecer"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index 867e21a..0afaf1b 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -2,6 +2,8 @@ "welcome_msg" = "С возвращением!"; "app_title" = "Очистка"; "sidebar_select_item" = "Выберите элемент в боковом меню"; +"sidebar_section_tools" = "Очистка"; +"sidebar_section_system" = "Система"; "close" = "Закрыть"; "cancel_description" = "Операция была отменена пользователем."; "reset" = "Сбросить"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index 7019a7e..49dd124 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -2,6 +2,8 @@ "welcome_msg" = "З поверненням!"; "app_title" = "Очищення"; "sidebar_select_item" = "Виберіть елемент у бічному меню"; +"sidebar_section_tools" = "Очищення"; +"sidebar_section_system" = "Система"; "close" = "Закрити"; "cancel_description" = "Операцію скасовано користувачем."; "reset" = "Скинути"; From 45e8b7aca004943cd8cd90a6e3c095dd9c9ec5f7 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 14:25:42 +0300 Subject: [PATCH 14/23] feat: enhance uninstaller functionality with improved candidate collection, evidence assessment, and app group handling --- .gitignore | 1 + MacOSCleaner/App/RootView.swift | 11 +- .../Features/Uninstaller/AppIdentity.swift | 27 +- .../Uninstaller/CandidateCollector.swift | 245 ++++++++++++++++-- .../Uninstaller/ConfidenceEngine.swift | 5 +- .../DeveloperComponentsDetector.swift | 2 +- .../Features/Uninstaller/EvidenceGraph.swift | 6 +- .../Features/Uninstaller/EvidenceProbe.swift | 130 ++++++++-- .../Features/Uninstaller/ParentLinker.swift | 11 + .../Uninstaller/Rules/BrowserRule.swift | 13 + .../Uninstaller/Rules/DefaultRule.swift | 14 + .../Uninstaller/Rules/DockerRule.swift | 15 +- .../Uninstaller/Rules/ElectronRule.swift | 15 +- .../Rules/MicrosoftOfficeRule.swift | 8 +- .../Uninstaller/Rules/ParallelsRule.swift | 6 + .../Uninstaller/Rules/VMwareFusionRule.swift | 5 + .../Uninstaller/UninstallerService.swift | 67 +++-- .../Uninstaller/VerificationEngine.swift | 11 +- .../Infrastructure/FileManager+Size.swift | 74 +++--- .../Infrastructure/SafetyManager.swift | 76 ++++-- .../MacOSCleanerTests/AppIdentityTests.swift | 24 ++ .../CandidateCollectorTests.swift | 162 +++++++++++- .../ConfidenceEngineTests.swift | 27 ++ .../EvidenceProbeTests.swift | 209 +++++++++++++++ .../SafetyManagerTests.swift | 54 ++++ .../UninstallerServiceTests.swift | 34 +++ 26 files changed, 1118 insertions(+), 134 deletions(-) diff --git a/.gitignore b/.gitignore index 4a44c74..5b8c1df 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,4 @@ xcuserdata/ *.xcuserstate opencode.json .agents +problematic_apps/ diff --git a/MacOSCleaner/App/RootView.swift b/MacOSCleaner/App/RootView.swift index fe58d8c..d78098d 100644 --- a/MacOSCleaner/App/RootView.swift +++ b/MacOSCleaner/App/RootView.swift @@ -2,6 +2,9 @@ import SwiftUI struct RootView: View { @State private var selectedItem: NavigationItem? = .dashboard + /// Keep the sidebar visible; hiding `.windowToolbar` on Dashboard used to + /// collapse NavigationSplitView to detail-only on macOS 26+. + @State private var columnVisibility: NavigationSplitViewVisibility = .all let cleanupViewModel: CleanupViewModel let journal: TransactionJournal let appSettings: AppSettings @@ -10,7 +13,7 @@ struct RootView: View { var body: some View { ZStack { - NavigationSplitView { + NavigationSplitView(columnVisibility: $columnVisibility) { List(selection: $selectedItem) { ForEach(SidebarSection.all) { section in Section { @@ -26,6 +29,7 @@ struct RootView: View { } } .listStyle(.sidebar) + .navigationTitle("app_title".localized) .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280) } detail: { Group { @@ -40,6 +44,7 @@ struct RootView: View { .scrollEdgeEffectStyle(.hard, for: .top) .frame(minWidth: 800, minHeight: 600) } + .navigationSplitViewStyle(.balanced) GlassOverlayView(manager: GlassOverlayManager.shared) } @@ -90,14 +95,14 @@ private struct ScreenNavigationTitleModifier: ViewModifier { func body(content: Content) -> some View { if item == .dashboard { + // Keep the window toolbar (sidebar toggle lives there). Only drop the + // detail title so Dashboard stays chrome-light without collapsing the split. content .navigationTitle("") .toolbar(removing: .title) - .toolbarVisibility(.hidden, for: .windowToolbar) } else { content .navigationTitle(item.localizedTitle) - .toolbarVisibility(.visible, for: .windowToolbar) } } } diff --git a/MacOSCleaner/Features/Uninstaller/AppIdentity.swift b/MacOSCleaner/Features/Uninstaller/AppIdentity.swift index 4283fef..ac9dc7f 100644 --- a/MacOSCleaner/Features/Uninstaller/AppIdentity.swift +++ b/MacOSCleaner/Features/Uninstaller/AppIdentity.swift @@ -18,6 +18,9 @@ public struct AppIdentity: Sendable, Hashable { public let frameworkNames: Set public let xpcServiceNames: Set public let plugInNames: Set + /// App groups from the code signature entitlements + /// (com.apple.security.application-groups) — ground truth for Group Containers. + public var appGroups: Set = [] public let isElectron: Bool public let isJetBrains: Bool @@ -40,7 +43,9 @@ public extension AppIdentity { let authority = await extractSigningAuthority(url: url, commandRunner: commandRunner) let isAdHoc = authority?.contains("Ad Hoc") == true || authority?.contains("not signed") == true let isAppStore = fileExists(at: url.appendingPathComponent("Contents/_MASReceipt")) - let isSandboxed = await checkSandbox(url: url, commandRunner: commandRunner) + let entitlements = await extractEntitlements(url: url, commandRunner: commandRunner) + let isSandboxed = entitlements?["com.apple.security.app-sandbox"] as? Bool ?? false + let appGroups = Set(entitlements?["com.apple.security.application-groups"] as? [String] ?? []) let frameworkNames = await scanFrameworks(url: url) let xpcServiceNames = await scanXPCServices(url: url) @@ -77,6 +82,7 @@ public extension AppIdentity { frameworkNames: frameworkNames, xpcServiceNames: xpcServiceNames, plugInNames: plugInNames, + appGroups: appGroups, isElectron: isElectron, isJetBrains: isJetBrains, isFlutter: isFlutter, @@ -111,10 +117,13 @@ private func extractSigningAuthority(url: URL, commandRunner: CommandRunner) asy return nil } -private func checkSandbox(url: URL, commandRunner: CommandRunner) async -> Bool { - let result = try? await commandRunner.run(command: "/usr/bin/codesign", arguments: ["-d", "--entitlements", ":-", url.path]) - guard let output = result?.stdout else { return false } - return output.contains("com.apple.security.app-sandbox") +private func extractEntitlements(url: URL, commandRunner: CommandRunner) async -> [String: Any]? { + let result = try? await commandRunner.run(command: "/usr/bin/codesign", arguments: ["-d", "--entitlements", ":-", "--xml", url.path]) + guard let output = result?.stdout, !output.isEmpty, + let data = output.data(using: .utf8), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) + else { return nil } + return plist as? [String: Any] } private func fileExists(at path: URL) -> Bool { @@ -157,13 +166,17 @@ private let vendorStopwords: Set = ["com", "org", "net", "io", "app", "c private func deriveVendorNames(bundleID: String, appName: String, authority: String?) -> Set { var names = Set() let parts = bundleID.components(separatedBy: ".") + // The last component of a 3+-part reverse-DNS ID is the product, not the vendor. + // Deriving "desktop" from ai.opencode.desktop (or "chrome" from com.google.Chrome) + // turns generic folder names anywhere on disk into vendor matches. + let vendorParts = parts.count >= 3 ? Array(parts.dropLast()) : parts - for p in parts where p.count >= 4 && !vendorStopwords.contains(p.lowercased()) { + for p in vendorParts where p.count >= 4 && !vendorStopwords.contains(p.lowercased()) { names.insert(p) names.insert(p.capitalized) names.insert(p.prefix(1).uppercased() + p.dropFirst()) } - if let first = parts.first(where: { $0.count >= 4 && !vendorStopwords.contains($0.lowercased()) }) { + if let first = vendorParts.first(where: { $0.count >= 4 && !vendorStopwords.contains($0.lowercased()) }) { names.insert(first.capitalized) } diff --git a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift index 7c22b5e..c0a66b5 100644 --- a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift +++ b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift @@ -1,5 +1,11 @@ import Foundation +public struct CandidateCollection: Sendable { + public let candidates: Set + /// Subset of `candidates` that came from a pkgutil receipt — guaranteed app files. + public let receiptPaths: Set +} + public actor CandidateCollector { private let fileManager: FileManager private let commandRunner: any CommandRunning @@ -10,6 +16,10 @@ public actor CandidateCollector { } public func collect(identity: AppIdentity, mode: ScanMode = .balanced) async -> Set { + await collectDetailed(identity: identity, mode: mode).candidates + } + + public func collectDetailed(identity: AppIdentity, mode: ScanMode = .balanced) async -> CandidateCollection { var candidates = Set() let home = NSHomeDirectory() let maxDepth = mode == .safe ? 3 : 5 @@ -57,16 +67,8 @@ public actor CandidateCollector { } // 3. pkgutil receipts - if identity.bundleID.count > 0 { - if let result = try? await commandRunner.run(command: "/usr/sbin/pkgutil", arguments: ["--files", identity.bundleID]) { - for line in result.stdout.components(separatedBy: .newlines) where !line.isEmpty { - let path = "/\(line)" - if fileManager.fileExists(atPath: path) { - candidates.insert(URL(fileURLWithPath: path)) - } - } - } - } + let receiptPaths = await collectReceiptPaths(identity: identity) + candidates.formUnion(receiptPaths) // 4. mdfind (balanced only) if mode == .balanced { @@ -191,7 +193,43 @@ public actor CandidateCollector { } } - return candidates + // 14. VM / container user data outside ~/Library (OrbStack_files, Parallels, …) + if isVirtualizationApp(identity) { + candidates.formUnion(await scanVMUserData(identity: identity, home: home)) + } + + // 15. Browser vendor folders (Google/Chrome, Mozilla/Firefox, …) + candidates.formUnion(await collectBrowserVendorPaths(identity: identity, home: home, maxDepth: maxDepth)) + + return CandidateCollection(candidates: candidates, receiptPaths: receiptPaths) + } + + /// Files recorded in installer receipts for packages whose id matches the bundle ID. + /// Paths inside the app bundle are skipped — the bundle is removed as a whole anyway. + private func collectReceiptPaths(identity: AppIdentity) async -> Set { + guard !identity.bundleID.isEmpty, !identity.bundleID.hasPrefix("unknown.") else { return [] } + + var packageIDs: Set = [identity.bundleID] + if let result = try? await commandRunner.run(command: "/usr/sbin/pkgutil", arguments: ["--pkgs"]) { + for line in result.stdout.components(separatedBy: .newlines) where !line.isEmpty { + if line == identity.bundleID || line.hasPrefix(identity.bundleID + ".") { + packageIDs.insert(line) + } + } + } + + var found = Set() + let bundlePrefix = identity.bundleURL.standardizedFileURL.path + "/" + for packageID in packageIDs { + guard let result = try? await commandRunner.run(command: "/usr/sbin/pkgutil", arguments: ["--files", packageID]), + result.exitCode == 0 else { continue } + for line in result.stdout.components(separatedBy: .newlines) where !line.isEmpty { + let path = "/\(line)" + guard !path.hasPrefix(bundlePrefix), fileManager.fileExists(atPath: path) else { continue } + found.insert(URL(fileURLWithPath: path)) + } + } + return found } private func shallowScan(_ url: URL, identity: AppIdentity, mode: ScanMode = .balanced) async -> Set { @@ -229,14 +267,51 @@ public actor CandidateCollector { private func matchCandidate(_ url: URL, identity: AppIdentity, mode: ScanMode = .balanced) -> Bool { let name = url.lastPathComponent let lowerName = name.lowercased() + let lowerBundleID = identity.bundleID.lowercased() + let lowerAppName = identity.appName.lowercased() + let lowerExecutable = identity.executableName.lowercased() + let lowerBundleName = identity.bundleName?.lowercased() - // Exact matches — always checked - if name == identity.bundleID || name.hasPrefix(identity.bundleID + ".") { + // Exact matches — always checked. Case-insensitive: Electron apps often use + // lowercase data dirs (~/Library/Application Support/discord for "Discord"). + if lowerName == lowerBundleID || lowerName.hasPrefix(lowerBundleID + ".") { + return true + } + if lowerName == lowerAppName + || lowerName.hasPrefix(lowerAppName + " ") + || lowerName.hasPrefix(lowerAppName + ".") + || lowerName.hasPrefix(lowerAppName + "-") { + return true + } + if let lowerBundleName, !lowerBundleName.isEmpty, + (lowerName == lowerBundleName + || lowerName.hasPrefix(lowerBundleName + " ") + || lowerName.hasPrefix(lowerBundleName + "-")) { + return true + } + // TeamID-prefixed containers (e.g. Group Containers/UBF8T346G9.Office) + if let teamID = identity.teamID, !teamID.isEmpty, name.hasPrefix(teamID + ".") { return true } - if name == identity.appName || name.hasPrefix(identity.appName + " ") || name.hasPrefix(identity.appName + ".") { + // App groups declared in the signature entitlements (TC3Q7MAJXF.com.adguard.mac) + if identity.appGroups.contains(name) { return true } + // Bundle ID tail inside a vendor folder: Google/Chrome from com.google.Chrome. + // Without the vendor-parent guard a generic tail ("desktop" from + // ai.opencode.desktop) matches Data/Desktop in every sandbox container. + if let tail = lowerBundleID.split(separator: ".").last.map(String.init), tail.count >= 3 { + let parentName = url.deletingLastPathComponent().lastPathComponent.lowercased() + let parentIsVendor = identity.vendorNames.contains { $0.lowercased() == parentName } + if parentIsVendor, + lowerName == tail || lowerName.hasPrefix(tail + "-") || lowerName.hasPrefix(tail + "_") { + return true + } + } + // Token prefix: opencode-desktop_br for OpenCode + if EvidenceProbe.tokenPrefixMatch(lowerName, lowerAppName) { return true } + if EvidenceProbe.tokenPrefixMatch(lowerName, lowerExecutable) { return true } + if let lowerBundleName { if EvidenceProbe.tokenPrefixMatch(lowerName, lowerBundleName) { return true } } // Safe mode: only exact matches above if mode == .safe { return false } @@ -248,31 +323,155 @@ public actor CandidateCollector { if identity.vendorNames.contains(where: { lowerName.contains($0.lowercased()) }) { return true } - if lowerName.contains(identity.bundleID.lowercased()) { + if lowerName.contains(lowerBundleID) { return true } - if lowerName.contains(identity.appName.lowercased()) { + if lowerName.contains(lowerAppName) { return true } - if lowerName == identity.executableName.lowercased() { + if lowerName == lowerExecutable { return true } return false } + // MARK: - Browser vendor paths + + /// Google Chrome lives under ~/Library/.../Google/Chrome, not a top-level "Google Chrome" folder. + private func collectBrowserVendorPaths(identity: AppIdentity, home: String, maxDepth: Int) async -> Set { + let bid = identity.bundleID.lowercased() + var vendorRoots: [(vendor: String, product: String?)] = [] + + if bid.contains("google") && (bid.contains("chrome") || identity.appName.lowercased().contains("chrome")) { + vendorRoots.append(("Google", "Chrome")) + } else if bid.contains("mozilla") || identity.appName.lowercased().contains("firefox") { + vendorRoots.append(("Mozilla", nil)) + } else if bid.contains("microsoft.edgemac") || identity.appName.lowercased().contains("edge") { + vendorRoots.append(("Microsoft Edge", nil)) + } + + var found = Set() + let bases = ["\(home)/Library/Application Support", "\(home)/Library/Caches", "\(home)/Library/Logs"] + for (vendor, product) in vendorRoots { + for base in bases { + let vendorURL = URL(fileURLWithPath: base).appendingPathComponent(vendor) + guard fileManager.fileExists(atPath: vendorURL.path) else { continue } + if let product { + let productURL = vendorURL.appendingPathComponent(product) + if fileManager.fileExists(atPath: productURL.path) { + found.insert(productURL) + found.formUnion(await deepScan(productURL, identity: identity, depth: 0, maxDepth: maxDepth)) + } + } else { + found.insert(vendorURL) + found.formUnion(await deepScan(vendorURL, identity: identity, depth: 0, maxDepth: maxDepth)) + } + } + } + return found + } + + // MARK: - VM user data outside ~/Library + + private func isVirtualizationApp(_ identity: AppIdentity) -> Bool { + if identity.isDocker { return true } + let bid = identity.bundleID.lowercased() + let name = identity.appName.lowercased() + let markers = ["parallels", "vmware", "virtualbox", "utm", "orbstack", "qemu"] + return markers.contains { bid.contains($0) || name.contains($0) } + } + + private func vmDataTokens(for identity: AppIdentity) -> [String] { + var tokens = Set() + let bid = identity.bundleID.lowercased() + let name = identity.appName.lowercased() + if bid.contains("orbstack") || name.contains("orbstack") { + tokens.formUnion(["orbstack", "orbstack_files", "orbstack files"]) + } + if bid.contains("parallels") || name.contains("parallels") { + tokens.formUnion(["parallels", ".pvm"]) + } + if bid.contains("vmware") || name.contains("vmware") { + tokens.formUnion(["vmware", "virtual machines", ".vmx"]) + } + if bid.contains("virtualbox") || name.contains("virtualbox") { + tokens.formUnion(["virtualbox", "virtualbox vms"]) + } + if bid.contains("utm") || name == "utm" { + tokens.formUnion(["utm"]) + } + if bid.contains("docker") || name.contains("docker") { + tokens.formUnion(["docker"]) + } + return Array(tokens) + } + + private func scanVMUserData(identity: AppIdentity, home: String) async -> Set { + let tokens = vmDataTokens(for: identity) + guard !tokens.isEmpty else { return [] } + var found = Set() + let roots = ["\(home)/Documents", "\(home)/Desktop", "/Users/Shared"] + for root in roots { + let url = URL(fileURLWithPath: root) + found.formUnion(await scanVMDataDir(url, tokens: tokens, depth: 0, maxDepth: 5)) + } + return found + } + + private func scanVMDataDir(_ url: URL, tokens: [String], depth: Int, maxDepth: Int) async -> Set { + guard depth <= maxDepth else { return [] } + var found = Set() + guard let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + return found + } + for item in contents { + let lower = item.lastPathComponent.lowercased() + if tokens.contains(where: { lower.contains($0) }) || item.pathExtension.lowercased() == "pvm" || item.pathExtension.lowercased() == "vmx" { + found.insert(item) + } + var isDir: ObjCBool = false + if fileManager.fileExists(atPath: item.path, isDirectory: &isDir), isDir.boolValue { + found.formUnion(await scanVMDataDir(item, tokens: tokens, depth: depth + 1, maxDepth: maxDepth)) + } + } + return found + } + + /// Attribute-scoped Spotlight lookup. Raw text queries are forbidden here: they run a + /// full-content search and match user documents/mail that merely mention the app name. private func runMdfind(identity: AppIdentity) async -> Set { + var queries: [(query: String, onlyIn: String?)] = [] + + if !identity.bundleID.isEmpty, !identity.bundleID.hasPrefix("unknown.") { + // Bundles carrying the app's identifier (stray copies, helpers) — precise, safe globally + queries.append(("kMDItemCFBundleIdentifier == '\(mdfindEscape(identity.bundleID))'", nil)) + } + + // Name lookups are fuzzy — restrict to ~/Library where residuals actually live + let home = NSHomeDirectory() + let names = Set([identity.appName, identity.executableName].filter { !$0.isEmpty }) + for name in names { + queries.append(("kMDItemFSName == '\(mdfindEscape(name))*'cd", "\(home)/Library")) + } + var urls = Set() - let queries = [identity.bundleID, identity.appName, identity.executableName] - for query in queries { - guard !query.isEmpty else { continue } - if let result = try? await commandRunner.run(command: "/usr/bin/mdfind", arguments: [query]) { + for (query, onlyIn) in queries { + var arguments: [String] = [] + if let onlyIn { arguments += ["-onlyin", onlyIn] } + arguments.append(query) + if let result = try? await commandRunner.run(command: "/usr/bin/mdfind", arguments: arguments) { for line in result.stdout.components(separatedBy: .newlines) where !line.isEmpty { - let url = URL(fileURLWithPath: line) - urls.insert(url) + urls.insert(URL(fileURLWithPath: line)) } } } return urls } + + private func mdfindEscape(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + } } diff --git a/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift b/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift index ca57bf7..edc153b 100644 --- a/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift +++ b/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift @@ -6,8 +6,8 @@ public enum ConfidenceEngine { .spotlightBundleAttr, .loginItem, ] - public static func assess(_ evidence: Set, identity: AppIdentity, weights: ScoringWeights = .default) -> ConfidenceAssessment { - let score = weights.score(evidence) + public static func assess(_ evidence: Set, ruleScore: Int = 0, identity: AppIdentity, weights: ScoringWeights = .default) -> ConfidenceAssessment { + let score = weights.score(evidence) + ruleScore let missing = criticalEvidence.subtracting(evidence) var tier: ConfidenceTier @@ -15,6 +15,7 @@ public enum ConfidenceEngine { if evidence.contains(.bundleIDExact) || evidence.contains(.bundleIDPrefix) || evidence.contains(.packageReceipt) || evidence.contains(.spotlightBundleAttr) || evidence.contains(.loginItem) + || ruleScore >= 100 || (evidence.contains(.teamID) && (evidence.contains(.launchAgent) || evidence.contains(.launchDaemon) || evidence.contains(.extension) || evidence.contains(.appGroup) || evidence.contains(.container))) { tier = .guaranteed diff --git a/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift b/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift index ae93ff3..cf5e737 100644 --- a/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift +++ b/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift @@ -108,6 +108,6 @@ public enum DeveloperComponentsDetector { } private static func getDirectorySize(url: URL) -> Int64 { - FileManager.default.getDirectorySize(url: url, excludedPaths: []) + FileManager.default.getPhysicalDirectorySize(url: url, excludedPaths: []) } } diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceGraph.swift b/MacOSCleaner/Features/Uninstaller/EvidenceGraph.swift index 309c6d0..c2a7ac5 100644 --- a/MacOSCleaner/Features/Uninstaller/EvidenceGraph.swift +++ b/MacOSCleaner/Features/Uninstaller/EvidenceGraph.swift @@ -71,7 +71,11 @@ public actor EvidenceGraph { } public func propagateFromSeeds(maxDepth: Int = 5) { - let seeds = nodes.filter { $0.value.evidence.contains(.bundleIDExact) } + let strongEvidence: Set = [ + .bundleIDExact, .bundleIDPrefix, .appNameExact, .appNamePrefix, + .packageReceipt, .container, .appGroup, .spotlightBundleAttr, + ] + let seeds = nodes.filter { !strongEvidence.isDisjoint(with: $0.value.evidence) } for seed in seeds.values { propagate(from: seed.url, depth: 0, maxDepth: maxDepth) } diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift b/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift index 4c2fc96..4ec80d5 100644 --- a/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift +++ b/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift @@ -22,20 +22,43 @@ public actor EvidenceProbe { let parentFolder = url.deletingLastPathComponent().lastPathComponent let path = url.path.lowercased() + // Case-insensitive identity checks: Electron apps often use lowercase + // data dirs (~/Library/Application Support/discord for "Discord"). + let lowerFileName = fileName.lowercased() + let lowerBundleID = identity.bundleID.lowercased() + let lowerAppName = identity.appName.lowercased() + let lowerVendorNames = Set(identity.vendorNames.map { $0.lowercased() }) + // Identity checks - if fileName == identity.bundleID { + if lowerFileName == lowerBundleID { evidence.insert(.bundleIDExact) - } else if fileName.hasPrefix(identity.bundleID + ".") || fileName == identity.bundleID { + } else if lowerFileName.hasPrefix(lowerBundleID + ".") { evidence.insert(.bundleIDPrefix) } - if fileName == identity.appName { + if lowerFileName == lowerAppName { evidence.insert(.appNameExact) - } else if fileName.hasPrefix(identity.appName + " ") || fileName.hasPrefix(identity.appName + "-") { + } else if lowerFileName.hasPrefix(lowerAppName + " ") + || lowerFileName.hasPrefix(lowerAppName + "-") + || lowerFileName.hasPrefix(lowerAppName + ".") { evidence.insert(.appNamePrefix) } - if identity.vendorNames.contains(fileName) || identity.vendorNames.contains(parentFolder) { + // CFBundleName is the app's own declared product name (iTerm2 for iTerm.app, + // Chrome for Google Chrome.app). Trusted only directly inside Library base + // dirs or a vendor dir — deep matches on short product names are too risky. + if let lowerBundleName = identity.bundleName?.lowercased(), !lowerBundleName.isEmpty, + Self.libraryBaseDirNames.contains(parentFolder) || lowerVendorNames.contains(parentFolder.lowercased()) { + if lowerFileName == lowerBundleName { + evidence.insert(.appNameExact) + } else if lowerFileName.hasPrefix(lowerBundleName + " ") + || lowerFileName.hasPrefix(lowerBundleName + "-") + || lowerFileName.hasPrefix(lowerBundleName + ".") { + evidence.insert(.appNamePrefix) + } + } + + if lowerVendorNames.contains(lowerFileName) || lowerVendorNames.contains(parentFolder.lowercased()) { evidence.insert(.vendorName) } @@ -51,23 +74,57 @@ public actor EvidenceProbe { evidence.insert(.plugInName) } - if fileName == identity.executableName { + if lowerFileName == identity.executableName.lowercased() { evidence.insert(.executableName) } // Container checks - if parentFolder == "Containers" && fileName == identity.bundleID { + if parentFolder == "Containers" && lowerFileName == lowerBundleID { evidence.insert(.container) } - if parentFolder == "Group Containers" && (fileName == "group.\(identity.bundleID)" || fileName.hasPrefix("group.\(identity.bundleID).")) { - evidence.insert(.appGroup) + if parentFolder == "Group Containers" { + // Entitlements are ground truth: TC3Q7MAJXF.com.adguard.mac belongs to + // AdGuard VPN (com.adguard.mac.vpn) because its signature declares it. + if identity.appGroups.contains(fileName) { + evidence.insert(.appGroup) + } else if lowerFileName == "group.\(lowerBundleID)" || lowerFileName.hasPrefix("group.\(lowerBundleID).") { + evidence.insert(.appGroup) + } else if let teamID = identity.teamID, !teamID.isEmpty, fileName.hasPrefix(teamID + ".") { + let suffix = String(fileName.dropFirst(teamID.count + 1)).lowercased() + // HUAQ24HBR6.dev.orbstack / TC3Q7MAJXF.com.adguard.mac — app-owned container + if suffix == lowerBundleID || suffix.hasPrefix(lowerBundleID + ".") { + evidence.insert(.appGroup) + } else if Self.bundleIDSuffixMatch(suffix, bundleID: lowerBundleID) { + evidence.insert(.appGroup) + } else { + // UBF8T346G9.Office — shared vendor container, weak evidence + evidence.insert(.vendorName) + } + } } - // System integration - if path.contains("/launchagents/") && (path.contains(identity.bundleID.lowercased()) || fileName.lowercased().contains(identity.appName.lowercased())) { + // Library cache/support folders named after the bundle ID (com.microsoft.autoupdate2, …) + if path.contains("/library/caches/") || path.contains("/library/logs/") { + if lowerFileName == lowerBundleID || lowerFileName.hasPrefix(lowerBundleID + ".") { + evidence.insert(.bundleIDExact) + } + } + if path.contains("/library/application support/") { + if lowerFileName == lowerAppName + || lowerFileName.hasPrefix(lowerAppName + " ") + || lowerFileName.hasPrefix(lowerAppName + "-") + || lowerFileName.hasPrefix(lowerAppName + ".") { + evidence.insert(.appNameExact) + } + } + + // System integration. App name must start a token ("ArcUpdater.plist" yes, + // "com.searchmarquis.plist" no) so short names don't match foreign agents. + let nameMatchesIdentifier = path.contains(lowerBundleID) || Self.tokenPrefixMatch(lowerFileName, lowerAppName) + if path.contains("/launchagents/") && nameMatchesIdentifier { evidence.insert(.launchAgent) } - if path.contains("/launchdaemons/") && (path.contains(identity.bundleID.lowercased()) || fileName.lowercased().contains(identity.appName.lowercased())) { + if path.contains("/launchdaemons/") && nameMatchesIdentifier { evidence.insert(.launchDaemon) } @@ -76,10 +133,15 @@ public actor EvidenceProbe { evidence.insert(.extension) } - // Electron cache detection + // Electron / VS Code–style cache detection if identity.isElectron { - let electronDirs = ["Cache", "Code Cache", "GPUCache", "Local Storage", "Session Storage", "IndexedDB", "Network"] - if electronDirs.contains(fileName) && path.contains("/application support/\(identity.appName.lowercased())") { + let electronDirs = [ + "Cache", "Code Cache", "GPUCache", "CachedData", "Backups", + "Local Storage", "Session Storage", "IndexedDB", "Network", + ] + let supportNames = [lowerAppName, identity.bundleName?.lowercased()].compactMap { $0 }.filter { !$0.isEmpty } + if electronDirs.contains(fileName), + supportNames.contains(where: { path.contains("/application support/\($0)") }) { evidence.insert(.electronCache) } } @@ -111,13 +173,14 @@ public actor EvidenceProbe { } } - // Plist content probe + // Plist content probe. Bundle ID is unique enough for substring match; the app + // name must be a whole word ("/Applications/Arc.app" yes, "architecture" no). if url.pathExtension == "plist" || fileName.hasSuffix(".plist") { let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 if size > 0 && size < 512 * 1024 { if let content = await plistCache.getContent(url: url) { let lower = content.lowercased() - if lower.contains(identity.bundleID.lowercased()) || lower.contains(identity.appName.lowercased()) { + if lower.contains(lowerBundleID) || Self.wordBoundaryMatch(lower, lowerAppName) { evidence.insert(.plistContent) } } @@ -126,4 +189,37 @@ public actor EvidenceProbe { return evidence } + + /// Standard Library residual roots where a name match is meaningful context. + static let libraryBaseDirNames: Set = [ + "Application Support", "Caches", "Preferences", "ByHost", "Logs", + "Containers", "Group Containers", "WebKit", "HTTPStorages", + "Saved Application State", "Application Scripts", + "LaunchAgents", "LaunchDaemons", + ] + + // MARK: - Name matching helpers + + /// Needle starts a token in haystack: "arc" matches "arc helper"/"arcupdater", not "searchmarquis". + static func tokenPrefixMatch(_ haystack: String, _ needle: String) -> Bool { + guard !needle.isEmpty else { return false } + let pattern = "(? Bool { + guard !needle.isEmpty else { return false } + let pattern = "(? Bool { + let suffixParts = containerSuffix.split(separator: ".") + let bundleParts = bundleID.split(separator: ".") + guard let tail = suffixParts.last, tail.count >= 3 else { return false } + return bundleParts.suffix(2).map(String.init).joined(separator: ".") == suffixParts.suffix(2).map(String.init).joined(separator: ".") + || bundleParts.last.map(String.init) == String(tail) + } } diff --git a/MacOSCleaner/Features/Uninstaller/ParentLinker.swift b/MacOSCleaner/Features/Uninstaller/ParentLinker.swift index 63e4bce..7c14937 100644 --- a/MacOSCleaner/Features/Uninstaller/ParentLinker.swift +++ b/MacOSCleaner/Features/Uninstaller/ParentLinker.swift @@ -33,6 +33,17 @@ public enum ParentLinker { let parentURL = URL(fileURLWithPath: parentPath) links.append((parentURL, .bundleIDPrefix)) } + + if identity.appGroups.contains(component) { + let parentPath = Array(pathComponents[0...i]).joined(separator: "/") + links.append((URL(fileURLWithPath: parentPath), .appGroup)) + } else if let teamID = identity.teamID, component.hasPrefix(teamID + ".") { + let suffix = String(component.dropFirst(teamID.count + 1)).lowercased() + if suffix == identity.bundleID.lowercased() || EvidenceProbe.bundleIDSuffixMatch(suffix, bundleID: identity.bundleID.lowercased()) { + let parentPath = Array(pathComponents[0...i]).joined(separator: "/") + links.append((URL(fileURLWithPath: parentPath), .appGroup)) + } + } } if path.hasPrefix(bundlePath + "/") || path == bundlePath { diff --git a/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift index 1f767ea..6aa3918 100644 --- a/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift +++ b/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift @@ -50,6 +50,19 @@ public struct BrowserRule: ApplicationRule { evidence.append(ArtifactEvidence(source: .appName, weight: 50)) } + // Google Chrome: ~/Library/{Application Support,Caches,Logs}/Google/Chrome + let bid = identity.bundleID.lowercased() + if bid.contains("google") && bid.contains("chrome") { + if path.contains("/application support/google/") || path.contains("/caches/google/") || path.contains("/logs/google/") { + if path.contains("/chrome") || name == "Google" { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + } + if path.contains("/caches/com.google.chrome") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + } + return evidence } } diff --git a/MacOSCleaner/Features/Uninstaller/Rules/DefaultRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/DefaultRule.swift index 80bb41b..5bf2735 100644 --- a/MacOSCleaner/Features/Uninstaller/Rules/DefaultRule.swift +++ b/MacOSCleaner/Features/Uninstaller/Rules/DefaultRule.swift @@ -34,6 +34,20 @@ public struct DefaultRule: ApplicationRule { if parent == "Group Containers" && (name == "group.\(identity.bundleID)" || name.hasPrefix("group.\(identity.bundleID).")) { evidence.append(ArtifactEvidence(source: .rule, weight: 70)) } + if parent == "Group Containers", identity.appGroups.contains(name) { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } else if parent == "Group Containers", let teamID = identity.teamID, name.hasPrefix(teamID + ".") { + let suffix = String(name.dropFirst(teamID.count + 1)).lowercased() + if suffix == identity.bundleID.lowercased() || EvidenceProbe.bundleIDSuffixMatch(suffix, bundleID: identity.bundleID.lowercased()) { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + } + + if path.contains("/library/caches/") || path.contains("/library/logs/") { + if name.lowercased() == identity.bundleID.lowercased() || name.lowercased().hasPrefix(identity.bundleID.lowercased() + ".") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) + } + } if path.contains("/launchagents/") || path.contains("/launchdaemons/") { if path.contains(identity.bundleID.lowercased()) || name.lowercased().contains(identity.appName.lowercased()) { diff --git a/MacOSCleaner/Features/Uninstaller/Rules/DockerRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/DockerRule.swift index 1d5cf16..0342b36 100644 --- a/MacOSCleaner/Features/Uninstaller/Rules/DockerRule.swift +++ b/MacOSCleaner/Features/Uninstaller/Rules/DockerRule.swift @@ -28,8 +28,19 @@ public struct DockerRule: ApplicationRule { if path.contains("/containers/com.docker.orbstack") { evidence.append(ArtifactEvidence(source: .bundleID, weight: 100)) } - if path.contains("/caches/com.docker") { - evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + if path.contains("/group containers/") && path.contains("orbstack") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 90)) + } + if path.contains("/caches/com.docker") || path.contains("/caches/dev.orbstack") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/logs/") && path.contains("orbstack") { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) + } + + // User VM data outside ~/Library (~/Documents/.../Orbstack_files, …) + if path.contains("orbstack") && (path.contains("/documents/") || path.contains("/desktop/") || path.contains("/users/shared/")) { + evidence.append(ArtifactEvidence(source: .rule, weight: 85)) } if path.hasSuffix(".raw") || path.hasSuffix(".qcow2") { diff --git a/MacOSCleaner/Features/Uninstaller/Rules/ElectronRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/ElectronRule.swift index 7440a0f..b76da00 100644 --- a/MacOSCleaner/Features/Uninstaller/Rules/ElectronRule.swift +++ b/MacOSCleaner/Features/Uninstaller/Rules/ElectronRule.swift @@ -24,7 +24,7 @@ public struct ElectronRule: ApplicationRule { ] private let electronArtifactDirs: Set = [ - "Cache", "Code Cache", "GPUCache", + "Cache", "Code Cache", "GPUCache", "CachedData", "Backups", "Local Storage", "Session Storage", "IndexedDB", "blob_storage", "Service Worker", "PartitionedStorage", "Network", "Extensions", @@ -37,15 +37,16 @@ public struct ElectronRule: ApplicationRule { let name = candidate.lastPathComponent var evidence: [ArtifactEvidence] = [] - let appSupportPath = "/application support/\(identity.appName.lowercased())" + let supportNames = [identity.appName, identity.executableName] + [identity.bundleName].compactMap { $0 } + // `path` is lowercased — names must be too, or "Cursor" never matches + let lowerSupportNames = supportNames.map { $0.lowercased() }.filter { !$0.isEmpty } - if electronArtifactDirs.contains(name) { - if path.contains(appSupportPath) { - evidence.append(ArtifactEvidence(source: .rule, weight: 40)) - } + if electronArtifactDirs.contains(name), + lowerSupportNames.contains(where: { path.contains("/application support/\($0)") }) { + evidence.append(ArtifactEvidence(source: .rule, weight: 50)) } - if path.contains(appSupportPath) { + if lowerSupportNames.contains(where: { path.contains("/application support/\($0)") }) { evidence.append(ArtifactEvidence(source: .appName, weight: 60)) } diff --git a/MacOSCleaner/Features/Uninstaller/Rules/MicrosoftOfficeRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/MicrosoftOfficeRule.swift index 3583ee5..59e2f7e 100644 --- a/MacOSCleaner/Features/Uninstaller/Rules/MicrosoftOfficeRule.swift +++ b/MacOSCleaner/Features/Uninstaller/Rules/MicrosoftOfficeRule.swift @@ -13,6 +13,9 @@ public struct MicrosoftOfficeRule: ApplicationRule { "com.microsoft.Excel", "com.microsoft.Word", "com.microsoft.Powerpoint", + "com.microsoft.autoupdate2", + "com.microsoft.autoupdate", + "com.microsoft.autoupdate.fba", ] public let supportedTeamIDs: Set = [ "UBF8T346G9", @@ -56,7 +59,10 @@ public struct MicrosoftOfficeRule: ApplicationRule { evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) } if path.contains("/caches/com.microsoft.") { - evidence.append(ArtifactEvidence(source: .bundleID, weight: 50)) + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + if path.contains("/caches/com.microsoft.autoupdate") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 80)) } if path.contains("/containers/com.microsoft.") { evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) diff --git a/MacOSCleaner/Features/Uninstaller/Rules/ParallelsRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/ParallelsRule.swift index 3b7f424..4933b29 100644 --- a/MacOSCleaner/Features/Uninstaller/Rules/ParallelsRule.swift +++ b/MacOSCleaner/Features/Uninstaller/Rules/ParallelsRule.swift @@ -33,6 +33,12 @@ public struct ParallelsRule: ApplicationRule { if path.contains("/users/shared/parallels") { evidence.append(ArtifactEvidence(source: .rule, weight: 60)) } + if path.contains("orbstack") && (path.contains("/documents/") || path.contains("/desktop/")) { + evidence.append(ArtifactEvidence(source: .rule, weight: 80)) + } + if path.contains("/virtual machines") || path.contains(".pvm") || path.contains(".vmx") { + evidence.append(ArtifactEvidence(source: .rule, weight: 80)) + } if path.contains("/usr/local/bin/prl") { evidence.append(ArtifactEvidence(source: .rule, weight: 60)) } diff --git a/MacOSCleaner/Features/Uninstaller/Rules/VMwareFusionRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/VMwareFusionRule.swift index 3aa5bc5..981dcc5 100644 --- a/MacOSCleaner/Features/Uninstaller/Rules/VMwareFusionRule.swift +++ b/MacOSCleaner/Features/Uninstaller/Rules/VMwareFusionRule.swift @@ -30,6 +30,11 @@ public struct VMwareFusionRule: ApplicationRule { if path.contains("/virtual machines") { evidence.append(ArtifactEvidence(source: .rule, weight: 80)) } + if path.contains("/documents/") || path.contains("/desktop/") { + if path.contains("vmware") || path.contains("virtual machines") || path.contains(".vmx") { + evidence.append(ArtifactEvidence(source: .rule, weight: 80)) + } + } if path.contains("/preferences/vmware fusion") { evidence.append(ArtifactEvidence(source: .appName, weight: 70)) } diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift index 13ab58e..7bae9f9 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift @@ -234,12 +234,15 @@ public actor UninstallerService { private func runDeepRelatedFiles(identity: AppIdentity, graph: EvidenceGraph, mode: ScanMode = .balanced) async -> [RelatedFile] { let collector = CandidateCollector(commandRunner: commandRunner) - let candidates = await collector.collect(identity: identity, mode: mode) + let collection = await collector.collectDetailed(identity: identity, mode: mode) let probe = EvidenceProbe(commandRunner: commandRunner, codesignCache: codesignCache, plistCache: plistCache) // Record evidence - for url in candidates { - let evidences = await probe.probe(url: url, identity: identity) + for url in collection.candidates { + var evidences = await probe.probe(url: url, identity: identity) + if collection.receiptPaths.contains(url) { + evidences.insert(.packageReceipt) + } await graph.record(evidences, for: url) // Attach via ParentLinker @@ -252,19 +255,23 @@ public actor UninstallerService { // Propogate from seeds await graph.propagateFromSeeds(maxDepth: 5) - // Assess confidence + // Assess confidence, boosted by app-specific rule knowledge (Docker, Office, ...) + let rule = await ruleRegistry.bestRule(for: identity) let nodes = await graph.allNodes() - let assessments = ConfidenceEngine.assessAll(nodes, identity: identity) var related: [(RelatedFile, ConfidenceTier)] = [] // Safe mode: only veryLikely and guaranteed; balanced: possible and above let minimumTier: ConfidenceTier = mode == .safe ? .veryLikely : .possible - for (node, assessment) in zip(nodes, assessments) { + for node in nodes { + let ruleScore = rule.evidence(for: node.url, identity: identity).reduce(0) { $0 + $1.weight } + let assessment = ConfidenceEngine.assess(node.evidence, ruleScore: ruleScore, identity: identity) guard assessment.tier >= minimumTier else { continue } guard (try? safetyManager.validate(url: node.url)) != nil else { continue } + guard !Self.isProtectedMailPath(node.url.path) else { continue } guard node.url.path != identity.bundleURL.path else { continue } guard !identity.bundleURL.path.hasPrefix(node.url.path + "/") else { continue } + guard !node.url.path.hasPrefix(identity.bundleURL.path + "/") else { continue } var isDir: ObjCBool = false guard fileManager.fileExists(atPath: node.url.path, isDirectory: &isDir) else { continue } @@ -274,7 +281,8 @@ public actor UninstallerService { let file = RelatedFile( url: node.url, - isSelected: true, + // Weak matches are shown but never pre-selected for deletion + isSelected: assessment.tier >= .veryLikely, size: fileSize, deletionRisk: risk, evidence: assessment.evidence, @@ -337,11 +345,18 @@ public actor UninstallerService { for file in app.relatedFiles where file.isSelected { let path = file.url.path if (path.contains("LaunchAgents") || path.contains("LaunchDaemons")), path.hasSuffix(".plist") { - do { - _ = try await commandRunner.run(command: "/bin/launchctl", arguments: ["unload", path]) - Logger.uninstaller.debug("Unloaded launchctl: \(path, privacy: .public)") - } catch { - Logger.uninstaller.warning("launchctl unload failed '\(path, privacy: .public)': \(error.localizedDescription, privacy: .public)") + // bootout is the modern reliable unload; fall back to legacy unload + let domain = path.contains("LaunchDaemons") ? "system" : "gui/\(getuid())" + let bootout = try? await commandRunner.run(command: "/bin/launchctl", arguments: ["bootout", domain, path]) + if bootout?.exitCode == 0 { + Logger.uninstaller.debug("launchctl bootout: \(path, privacy: .public)") + } else { + do { + _ = try await commandRunner.run(command: "/bin/launchctl", arguments: ["unload", path]) + Logger.uninstaller.debug("Unloaded launchctl: \(path, privacy: .public)") + } catch { + Logger.uninstaller.warning("launchctl unload failed '\(path, privacy: .public)': \(error.localizedDescription, privacy: .public)") + } } } } @@ -420,14 +435,32 @@ public actor UninstallerService { // MARK: - Private helpers + /// Mail message storage must never be offered as an app residual. + /// ~/Library/Mail/Bundles stays allowed — Mail plugins are legitimate residuals. + static func isProtectedMailPath(_ path: String) -> Bool { + let home = NSHomeDirectory() + let mailRoot = "\(home)/Library/Mail" + let bundles = "\(home)/Library/Mail/Bundles" + let mailContainer = "\(home)/Library/Containers/com.apple.mail" + + if path == mailContainer || path.hasPrefix(mailContainer + "/") { return true } + if path == mailRoot { return true } + if path.hasPrefix(mailRoot + "/") { + return !(path == bundles || path.hasPrefix(bundles + "/")) + } + return false + } + private func version(from url: URL) -> String { Bundle(url: url)?.infoDictionary?["CFBundleShortVersionString"] as? String ?? Bundle(url: url)?.infoDictionary?["CFBundleVersion"] as? String ?? "version_unknown".localized } + /// Physical size: what deleting the item actually frees. Sparse VM images + /// (OrbStack data.img.raw) report tens of GB here, not their logical 500 GB. private func getDirectorySize(url: URL) async -> Int64 { - fileManager.getDirectorySize(url: url, excludedPaths: []) + fileManager.getPhysicalDirectorySize(url: url, excludedPaths: []) } private func mergeApps(_ apps: [AppInfo]) -> [AppInfo] { @@ -451,8 +484,12 @@ public actor UninstallerService { let sortedByPath = items.map(\.file).sorted { $0.url.path.count < $1.url.path.count } var deduplicated: [URL: RelatedFile] = [:] for file in sortedByPath { - let isChild = deduplicated.keys.contains { file.url.path.hasPrefix($0.path + "/") } - if !isChild { + // Collapse into parent only when the parent is at least as confident; + // a guaranteed child must not disappear inside an unselected possible parent. + let coveredByParent = deduplicated.values.contains { + file.url.path.hasPrefix($0.url.path + "/") && $0.confidence >= file.confidence + } + if !coveredByParent { deduplicated[file.url] = file } } diff --git a/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift b/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift index b0faf42..95fe42f 100644 --- a/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift +++ b/MacOSCleaner/Features/Uninstaller/VerificationEngine.swift @@ -11,19 +11,22 @@ public actor VerificationEngine { private let plistCache: PlistContentCache private let thresholds: ScoreThresholds private let weights: ScoringWeights + private let ruleRegistry: ApplicationRuleRegistry public init( commandRunner: any CommandRunning = CommandRunner(), codesignCache: CodesignCache = CodesignCache(), plistCache: PlistContentCache = PlistContentCache(), thresholds: ScoreThresholds = .default, - weights: ScoringWeights = .default + weights: ScoringWeights = .default, + ruleRegistry: ApplicationRuleRegistry = ApplicationRuleRegistry.createDefault() ) { self.commandRunner = commandRunner self.codesignCache = codesignCache self.plistCache = plistCache self.thresholds = thresholds self.weights = weights + self.ruleRegistry = ruleRegistry } public func verify(identity: AppIdentity) async -> VerificationReport { @@ -44,12 +47,14 @@ public actor VerificationEngine { ) var artifacts: [ScoredArtifact] = [] + let rule = await ruleRegistry.bestRule(for: identity) for url in candidates { let evidence = await probe.probe(url: url, identity: identity) - guard !evidence.isEmpty else { continue } + let ruleEvidence = rule.evidence(for: url, identity: identity) + guard !evidence.isEmpty || !ruleEvidence.isEmpty else { continue } - let artifactEvidence = evidence.artifactEvidence(weights: weights) + let artifactEvidence = evidence.artifactEvidence(weights: weights) + ruleEvidence let score = artifactEvidence.reduce(0) { $0 + $1.weight } artifacts.append( diff --git a/MacOSCleaner/Infrastructure/FileManager+Size.swift b/MacOSCleaner/Infrastructure/FileManager+Size.swift index 1267fc9..2e70dcb 100644 --- a/MacOSCleaner/Infrastructure/FileManager+Size.swift +++ b/MacOSCleaner/Infrastructure/FileManager+Size.swift @@ -155,47 +155,59 @@ extension FileManager { return size } + /// Physical (allocated) size — the disk space actually freed when the item is + /// deleted. Sparse VM images (OrbStack data.img.raw: 494 GB logical / 44 GB + /// allocated) are counted by their real footprint, never the logical size. + /// Works for single files as well as directories. public func getPhysicalDirectorySize( url: URL, excludedPaths: [String] = FileManager.defaultExcludedPaths ) -> Int64 { + let cacheKey = "physical:\(url.path)" as NSString + FileManager._sizeCacheLock.lock() + if let cached = FileManager._sizeCache.object(forKey: cacheKey) { + FileManager._sizeCacheLock.unlock() + return cached.int64Value + } + FileManager._sizeCacheLock.unlock() + var size: Int64 = 0 - let enumerator = self.enumerator( - at: url, - includingPropertiesForKeys: [.fileSizeKey, .totalFileAllocatedSizeKey, .isDirectoryKey], - options: [] - ) - while let fileURL = enumerator?.nextObject() as? URL { - let shouldExclude: Bool - if excludedPaths.isEmpty { - shouldExclude = false - } else if excludedPaths == FileManager.defaultExcludedPaths { - shouldExclude = FileManager.shouldExclude(url: fileURL) - } else { - let filePath = fileURL.path - shouldExclude = excludedPaths.contains { filePath.contains($0) } - } - if shouldExclude { - if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { - enumerator?.skipDescendants() + if let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .totalFileAllocatedSizeKey]), + values.isRegularFile == true { + size = Int64(values.totalFileAllocatedSize ?? values.fileSize ?? 0) + } else { + let enumerator = self.enumerator( + at: url, + includingPropertiesForKeys: [.fileSizeKey, .totalFileAllocatedSizeKey, .isDirectoryKey], + options: [] + ) + while let fileURL = enumerator?.nextObject() as? URL { + let shouldExclude: Bool + if excludedPaths.isEmpty { + shouldExclude = false + } else if excludedPaths == FileManager.defaultExcludedPaths { + shouldExclude = FileManager.shouldExclude(url: fileURL) + } else { + let filePath = fileURL.path + shouldExclude = excludedPaths.contains { filePath.contains($0) } + } + if shouldExclude { + if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { + enumerator?.skipDescendants() + } + continue } - continue - } - - guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .totalFileAllocatedSizeKey]), - let fileSize = values.fileSize else { continue } - if let allocated = values.totalFileAllocatedSize, allocated > 0, - fileSize / allocated >= FileManager.sparseFileRatioThreshold { - continue - } + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .totalFileAllocatedSizeKey]), + let fileSize = values.fileSize else { continue } - if let allocated = values.totalFileAllocatedSize { - size += Int64(allocated) - } else { - size += Int64(fileSize) + size += Int64(values.totalFileAllocatedSize ?? fileSize) } } + + FileManager._sizeCacheLock.lock() + FileManager._sizeCache.setObject(NSNumber(value: size), forKey: cacheKey) + FileManager._sizeCacheLock.unlock() return size } } diff --git a/MacOSCleaner/Infrastructure/SafetyManager.swift b/MacOSCleaner/Infrastructure/SafetyManager.swift index 582b6c0..ae99cb8 100644 --- a/MacOSCleaner/Infrastructure/SafetyManager.swift +++ b/MacOSCleaner/Infrastructure/SafetyManager.swift @@ -14,6 +14,10 @@ public struct SafetyManager: Sendable { // Checked before exceptions, so they can never be deleted. private let hardRefuseList: [String] + // Directories that must never be deleted wholesale, though their children may be + // (e.g. ~/Library/Preferences itself vs. an app's plist inside it). Exact match only. + private let exactRefuseList: Set + public init(allowedExceptions: [String] = []) { let home = NSHomeDirectory() self.refuseList = [ @@ -30,21 +34,6 @@ public struct SafetyManager: Sendable { "\(home)/.ssh", "\(home)/.gnupg", "\(home)/Documents", - // Sensitive user data - never clean - "\(home)/Library/Keychains", - "\(home)/Library/Mail", - "\(home)/Library/Calendars", - "\(home)/Library/Reminders", - "\(home)/Library/Contacts", - "\(home)/Library/Group Containers", - "\(home)/Library/Containers/com.apple.mail", - // Passwords and credentials - "\(home)/Library/Application Support/Chrome/Default/Login Data", - "\(home)/Library/Application Support/Chrome/Default/Cookies", - "\(home)/Library/Application Support/Google/Chrome/Default/Login Data", - "\(home)/Library/Application Support/Firefox/Profiles", - // Critical system-level user data - "\(home)/Library/Preferences", ] let defaultExceptions = [ @@ -87,6 +76,41 @@ public struct SafetyManager: Sendable { "/Library/Application Support/Apple", "/Library/Application Support/Script Editor", "\(home)/Library/Application Support/Apple", + // Sensitive user data — protected even inside allowed exception roots. + // Mail paths are intentionally absent: cleanup legitimately clears + // attachment caches there; the uninstaller filters Mail on its side. + "\(home)/Library/Keychains", + "\(home)/Library/Calendars", + "\(home)/Library/Reminders", + "\(home)/Library/Contacts", + "\(home)/Library/Application Support/AddressBook", + // Passwords and credentials + "\(home)/Library/Application Support/Chrome/Default/Login Data", + "\(home)/Library/Application Support/Chrome/Default/Cookies", + "\(home)/Library/Application Support/Google/Chrome/Default/Login Data", + "\(home)/Library/Application Support/Google/Chrome/Default/Cookies", + ] + + // Note: roots whose *contents* cleanup legitimately clears (Saved Application + // State, ~/Library/LaunchAgents, /Library/LaunchDaemons) are not listed — + // FileCleanupActor validates the root itself before touching children. + self.exactRefuseList = [ + "/Users", + home, + "\(home)/Library", + "\(home)/Library/Preferences", + "\(home)/Library/Preferences/ByHost", + "\(home)/Library/Application Support", + "\(home)/Library/Caches", + "\(home)/Library/Containers", + "\(home)/Library/Group Containers", + "\(home)/Library/Logs", + "\(home)/Library/WebKit", + "\(home)/Library/HTTPStorages", + "\(home)/Library/Application Scripts", + "\(home)/Library/Developer", + "/Library/Application Support", + "/Library/Preferences", ] } @@ -111,6 +135,10 @@ public struct SafetyManager: Sendable { throw SafetyError.protectedPath(refused) } + if exactRefuseList.contains(p) { + throw SafetyError.protectedPath(p) + } + let isException = allowedExceptions.contains { exception in p == exception || p.hasPrefix(exception + "/") } @@ -119,6 +147,11 @@ public struct SafetyManager: Sendable { continue } + // VM disk images / user containers under Documents or Desktop — app residuals only + if Self.isVirtualizationUserDataResidual(p) { + continue + } + for refused in refuseList { let isExactMatch = (p == refused) let isSubdirectory = p.hasPrefix(refused + "/") @@ -129,4 +162,17 @@ public struct SafetyManager: Sendable { } } } + + /// Allows deletion of VM/container user data under Documents or Desktop when clearly app-owned. + static func isVirtualizationUserDataResidual(_ path: String) -> Bool { + let home = NSHomeDirectory().lowercased() + let lower = path.lowercased() + guard lower.hasPrefix("\(home)/documents/") + || lower.hasPrefix("\(home)/desktop/") else { return false } + let vmMarkers = [ + "orbstack", "parallels", "vmware", "virtualbox", "utm", + "virtual machines", ".pvm", ".vmx", ".vmdk", ".qcow2", + ] + return vmMarkers.contains { lower.contains($0) } + } } diff --git a/MacOSCleaner/MacOSCleanerTests/AppIdentityTests.swift b/MacOSCleaner/MacOSCleanerTests/AppIdentityTests.swift index b9e05b0..8601cc3 100644 --- a/MacOSCleaner/MacOSCleanerTests/AppIdentityTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/AppIdentityTests.swift @@ -89,6 +89,30 @@ final class AppIdentityTests: XCTestCase { wait(for: [expectation], timeout: 10) } + func test_resolve_vendorNames_excludeProductToken() { + // Product token "desktop" from ai.opencode.desktop must not become a vendor name + let appURL = makeAppBundle(named: "OpenCode", bundleID: "ai.opencode.desktop") + let expectation = expectation(description: "resolve") + Task { + let identity = await AppIdentity.resolve(from: appURL) + XCTAssertFalse(identity.vendorNames.contains { $0.lowercased() == "desktop" }) + XCTAssertTrue(identity.vendorNames.contains { $0.lowercased() == "opencode" }) + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + + func test_resolve_appGroups_empty_whenUnsigned() { + let appURL = makeAppBundle(named: "PlainApp", bundleID: "com.plain.app") + let expectation = expectation(description: "resolve") + Task { + let identity = await AppIdentity.resolve(from: appURL) + XCTAssertTrue(identity.appGroups.isEmpty) + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + func test_resolve_teamID_isNil_whenUnsigned() { let appURL = makeAppBundle(named: "UnsignedApp", bundleID: "com.unsigned.app") let expectation = expectation(description: "resolve") diff --git a/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift b/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift index e97f1de..590c2b6 100644 --- a/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift @@ -2,17 +2,23 @@ import XCTest @testable import MacOSCleaner final class CandidateCollectorTests: XCTestCase { - func test_collect_returnsURLs() async { + func test_collect_findsAppSupportDirByExactName() async throws { + let appName = "CollectorTestApp_\(UUID().uuidString.prefix(8))" + let fixture = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library/Application Support/\(appName)") + try FileManager.default.createDirectory(at: fixture, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: fixture) } + let collector = CandidateCollector() let identity = AppIdentity( - bundleID: "com.test.app", - appName: "TestApp", + bundleID: "com.test.\(appName)", + appName: String(appName), bundleName: nil, bundleVersion: nil, - executableName: "TestApp", + executableName: String(appName), teamID: nil, signingAuthority: nil, - bundleURL: URL(fileURLWithPath: "/Applications/TestApp.app"), + bundleURL: URL(fileURLWithPath: "/Applications/\(appName).app"), isAppStore: false, isSandboxed: false, isAdHocSigned: false, vendorNames: ["TestVendor"], helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], @@ -20,6 +26,150 @@ final class CandidateCollectorTests: XCTestCase { isJava: false, isQt: false, isDocker: false ) let candidates = await collector.collect(identity: identity) - XCTAssertFalse(candidates.isEmpty) + XCTAssertTrue(candidates.contains { $0.path == fixture.path }, "Collector must find Application Support dir by exact name") + } + + func test_collect_safeMode_findsExactMatches() async throws { + let appName = "CollectorSafeApp_\(UUID().uuidString.prefix(8))" + let bundleID = "com.test.\(appName)" + let fixture = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library/Caches/\(bundleID)") + try FileManager.default.createDirectory(at: fixture, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: fixture) } + + let collector = CandidateCollector() + let identity = AppIdentity( + bundleID: bundleID, + appName: String(appName), + bundleName: nil, + bundleVersion: nil, + executableName: String(appName), + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/\(appName).app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let candidates = await collector.collect(identity: identity, mode: .safe) + XCTAssertTrue(candidates.contains { $0.path == fixture.path }, "Safe mode must find cache dir by exact bundle ID") + } + + func test_collect_findsNestedCacheByTokenPrefix() async throws { + let appName = "OpenCode" + let home = NSHomeDirectory() + let nested = URL(fileURLWithPath: home) + .appendingPathComponent("Library/Caches/com.other.updater/UpdaterCache/opencode-desktop_br") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: URL(fileURLWithPath: home).appendingPathComponent("Library/Caches/com.other.updater")) } + + let collector = CandidateCollector() + let identity = AppIdentity( + bundleID: "dev.opencode.desktop", + appName: appName, + bundleName: nil, + bundleVersion: nil, + executableName: appName, + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/OpenCode.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["opencode"], + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let candidates = await collector.collect(identity: identity) + XCTAssertTrue(candidates.contains { $0.path == nested.path }) + } + + func test_collect_skipsGenericBundleTailOutsideVendorContext() async throws { + let marker = "TailFP\(UUID().uuidString.prefix(8))" + let home = NSHomeDirectory() + let root = URL(fileURLWithPath: home).appendingPathComponent("Library/Caches/\(marker)") + // ai..desktop must not claim a folder named "desktop" outside vendor context + let trap = root.appendingPathComponent("Data/desktop") + try FileManager.default.createDirectory(at: trap, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let collector = CandidateCollector() + let identity = AppIdentity( + bundleID: "ai.\(marker.lowercased()).desktop", + appName: "\(marker)App", + bundleName: nil, + bundleVersion: nil, + executableName: "\(marker)App", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/\(marker)App.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [marker.lowercased()], + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let candidates = await collector.collect(identity: identity) + XCTAssertFalse(candidates.contains { $0.path == trap.path }, + "Generic bundle-ID tail must not match folders outside a vendor dir") + } + + func test_collect_findsGroupContainerFromEntitlements() async throws { + let marker = "grp\(UUID().uuidString.prefix(8).lowercased())" + let groupName = "FAKETEAM99.com.\(marker).shared" + let home = NSHomeDirectory() + let fixture = URL(fileURLWithPath: home).appendingPathComponent("Library/Group Containers/\(groupName)") + try FileManager.default.createDirectory(at: fixture, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: fixture) } + + let collector = CandidateCollector() + // No teamID, no name relation — only the entitlements declare the group + var identity = AppIdentity( + bundleID: "com.other.\(marker)vpn", + appName: "Grp\(marker)", + bundleName: nil, + bundleVersion: nil, + executableName: "Grp\(marker)", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Grp\(marker).app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + identity.appGroups = [groupName] + let candidates = await collector.collect(identity: identity) + XCTAssertTrue(candidates.contains { $0.path == fixture.path }, + "Group container declared in entitlements must be collected") + } + + func test_collect_findsGoogleChromeVendorPath() async throws { + let home = NSHomeDirectory() + let chromeDir = URL(fileURLWithPath: home) + .appendingPathComponent("Library/Application Support/Google/Chrome") + try FileManager.default.createDirectory(at: chromeDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: URL(fileURLWithPath: home).appendingPathComponent("Library/Application Support/Google")) } + + let collector = CandidateCollector() + let identity = AppIdentity( + bundleID: "com.google.Chrome", + appName: "Google Chrome", + bundleName: "Chrome", + bundleVersion: nil, + executableName: "Google Chrome", + teamID: "EQHXZ8M8AV", + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Google Chrome.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["Google", "Chrome"], + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let candidates = await collector.collect(identity: identity) + XCTAssertTrue(candidates.contains { $0.path == chromeDir.path }) } } diff --git a/MacOSCleaner/MacOSCleanerTests/ConfidenceEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/ConfidenceEngineTests.swift index e2a3999..4eab8cd 100644 --- a/MacOSCleaner/MacOSCleanerTests/ConfidenceEngineTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/ConfidenceEngineTests.swift @@ -108,6 +108,33 @@ final class ConfidenceEngineTests: XCTestCase { XCTAssertEqual(result.tier, .guaranteed) } + func test_ruleScore_boosts_tier() { + let identity = AppIdentity( + bundleID: "com.docker.docker", + appName: "Docker", + bundleName: nil, + bundleVersion: nil, + executableName: "Docker", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Docker.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["Docker"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: true + ) + // vendorName alone = possible; rule knowledge (e.g. DockerRule path match) lifts it + let weak = ConfidenceEngine.assess([.vendorName], identity: identity) + XCTAssertEqual(weak.tier, .possible) + + let boosted = ConfidenceEngine.assess([.vendorName], ruleScore: 100, identity: identity) + XCTAssertEqual(boosted.tier, .guaranteed) + + let mediumBoost = ConfidenceEngine.assess([.vendorName], ruleScore: 40, identity: identity) + XCTAssertEqual(mediumBoost.tier, .veryLikely) + } + func test_ignore_when_no_evidence() { let identity = AppIdentity( bundleID: "com.test.app", diff --git a/MacOSCleaner/MacOSCleanerTests/EvidenceProbeTests.swift b/MacOSCleaner/MacOSCleanerTests/EvidenceProbeTests.swift index 6ce29d2..7cf73ab 100644 --- a/MacOSCleaner/MacOSCleanerTests/EvidenceProbeTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/EvidenceProbeTests.swift @@ -102,6 +102,215 @@ final class EvidenceProbeTests: XCTestCase { XCTAssertTrue(evidence.contains(Evidence.appGroup)) } + func test_probe_appName_caseInsensitive() async { + let probe = EvidenceProbe(codesignCache: CodesignCache(), plistCache: PlistContentCache()) + let identity = AppIdentity( + bundleID: "com.hnc.Discord", + appName: "Discord", + bundleName: nil, + bundleVersion: nil, + executableName: "Discord", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Discord.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: true, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + // Electron apps often use lowercase data dirs + let url = URL(fileURLWithPath: "/Users/test/Library/Application Support/discord") + let evidence = await probe.probe(url: url, identity: identity) + XCTAssertTrue(evidence.contains(Evidence.appNameExact)) + } + + func test_probe_launchAgent_shortName_noSubstringFalsePositive() async { + let probe = EvidenceProbe(codesignCache: CodesignCache(), plistCache: PlistContentCache()) + let identity = AppIdentity( + bundleID: "company.thebrowser.Browser", + appName: "Arc", + bundleName: nil, + bundleVersion: nil, + executableName: "Arc", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Arc.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + // "seARChmarquis" contains "arc" mid-word — must NOT match + let foreign = URL(fileURLWithPath: "/Users/test/Library/LaunchAgents/com.searchmarquis.plist") + let foreignEvidence = await probe.probe(url: foreign, identity: identity) + XCTAssertFalse(foreignEvidence.contains(Evidence.launchAgent)) + + // "ArcUpdater" starts a token with "arc" — must match + let own = URL(fileURLWithPath: "/Users/test/Library/LaunchAgents/ArcUpdater.plist") + let ownEvidence = await probe.probe(url: own, identity: identity) + XCTAssertTrue(ownEvidence.contains(Evidence.launchAgent)) + } + + func test_probe_groupContainer_teamIDPrefix_weakEvidence() async { + let probe = EvidenceProbe(codesignCache: CodesignCache(), plistCache: PlistContentCache()) + let identity = AppIdentity( + bundleID: "com.microsoft.word", + appName: "Microsoft Word", + bundleName: nil, + bundleVersion: nil, + executableName: "Microsoft Word", + teamID: "UBF8T346G9", + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/Microsoft Word.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let url = URL(fileURLWithPath: "/Users/test/Library/Group Containers/UBF8T346G9.Office") + let evidence = await probe.probe(url: url, identity: identity) + XCTAssertTrue(evidence.contains(Evidence.vendorName)) + XCTAssertFalse(evidence.contains(Evidence.appGroup)) + } + + func test_probe_groupContainer_teamIDWithBundleID_strongEvidence() async { + let probe = EvidenceProbe(codesignCache: CodesignCache(), plistCache: PlistContentCache()) + let identity = AppIdentity( + bundleID: "dev.orbstack", + appName: "OrbStack", + bundleName: nil, + bundleVersion: nil, + executableName: "OrbStack", + teamID: "HUAQ24HBR6", + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/OrbStack.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: true + ) + let url = URL(fileURLWithPath: "/Users/test/Library/Group Containers/HUAQ24HBR6.dev.orbstack") + let evidence = await probe.probe(url: url, identity: identity) + XCTAssertTrue(evidence.contains(Evidence.appGroup)) + } + + func test_probe_groupContainer_entitlementAppGroup_strongEvidence() async { + let probe = EvidenceProbe(codesignCache: CodesignCache(), plistCache: PlistContentCache()) + // AdGuard VPN (com.adguard.mac.vpn) declares TC3Q7MAJXF.com.adguard.mac + // in its entitlements — no bundle-ID suffix relation, entitlements only. + var identity = AppIdentity( + bundleID: "com.adguard.mac.vpn", + appName: "AdGuard VPN", + bundleName: "AdGuard VPN", + bundleVersion: nil, + executableName: "AdGuard VPN", + teamID: "TC3Q7MAJXF", + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/AdGuard VPN.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + identity.appGroups = ["TC3Q7MAJXF.com.adguard.mac"] + let url = URL(fileURLWithPath: "/Users/test/Library/Group Containers/TC3Q7MAJXF.com.adguard.mac") + let evidence = await probe.probe(url: url, identity: identity) + XCTAssertTrue(evidence.contains(Evidence.appGroup)) + } + + func test_probe_foreignContainerDesktop_noEvidence() async { + let probe = EvidenceProbe(codesignCache: CodesignCache(), plistCache: PlistContentCache()) + // ai.opencode.desktop must not claim Data/Desktop inside Apple containers + let identity = AppIdentity( + bundleID: "ai.opencode.desktop", + appName: "OpenCode", + bundleName: "OpenCode", + bundleVersion: nil, + executableName: "OpenCode", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/OpenCode.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["OpenCode", "opencode", "Opencode"], + helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let url = URL(fileURLWithPath: "/Users/test/Library/Containers/com.apple.podcasts/Data/Desktop") + let evidence = await probe.probe(url: url, identity: identity) + XCTAssertTrue(evidence.isEmpty, "Foreign container Desktop symlink got \(evidence)") + } + + func test_probe_bundleName_matchesOnlyInBaseDirContext() async { + let probe = EvidenceProbe(codesignCache: CodesignCache(), plistCache: PlistContentCache()) + // iTerm.app declares CFBundleName "iTerm2"; data dir is AS/iTerm2 + let identity = AppIdentity( + bundleID: "com.googlecode.iterm2", + appName: "iTerm", + bundleName: "iTerm2", + bundleVersion: nil, + executableName: "iTerm", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/iTerm.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let own = URL(fileURLWithPath: "/Users/test/Library/Application Support/iTerm2") + let ownEvidence = await probe.probe(url: own, identity: identity) + XCTAssertTrue(ownEvidence.contains(Evidence.appNameExact)) + + // Same name deep inside another app's folder — no identity evidence + let foreign = URL(fileURLWithPath: "/Users/test/Library/Application Support/OtherApp/Data/iTerm2") + let foreignEvidence = await probe.probe(url: foreign, identity: identity) + XCTAssertFalse(foreignEvidence.contains(Evidence.appNameExact)) + } + + func test_probe_cacheFolder_bundleIDExact() async { + let probe = EvidenceProbe(codesignCache: CodesignCache(), plistCache: PlistContentCache()) + let identity = AppIdentity( + bundleID: "com.microsoft.autoupdate2", + appName: "Microsoft AutoUpdate", + bundleName: nil, + bundleVersion: nil, + executableName: "Microsoft AutoUpdate", + teamID: "UBF8T346G9", + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Library/Application Support/Microsoft/MAU2.0/Microsoft AutoUpdate.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: ["Microsoft"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let url = URL(fileURLWithPath: "/Users/test/Library/Caches/com.microsoft.autoupdate2") + let evidence = await probe.probe(url: url, identity: identity) + XCTAssertTrue(evidence.contains(Evidence.bundleIDExact)) + } + + func test_tokenPrefixMatch() { + XCTAssertTrue(EvidenceProbe.tokenPrefixMatch("arcupdater.plist", "arc")) + XCTAssertTrue(EvidenceProbe.tokenPrefixMatch("com.arc.helper", "arc")) + XCTAssertFalse(EvidenceProbe.tokenPrefixMatch("com.searchmarquis.plist", "arc")) + XCTAssertFalse(EvidenceProbe.tokenPrefixMatch("monarch", "arc")) + } + + func test_wordBoundaryMatch() { + XCTAssertTrue(EvidenceProbe.wordBoundaryMatch("/applications/arc.app/contents", "arc")) + XCTAssertTrue(EvidenceProbe.wordBoundaryMatch("open arc now", "arc")) + XCTAssertFalse(EvidenceProbe.wordBoundaryMatch("architecture", "arc")) + XCTAssertFalse(EvidenceProbe.wordBoundaryMatch("searchmarquis", "arc")) + } + func test_probe_noEvidence() async { let codesignCache = CodesignCache() let plistCache = PlistContentCache() diff --git a/MacOSCleaner/MacOSCleanerTests/SafetyManagerTests.swift b/MacOSCleaner/MacOSCleanerTests/SafetyManagerTests.swift index 2a7a891..38bc7b6 100644 --- a/MacOSCleaner/MacOSCleanerTests/SafetyManagerTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/SafetyManagerTests.swift @@ -120,4 +120,58 @@ final class SafetyManagerTests: XCTestCase { XCTAssertNoThrow(try safetyManager.validate(url: url), "Default exceptions should still work") } + + func testSensitiveUserDataProtectedDespiteLibraryException() { + let sensitive = [ + "\(home!)/Library/Keychains/login.keychain-db", + "\(home!)/Library/Calendars/Calendar.sqlitedb", + "\(home!)/Library/Reminders/Container_v1", + "\(home!)/Library/Application Support/AddressBook/AddressBook-v22.abcddb", + "\(home!)/Library/Application Support/Google/Chrome/Default/Login Data", + ] + for path in sensitive { + let url = URL(fileURLWithPath: path) + XCTAssertThrowsError(try safetyManager.validate(url: url), "Sensitive path \(path) must be protected") + } + } + + func testLibraryRootsNotDeletableWholesale() { + let roots = [ + "\(home!)", + "\(home!)/Library", + "\(home!)/Library/Preferences", + "\(home!)/Library/Application Support", + "\(home!)/Library/Group Containers", + "\(home!)/Library/Containers", + ] + for path in roots { + let url = URL(fileURLWithPath: path) + XCTAssertThrowsError(try safetyManager.validate(url: url), "Root \(path) must not be deletable wholesale") + } + } + + func testChildrenOfProtectedRootsStillDeletable() { + let children = [ + "\(home!)/Library/Preferences/com.example.app.plist", + "\(home!)/Library/Group Containers/group.com.example.app", + "\(home!)/Library/Application Support/ExampleApp", + "/Library/LaunchAgents/com.example.agent.plist", + ] + for path in children { + let url = URL(fileURLWithPath: path) + XCTAssertNoThrow(try safetyManager.validate(url: url), "Child \(path) must remain deletable") + } + } + + func testVirtualizationUserDataUnderDocumentsAllowed() { + let vmPath = "\(home!)/Documents/my_project/Orbstack_files" + let url = URL(fileURLWithPath: vmPath) + XCTAssertNoThrow(try safetyManager.validate(url: url)) + } + + func testNonVMDataUnderDocumentsStillProtected() { + let path = "\(home!)/Documents/my_project/source.swift" + let url = URL(fileURLWithPath: path) + XCTAssertThrowsError(try safetyManager.validate(url: url)) + } } diff --git a/MacOSCleaner/MacOSCleanerTests/UninstallerServiceTests.swift b/MacOSCleaner/MacOSCleanerTests/UninstallerServiceTests.swift index 848969d..470b90f 100644 --- a/MacOSCleaner/MacOSCleanerTests/UninstallerServiceTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/UninstallerServiceTests.swift @@ -61,6 +61,40 @@ final class UninstallerServiceTests: XCTestCase { XCTAssertEqual(file.confidence, .guaranteed) } + func testProtectedMailPaths() { + let home = NSHomeDirectory() + XCTAssertTrue(UninstallerService.isProtectedMailPath("\(home)/Library/Mail")) + XCTAssertTrue(UninstallerService.isProtectedMailPath("\(home)/Library/Mail/V10/INBOX.mbox")) + XCTAssertTrue(UninstallerService.isProtectedMailPath("\(home)/Library/Containers/com.apple.mail/Data")) + // Mail plugins are legitimate residuals + XCTAssertFalse(UninstallerService.isProtectedMailPath("\(home)/Library/Mail/Bundles/SomePlugin.mailbundle")) + XCTAssertFalse(UninstallerService.isProtectedMailPath("\(home)/Library/Application Support/SomeApp")) + } + + func testPhysicalSize_sparseFile_reportsAllocatedNotLogical() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("SparseSizeTest_\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + // 1 GB logical, ~0 allocated (APFS sparse file, like OrbStack data.img.raw) + let sparse = dir.appendingPathComponent("data.img.raw") + FileManager.default.createFile(atPath: sparse.path, contents: nil) + let handle = try FileHandle(forWritingTo: sparse) + try handle.truncate(atOffset: 1_073_741_824) + try handle.close() + + let logical = (try? sparse.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + XCTAssertEqual(logical, 1_073_741_824) + + let physical = FileManager.default.getPhysicalDirectorySize(url: dir, excludedPaths: []) + XCTAssertLessThan(physical, 50 * 1024 * 1024, + "Sparse file must be counted by allocated size, got \(physical)") + + let filePhysical = FileManager.default.getPhysicalDirectorySize(url: sparse, excludedPaths: []) + XCTAssertLessThan(filePhysical, 50 * 1024 * 1024) + } + func testEvidence_category_mapping() { XCTAssertEqual(Evidence.bundleIDExact.category, .identity) XCTAssertEqual(Evidence.teamID.category, .signature) From 9d533539eee55535feca1a2c9c4130c60fbee477 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 15:40:17 +0300 Subject: [PATCH 15/23] feat: enhance cleanup functionality with new embedded and generated paths, improve candidate collection, and integrate known residual catalog for better uninstallation accuracy --- .../Domains/Cleanup/CleanupEngine.swift | 255 +-- .../Domains/Cleanup/CleanupPathProvider.swift | 40 + .../Cleanup/EmbeddedCleanupPaths.swift | 62 +- .../Cleanup/GeneratedCleanupPaths.swift | 189 +++ .../Uninstaller/CandidateCollector.swift | 61 +- .../Uninstaller/ConfidenceEngine.swift | 6 +- .../Features/Uninstaller/Evidence.swift | 3 +- .../Features/Uninstaller/EvidenceSource.swift | 2 +- .../Uninstaller/KnownResidualCatalog.swift | 1415 +++++++++++++++++ .../Uninstaller/Rules/BrowserRule.swift | 40 +- .../Features/Uninstaller/ScoringWeights.swift | 2 + .../Uninstaller/UninstallerService.swift | 3 + .../Infrastructure/SafetyManager.swift | 5 + .../MacOSCleaner.xcodeproj/project.pbxproj | 12 + .../KnownResidualCatalogTests.swift | 127 ++ .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + README.md | 122 +- 20 files changed, 1995 insertions(+), 357 deletions(-) create mode 100644 MacOSCleaner/Domains/Cleanup/GeneratedCleanupPaths.swift create mode 100644 MacOSCleaner/Features/Uninstaller/KnownResidualCatalog.swift create mode 100644 MacOSCleaner/MacOSCleanerTests/KnownResidualCatalogTests.swift diff --git a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift index 332973a..f8fe90e 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift @@ -615,6 +615,42 @@ extension CleanupEngine { try await fileActor.cleanContentsParallel(paths, dryRun: dryRun, progress: progress) } + /// Absolute existing paths from EmbeddedCleanupPaths (+ GeneratedCleanupPaths merge). + func resolvedEmbeddedPaths(for category: CleanupCategory) -> [String] { + let home = fm.homeDirectoryForCurrentUser.path + var result: [String] = [] + var seen = Set() + for entry in EmbeddedCleanupPaths.paths(for: category) { + for path in CleanupPathExpander.expand(entry.path, home: home, fileManager: fm) { + if seen.insert(path).inserted { + result.append(path) + } + } + } + return result + } + + func cleanFromEmbeddedPaths( + _ category: CleanupCategory, + label: String, + dryRun: Bool, + progress: (@Sendable (CleanupEngineEvent) -> Void)? + ) async throws -> [CleanupEngineResult] { + let paths = resolvedEmbeddedPaths(for: category) + progress?(.log("Scanning \(label) (\(paths.count) paths)...")) + var totalFreed: Int64 = 0 + for path in paths { + try Task.checkCancellation() + let (freed, item) = try await cleanContents(of: path, dryRun: dryRun, progress: progress) + totalFreed += freed + if dryRun { emitFileItem(item, category: label, parentName: nil, progress: progress) } + } + let mb = Int(totalFreed / (1024 * 1024)) + progress?(.log("\(label) total: \(Self.formatBytes(totalFreed))")) + progress?(.result(label: label, freedMB: mb)) + return [CleanupEngineResult(label: label, freedMB: mb)] + } + func withUserPath(_ command: String) async -> String { await processActor.withUserPath(command) } @@ -1179,149 +1215,7 @@ extension CleanupEngine { func cleanIDECaches(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { let home = fm.homeDirectoryForCurrentUser.path - - let ideDirs = [ - // Cursor - "\(home)/Library/Application Support/Cursor/Cache", - "\(home)/Library/Application Support/Cursor/CachedData", - "\(home)/Library/Application Support/Cursor/Code Cache", - "\(home)/Library/Application Support/Cursor/CachedExtensionVSIXs", - "\(home)/Library/Application Support/Cursor/User/workspaceStorage", - "\(home)/Library/Application Support/Cursor/Crashpad", - "\(home)/Library/Application Support/Cursor/Session Storage", - "\(home)/Library/Application Support/Cursor/Service Worker", - // VS Code - "\(home)/Library/Application Support/Code/Cache", - "\(home)/Library/Application Support/Code/CachedData", - "\(home)/Library/Application Support/Code/CachedExtensionVSIXs", - "\(home)/Library/Application Support/Code/User/workspaceStorage", - "\(home)/Library/Application Support/Code/Crashpad", - "\(home)/Library/Application Support/Code/Session Storage", - "\(home)/Library/Application Support/Code/Service Worker", - // VS Code Insiders - "\(home)/Library/Application Support/Code - Insiders/Cache", - "\(home)/Library/Application Support/Code - Insiders/CachedData", - "\(home)/Library/Application Support/Code - Insiders/CachedExtensionVSIXs", - "\(home)/Library/Application Support/Code - Insiders/User/workspaceStorage", - "\(home)/Library/Application Support/Code - Insiders/Crashpad", - "\(home)/Library/Application Support/Code - Insiders/Session Storage", - "\(home)/Library/Application Support/Code - Insiders/Service Worker", - // Windsurf - "\(home)/Library/Application Support/Windsurf/Cache", - "\(home)/Library/Application Support/Windsurf/CachedData", - "\(home)/Library/Application Support/Windsurf/Code Cache", - "\(home)/Library/Application Support/Windsurf/CachedExtensionVSIXs", - "\(home)/Library/Application Support/Windsurf/User/workspaceStorage", - "\(home)/Library/Application Support/Windsurf/Crashpad", - "\(home)/Library/Application Support/Windsurf/Session Storage", - "\(home)/Library/Application Support/Windsurf/Service Worker", - // Zed - "\(home)/Library/Application Support/dev.zed.Zed/cache", - "\(home)/.config/zed/cache", - // opencode Desktop - "\(home)/Library/Application Support/ai.opencode.desktop/Cache", - "\(home)/Library/Application Support/ai.opencode.desktop/CachedData", - "\(home)/Library/Application Support/ai.opencode.desktop/Code Cache", - "\(home)/Library/Application Support/ai.opencode.desktop/CachedExtensionVSIXs", - "\(home)/Library/Application Support/ai.opencode.desktop/User/workspaceStorage", - "\(home)/Library/Application Support/ai.opencode.desktop/Crashpad", - "\(home)/Library/Application Support/ai.opencode.desktop/Session Storage", - "\(home)/Library/Application Support/ai.opencode.desktop/Service Worker", - // Nova (Panic) - "\(home)/Library/Application Support/Nova/Caches", - "\(home)/Library/Caches/com.panic.Nova", - // Sublime Text 4 - "\(home)/Library/Application Support/Sublime Text/Cache", - "\(home)/Library/Application Support/Sublime Text/Index", - "\(home)/Library/Application Support/Sublime Text/Package Control.cache", - "\(home)/Library/Caches/com.sublimetext.4", - // Sublime Merge - "\(home)/Library/Caches/com.sublimetext.sublime-merge", - // Atom (legacy) - "\(home)/Library/Application Support/Atom/Cache", - "\(home)/Library/Application Support/Atom/CachedData", - "\(home)/Library/Application Support/Atom/Crashpad", - "\(home)/Library/Caches/com.github.atom", - // Gemini - "\(home)/Library/Application Support/Gemini/Cache", - "\(home)/Library/Application Support/Gemini/CachedData", - "\(home)/Library/Application Support/Gemini/Session Storage", - // Perplexity - "\(home)/Library/Application Support/Perplexity/Cache", - "\(home)/Library/Application Support/Perplexity/CachedData", - "\(home)/Library/Application Support/Perplexity/Session Storage", - // GitHub Desktop - "\(home)/Library/Application Support/GitHub Desktop/Cache", - "\(home)/Library/Application Support/GitHub Desktop/CachedData", - "\(home)/Library/Application Support/GitHub Desktop/Code Cache", - "\(home)/Library/Application Support/GitHub Desktop/Session Storage", - // 1Password - "\(home)/Library/Caches/com.1password.1password", - "\(home)/Library/Caches/com.agilebits.onepassword7", - // Tower (Git client) - "\(home)/Library/Caches/com.fournova.Tower3", - // TablePlus - "\(home)/Library/Caches/com.tinyapp.TablePlus", - // Insomnia - "\(home)/Library/Application Support/Insomnia/Cache", - "\(home)/Library/Application Support/Insomnia/CachedData", - "\(home)/Library/Application Support/Insomnia/Code Cache", - // Claude - "\(home)/Library/Application Support/Claude/Cache", - "\(home)/Library/Application Support/Claude/CachedData", - "\(home)/Library/Application Support/Claude/Code Cache", - "\(home)/Library/Application Support/Claude/Session Storage", - "\(home)/Library/Application Support/Claude/Service Worker", - "\(home)/Library/Application Support/Claude/Crashpad", - "\(home)/Library/Application Support/Claude/GPUCache", - // ChatGPT - "\(home)/Library/Application Support/ChatGPT/Cache", - "\(home)/Library/Application Support/ChatGPT/CachedData", - "\(home)/Library/Application Support/ChatGPT/Code Cache", - "\(home)/Library/Application Support/ChatGPT/Session Storage", - "\(home)/Library/Application Support/ChatGPT/Service Worker", - "\(home)/Library/Application Support/ChatGPT/Crashpad", - "\(home)/Library/Application Support/ChatGPT/GPUCache", - // Slack - "\(home)/Library/Application Support/Slack/Cache", - "\(home)/Library/Application Support/Slack/CachedData", - "\(home)/Library/Application Support/Slack/Code Cache", - "\(home)/Library/Application Support/Slack/Service Worker", - "\(home)/Library/Application Support/Slack/Session Storage", - // Discord - "\(home)/Library/Application Support/discord/Cache", - "\(home)/Library/Application Support/discord/CachedData", - "\(home)/Library/Application Support/discord/Code Cache", - "\(home)/Library/Application Support/discord/Session Storage", - "\(home)/Library/Application Support/discord/Crashpad", - // Figma - "\(home)/Library/Application Support/Figma/Cache", - "\(home)/Library/Application Support/Figma/CachedData", - "\(home)/Library/Application Support/Figma/Code Cache", - "\(home)/Library/Application Support/Figma/Session Storage", - // Notion - "\(home)/Library/Application Support/Notion/Cache", - "\(home)/Library/Application Support/Notion/CachedData", - "\(home)/Library/Application Support/Notion/Code Cache", - "\(home)/Library/Application Support/Notion/Session Storage", - // JetBrains logs - "\(home)/Library/Logs/JetBrains", - // Postman - "\(home)/Library/Application Support/Postman/Cache", - "\(home)/Library/Application Support/Postman/CachedData", - "\(home)/Library/Application Support/Postman/Code Cache", - "\(home)/Library/Application Support/Postman/Session Storage", - // Linear - "\(home)/Library/Application Support/Linear/Cache", - "\(home)/Library/Application Support/Linear/CachedData", - "\(home)/Library/Application Support/Linear/Code Cache", - "\(home)/Library/Application Support/Linear/Session Storage", - // JetBrains Toolbox - "\(home)/Library/Application Support/JetBrains/Toolbox/apps", - "\(home)/Library/Caches/JetBrains", - // Vivaldi - "\(home)/Library/Caches/com.vivaldi.Vivaldi", - ] + let ideDirs = resolvedEmbeddedPaths(for: .ideCaches) // Known apps to skip in dynamic discovery let knownApps: Set = [ @@ -1553,36 +1447,7 @@ extension CleanupEngine { // MARK: 10. Browser Caches func cleanBrowserCaches(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { - let home = fm.homeDirectoryForCurrentUser.path - progress?(.log("Scanning browser caches...")) - let browserDirs = [ - "\(home)/Library/Caches/com.apple.Safari", - "\(home)/Library/Safari/Favicon Cache", - "\(home)/Library/Caches/com.brave.Browser", - "\(home)/Library/Caches/com.operasoftware.Opera", - "\(home)/Library/Caches/com.microsoft.Edge", - "\(home)/Library/Caches/org.mozilla.firefox", - "\(home)/Library/Caches/Firefox", - "\(home)/Library/Caches/com.google.Chrome", - "\(home)/Library/Caches/com.google.Chrome.beta", - "\(home)/Library/Caches/com.apple.WebKit.Networking", - "\(home)/Library/Caches/BraveSoftware", - "\(home)/Library/Caches/com.vivaldi.Vivaldi", - "\(home)/Library/Caches/company.thebrowser.Browser", - ] - - var totalFreed: Int64 = 0 - for dir in browserDirs { - try Task.checkCancellation() - let (freed, item) = try await cleanContents(of: dir, dryRun: dryRun, progress: progress) - totalFreed += freed - if dryRun { emitFileItem(item, category: "Browser caches", parentName: nil, progress: progress) } - } - - let mb = Int(totalFreed / (1024 * 1024)) - progress?(.log("Browser caches total: \(Self.formatBytes(totalFreed))")) - progress?(.result(label: "Browser caches", freedMB: mb)) - return [CleanupEngineResult(label: "Browser caches", freedMB: mb)] + try await cleanFromEmbeddedPaths(.browserCaches, label: "Browser caches", dryRun: dryRun, progress: progress) } // MARK: 11. Messaging / Media @@ -1974,49 +1839,7 @@ extension CleanupEngine { // MARK: 17. Dotfile Caches func cleanDotfileCaches(dryRun: Bool, progress: (@Sendable (CleanupEngineEvent) -> Void)?) async throws -> [CleanupEngineResult] { - let home = fm.homeDirectoryForCurrentUser.path - progress?(.log("Scanning dotfile caches...")) - var freed: Int64 = 0 - - // AI CLI tools - let aiPaths = [ - "\(home)/.config/opencode/cache", - "\(home)/.config/claude-cli/cache", - "\(home)/.config/gemini/cache", - "\(home)/.config/codex/cache", - "\(home)/.config/aider/cache", - "\(home)/.config/continue/cache", - "\(home)/.config/cody/cache", - "\(home)/.local/share/ollama/models", - ] - - // Dev tool caches - let devPaths = [ - "\(home)/.npm/_logs", - "\(home)/.terraform.d/cache", - "\(home)/.cache/helm/repository", - "\(home)/.cache/bazel", - "\(home)/.ccache", - "\(home)/.vcpkg/cache", - ] - - // Local Trash - let trashPaths = [ - "\(home)/.local/share/Trash", - ] - - let allPaths = aiPaths + devPaths + trashPaths - for path in allPaths { - guard fm.fileExists(atPath: path) else { continue } - let (f, item) = try await cleanContents(of: path, dryRun: dryRun, progress: progress) - freed += f - if dryRun { emitFileItem(item, category: "Dotfile caches", parentName: nil, progress: progress) } - } - - let mb = Int(freed / (1024 * 1024)) - progress?(.log("Dotfile caches total: \(Self.formatBytes(freed))")) - progress?(.result(label: "Dotfile caches", freedMB: mb)) - return [CleanupEngineResult(label: "Dotfile caches", freedMB: mb)] + try await cleanFromEmbeddedPaths(.dotfileCaches, label: "Dotfile caches", dryRun: dryRun, progress: progress) } // MARK: 18. Scattered Junk diff --git a/MacOSCleaner/Domains/Cleanup/CleanupPathProvider.swift b/MacOSCleaner/Domains/Cleanup/CleanupPathProvider.swift index 63a8d45..71bd688 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupPathProvider.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupPathProvider.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation public struct CleanupPath: Sendable, Equatable, Hashable { @@ -71,4 +72,43 @@ public enum CleanupPathType: String, Sendable { case cloudDocs = "cloud_docs" case sharedFileLists = "shared_file_lists" case developerArtifacts = "developer_artifacts" +} + +public enum CleanupPathExpander { + /// Expands `~` and simple `*`/`?` path components; returns only existing paths. + public static func expand(_ template: String, home: String, fileManager: FileManager = .default) -> [String] { + let absolute = template.hasPrefix("~") ? home + template.dropFirst() : template + guard absolute.contains("*") || absolute.contains("?") else { + return fileManager.fileExists(atPath: absolute) ? [absolute] : [] + } + var matches = [""] + for component in absolute.split(separator: "/", omittingEmptySubsequences: true).map(String.init) { + var next: [String] = [] + if component.contains("*") || component.contains("?") { + for base in matches { + let dir = base.isEmpty ? "/" : base + guard let children = try? fileManager.contentsOfDirectory(atPath: dir) else { continue } + for child in children where Self.fnmatch(pattern: component, string: child) { + next.append(base + "/" + child) + } + } + } else { + for base in matches { + let candidate = base + "/" + component + if fileManager.fileExists(atPath: candidate) { next.append(candidate) } + } + } + matches = next + if matches.isEmpty { return [] } + } + return matches + } + + private static func fnmatch(pattern: String, string: String) -> Bool { + #if canImport(Darwin) + return Darwin.fnmatch(pattern, string, 0) == 0 + #else + return string == pattern + #endif + } } \ No newline at end of file diff --git a/MacOSCleaner/Domains/Cleanup/EmbeddedCleanupPaths.swift b/MacOSCleaner/Domains/Cleanup/EmbeddedCleanupPaths.swift index 6f93231..acb8df1 100644 --- a/MacOSCleaner/Domains/Cleanup/EmbeddedCleanupPaths.swift +++ b/MacOSCleaner/Domains/Cleanup/EmbeddedCleanupPaths.swift @@ -46,6 +46,8 @@ public enum EmbeddedCleanupPaths { CleanupPath(path: "~/Library/WebKit/com.apple.Safari", category: .browserCaches), CleanupPath(path: "~/Library/WebKit/WebsiteData", category: .browserCaches), CleanupPath(path: "~/Library/Safari/Favicon Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Safari/Touch Icons Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Safari/Template Icons", category: .browserCaches), CleanupPath(path: "~/Library/Caches/com.brave.Browser", category: .browserCaches), CleanupPath(path: "~/Library/Caches/com.operasoftware.Opera", category: .browserCaches), CleanupPath(path: "~/Library/Caches/com.microsoft.Edge", category: .browserCaches), @@ -453,33 +455,43 @@ public enum EmbeddedCleanupPaths { // MARK: - Accessor public static func paths(for category: CleanupCategory) -> [CleanupPath] { + let base: [CleanupPath] switch category { - case .appCaches: return appCaches - case .browserCaches: return browserCaches - case .messagingMedia: return messagingMedia - case .ideCaches: return ideCaches - case .languageCaches: return languageCaches - case .systemCaches: return systemCaches - case .dotfileCaches: return dotfileCaches - case .userLogs: return userLogs - case .savedAppState: return savedAppState - case .crashReporter: return crashReporter - case .mailDownloads: return mailDownloads - case .launchAgents: return launchAgents - case .launchDaemons: return launchDaemons - case .privilegedHelpers: return privilegedHelpers - case .pkgReceipts: return pkgReceipts - case .internetPlugins: return internetPlugins - case .sharedFileLists: return sharedFileLists - case .cloudDocs: return cloudDocs - case .photosCache: return photosCache - case .voiceMemos: return voiceMemos - case .garageBandLogic: return garageBandLogic - case .iMovieFinalCut: return iMovieFinalCut - case .garminFitbit: return garminFitbit - case .oldBackups: return oldBackups - default: return [] + case .appCaches: base = appCaches + case .browserCaches: base = browserCaches + case .messagingMedia: base = messagingMedia + case .ideCaches: base = ideCaches + case .languageCaches: base = languageCaches + case .systemCaches: base = systemCaches + case .dotfileCaches: base = dotfileCaches + case .userLogs: base = userLogs + case .savedAppState: base = savedAppState + case .crashReporter: base = crashReporter + case .mailDownloads: base = mailDownloads + case .launchAgents: base = launchAgents + case .launchDaemons: base = launchDaemons + case .privilegedHelpers: base = privilegedHelpers + case .pkgReceipts: base = pkgReceipts + case .internetPlugins: base = internetPlugins + case .sharedFileLists: base = sharedFileLists + case .cloudDocs: base = cloudDocs + case .photosCache: base = photosCache + case .voiceMemos: base = voiceMemos + case .garageBandLogic: base = garageBandLogic + case .iMovieFinalCut: base = iMovieFinalCut + case .garminFitbit: base = garminFitbit + case .oldBackups: base = oldBackups + default: base = [] + } + let generated = GeneratedCleanupPaths.paths(for: category) + guard !generated.isEmpty else { return base } + var seen = Set(base.map(\.path)) + var merged = base + for path in generated where !seen.contains(path.path) { + seen.insert(path.path) + merged.append(path) } + return merged } public static func commands(for category: CleanupCategory) -> [CleanupCommand] { diff --git a/MacOSCleaner/Domains/Cleanup/GeneratedCleanupPaths.swift b/MacOSCleaner/Domains/Cleanup/GeneratedCleanupPaths.swift new file mode 100644 index 0000000..e690cc1 --- /dev/null +++ b/MacOSCleaner/Domains/Cleanup/GeneratedCleanupPaths.swift @@ -0,0 +1,189 @@ +import Foundation + +/// Safe cache/log paths extracted once from the problematic-apps fixture base. +/// Merged into EmbeddedCleanupPaths; hand-maintained onward. +public enum GeneratedCleanupPaths { + + public static let browserCaches: [CleanupPath] = [ + CleanupPath(path: "~/Library/Application Support/Google/Chrome/Crashpad", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/Default/Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/Default/Code Cache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/Default/GPUCache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/Default/Service Worker", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/GrShaderCache", category: .browserCaches), + CleanupPath(path: "~/Library/Application Support/Google/Chrome/ShaderCache", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/Apple/com.apple.Safari", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/Arc", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/Firefox", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/Google/Chrome", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Safari", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Safari.ImageCache", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Safari.SafeBrowsing", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Safari.SearchHelper", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Safari.WebPagePreview", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Safari.WebPageThumbnails", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.Safari.WebResourceLoadStatistics", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.SafariTechnologyPreview", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.apple.WebKit.PluginProcess", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.brave.Browser", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.brave.Browser.ShipIt", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.brave.Browser.beta", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.brave.Browser.nightly", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.duckduckgo.macos.browser", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.google.Chrome", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.google.Chrome.ShipIt", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.google.Chrome.canary", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.edgemac", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.edgemac.Beta", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.edgemac.Canary", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.edgemac.Dev", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.edgemac.ShipIt", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.operasoftware.Opera", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.operasoftware.OperaDeveloperEdition", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.operasoftware.OperaGX", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.vivaldi.Vivaldi", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/com.vivaldi.Vivaldi.ShipIt", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/org.chromium.Chromium", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/org.mozilla.firefox", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/org.mozilla.firefox_esr", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/org.mozilla.firefoxdeveloperedition", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/org.torproject.torbrowser", category: .browserCaches), + CleanupPath(path: "~/Library/Caches/ru.yandex.desktop.yandex-browser", category: .browserCaches), + CleanupPath(path: "~/Library/HTTPStorages/com.apple.Safari.WebPageThumbnails", category: .browserCaches), + ] + + public static let ideCaches: [CleanupPath] = [ + CleanupPath(path: "~/Library/Application Support/Code/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Code/CachedData", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Code/Crashpad", category: .ideCaches), + CleanupPath(path: "~/Library/Application Support/Sublime Text*/Cache", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/AndroidStudio*", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/Google/AndroidStudio*", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/JetBrains", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.apple.dt.Xcode", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.apple.dt.XcodePreviews", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.apple.dt.xcodebuild", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.exafunction.windsurf", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.VSCode", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.VSCode.ShipIt", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.sublimetext.4", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/com.torusknot.SourceTreeNotMAS", category: .ideCaches), + CleanupPath(path: "~/Library/Caches/dev.zed.Zed", category: .ideCaches), + CleanupPath(path: "~/Library/Developer/CoreSimulator/Caches", category: .ideCaches), + CleanupPath(path: "~/Library/Developer/Xcode/DerivedData", category: .ideCaches), + ] + + public static let appCaches: [CleanupPath] = [ + CleanupPath(path: "~/Library/Caches/CMake", category: .appCaches), + CleanupPath(path: "~/Library/Caches/Docker Desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/Homebrew", category: .appCaches), + CleanupPath(path: "~/Library/Caches/co.zeit.hyper", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.DanPristupov.Fork", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.apple.dt.SourceKitService", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.avast.browser", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.axosoft.GitKraken", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.axosoft.GitKraken.ShipIt", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.barebones.bbedit", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.bitwarden.desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.bjango.istatmenus", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.blackpixel.kaleidoscope", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.bohemiancoding.sketch3", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.docker.docker", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.facebook.watchman", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.fenrir-inc.Sleipnir", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.figma.Desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.figma.Desktop.ShipIt", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.fournova.Tower3", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.framer.desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.ghostery.browser", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.github.GitHubClient", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.github.GitHubClient.ShipIt", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.googlecode.iterm2", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.hiddenreflex.epic", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.insomnia.app", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.insomnia.app.ShipIt", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.kagi.kagimacOS", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.kapeli.dashdoc", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.luckymarmot.Paw", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.macromates.TextMate", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.maxthon.mac.maxthon", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.microsoft.azuredatastudio", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.mongodb.compass", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.navicat.NavicatPremium", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.omnigroup.OmniWeb5", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.oracle.java.Java-Updater", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.oracle.mysql.workbench", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.panic.Nova", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.piriform.ccleaner.browser", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.postmanlabs.mac", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.postmanlabs.mac.ShipIt", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.proxyman.NSProxy", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.raycast.macos", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.redis.RedisInsight", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.runningwithcrayons.Alfred", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.sequel-ace.sequel-ace", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.sequelpro.SequelPro", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.sigmaos.sigmaos.macos", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.tableplus.TablePlus", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.todesktop.230313mzl4w4u92", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.usebruno.app", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.utmapp.UTM", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.vagrant.vagrant", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.wiheads.paste", category: .appCaches), + CleanupPath(path: "~/Library/Caches/com.xk72.Charles", category: .appCaches), + CleanupPath(path: "~/Library/Caches/company.thebrowser.Browser", category: .appCaches), + CleanupPath(path: "~/Library/Caches/dev.orbstack.OrbStack", category: .appCaches), + CleanupPath(path: "~/Library/Caches/dev.warp.Warp-Stable", category: .appCaches), + CleanupPath(path: "~/Library/Caches/expo", category: .appCaches), + CleanupPath(path: "~/Library/Caches/flutter", category: .appCaches), + CleanupPath(path: "~/Library/Caches/go-build", category: .appCaches), + CleanupPath(path: "~/Library/Caches/io.devdocs.desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/io.gitlab.librewolf-community", category: .appCaches), + CleanupPath(path: "~/Library/Caches/io.gitpod.gitpod-desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/io.hoppscotch.desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/io.httpie.desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/io.rancher.desktop", category: .appCaches), + CleanupPath(path: "~/Library/Caches/java", category: .appCaches), + CleanupPath(path: "~/Library/Caches/jp.lunascape.lunascape", category: .appCaches), + CleanupPath(path: "~/Library/Caches/net.ablaze.floorp", category: .appCaches), + CleanupPath(path: "~/Library/Caches/net.mullvad.MullvadBrowser", category: .appCaches), + CleanupPath(path: "~/Library/Caches/net.waterfox.waterfox", category: .appCaches), + CleanupPath(path: "~/Library/Caches/ngrok", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.basilisk.basilisk", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.jkiss.dbeaver.core.product", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.keepassx.keepassxc", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.mozilla.nightly", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.mozilla.seamonkey", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.palemoon.PaleMoon", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.pgadmin.pgadmin4", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.ruby-lang.ruby", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.swift.swiftpm", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.tabby", category: .appCaches), + CleanupPath(path: "~/Library/Caches/org.wireshark.Wireshark", category: .appCaches), + ] + + public static let dotfileCaches: [CleanupPath] = [ + CleanupPath(path: "~/.aws/cache", category: .dotfileCaches), + CleanupPath(path: "~/.cargo/registry/cache", category: .dotfileCaches), + CleanupPath(path: "~/.config/gcloud/cache", category: .dotfileCaches), + CleanupPath(path: "~/.gradle/caches", category: .dotfileCaches), + CleanupPath(path: "~/.kube/cache", category: .dotfileCaches), + CleanupPath(path: "~/.minikube/cache", category: .dotfileCaches), + CleanupPath(path: "~/.pyenv/cache", category: .dotfileCaches), + CleanupPath(path: "~/.swiftpm/cache", category: .dotfileCaches), + ] + + public static let userLogs: [CleanupPath] = [ + ] + + public static func paths(for category: CleanupCategory) -> [CleanupPath] { + switch category { + case .browserCaches: return browserCaches + case .ideCaches: return ideCaches + case .appCaches: return appCaches + case .dotfileCaches: return dotfileCaches + case .userLogs: return userLogs + default: return [] + } + } +} diff --git a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift index c0a66b5..54cb3b5 100644 --- a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift +++ b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift @@ -4,6 +4,8 @@ public struct CandidateCollection: Sendable { public let candidates: Set /// Subset of `candidates` that came from a pkgutil receipt — guaranteed app files. public let receiptPaths: Set + /// Subset from KnownResidualCatalog — high-confidence curated residuals. + public let catalogPaths: Set } public actor CandidateCollector { @@ -37,11 +39,27 @@ public actor CandidateCollector { "\(home)/Library/Saved Application State", "\(home)/Library/Application Scripts", "\(home)/Library/Logs", + "\(home)/Library/Logs/DiagnosticReports", + "\(home)/Library/Cookies", + "\(home)/Library/Internet Plug-Ins", + "\(home)/Library/QuickLook", + "\(home)/Library/Application Support/CrashReporter", "\(home)/Library/LaunchAgents", "/Library/LaunchAgents", "/Library/LaunchDaemons", "/Library/Preferences", "/Library/Application Support", + "/Library/PrivilegedHelperTools", + "/Library/Internet Plug-Ins", + "/Library/QuickLook", + "/Library/Spotlight", + "/Library/PreferencePanes", + "/Library/Input Methods", + "/Library/Logs/DiagnosticReports", + "/Library/Audio/Plug-Ins/HAL", + "/Library/Audio/Plug-Ins/Components", + "/Library/Audio/Plug-Ins/VST", + "/Library/Audio/Plug-Ins/VST3", "\(home)/Library/Developer", ] @@ -201,7 +219,21 @@ public actor CandidateCollector { // 15. Browser vendor folders (Google/Chrome, Mozilla/Firefox, …) candidates.formUnion(await collectBrowserVendorPaths(identity: identity, home: home, maxDepth: maxDepth)) - return CandidateCollection(candidates: candidates, receiptPaths: receiptPaths) + // 16. Known residual catalog (exact/glob templates for problematic apps) + let catalogPaths = collectCatalogPaths(identity: identity, home: home) + candidates.formUnion(catalogPaths) + + return CandidateCollection(candidates: candidates, receiptPaths: receiptPaths, catalogPaths: catalogPaths) + } + + private func collectCatalogPaths(identity: AppIdentity, home: String) -> Set { + var found = Set() + for template in KnownResidualCatalog.pathTemplates(for: identity) { + for path in KnownResidualCatalog.expand(template: template, home: home, fileManager: fileManager) { + found.insert(URL(fileURLWithPath: path)) + } + } + return found } /// Files recorded in installer receipts for packages whose id matches the bundle ID. @@ -341,14 +373,35 @@ public actor CandidateCollector { /// Google Chrome lives under ~/Library/.../Google/Chrome, not a top-level "Google Chrome" folder. private func collectBrowserVendorPaths(identity: AppIdentity, home: String, maxDepth: Int) async -> Set { let bid = identity.bundleID.lowercased() + let app = identity.appName.lowercased() var vendorRoots: [(vendor: String, product: String?)] = [] - if bid.contains("google") && (bid.contains("chrome") || identity.appName.lowercased().contains("chrome")) { + if bid.contains("google") && (bid.contains("chrome") || app.contains("chrome")) { vendorRoots.append(("Google", "Chrome")) - } else if bid.contains("mozilla") || identity.appName.lowercased().contains("firefox") { + } else if bid.contains("mozilla") || app.contains("firefox") { vendorRoots.append(("Mozilla", nil)) - } else if bid.contains("microsoft.edgemac") || identity.appName.lowercased().contains("edge") { + vendorRoots.append(("Firefox", nil)) + } else if bid.contains("microsoft.edgemac") || app.contains("edge") { vendorRoots.append(("Microsoft Edge", nil)) + } else if bid.contains("brave") || app.contains("brave") { + vendorRoots.append(("BraveSoftware", nil)) + vendorRoots.append(("BraveSoftware", "Brave-Browser")) + } else if bid.contains("operasoftware") || app.contains("opera") { + vendorRoots.append(("com.operasoftware.Opera", nil)) + vendorRoots.append(("Opera Software", nil)) + vendorRoots.append(("Opera", nil)) + } else if bid.contains("vivaldi") || app.contains("vivaldi") { + vendorRoots.append(("Vivaldi", nil)) + } else if bid.contains("thebrowser") || app == "arc" { + vendorRoots.append(("Arc", nil)) + } else if bid.contains("chromium") || app.contains("chromium") { + vendorRoots.append(("Chromium", nil)) + } else if bid.contains("torproject") || app.contains("tor") { + vendorRoots.append(("TorBrowser-Data", nil)) + } else if bid.contains("duckduckgo") { + vendorRoots.append(("DuckDuckGo", nil)) + } else if bid.contains("yandex") { + vendorRoots.append(("Yandex", nil)) } var found = Set() diff --git a/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift b/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift index edc153b..129f1ae 100644 --- a/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift +++ b/MacOSCleaner/Features/Uninstaller/ConfidenceEngine.swift @@ -2,7 +2,7 @@ import Foundation public enum ConfidenceEngine { public static let criticalEvidence: Set = [ - .bundleIDExact, .bundleIDPrefix, .packageReceipt, + .bundleIDExact, .bundleIDPrefix, .packageReceipt, .knownCatalog, .spotlightBundleAttr, .loginItem, ] @@ -13,8 +13,8 @@ public enum ConfidenceEngine { var tier: ConfidenceTier if score >= 100 { if evidence.contains(.bundleIDExact) || evidence.contains(.bundleIDPrefix) - || evidence.contains(.packageReceipt) || evidence.contains(.spotlightBundleAttr) - || evidence.contains(.loginItem) + || evidence.contains(.packageReceipt) || evidence.contains(.knownCatalog) + || evidence.contains(.spotlightBundleAttr) || evidence.contains(.loginItem) || ruleScore >= 100 || (evidence.contains(.teamID) && (evidence.contains(.launchAgent) || evidence.contains(.launchDaemon) || evidence.contains(.extension) || evidence.contains(.appGroup) || evidence.contains(.container))) { diff --git a/MacOSCleaner/Features/Uninstaller/Evidence.swift b/MacOSCleaner/Features/Uninstaller/Evidence.swift index 0b770f2..a2800d7 100644 --- a/MacOSCleaner/Features/Uninstaller/Evidence.swift +++ b/MacOSCleaner/Features/Uninstaller/Evidence.swift @@ -23,6 +23,7 @@ public enum Evidence: String, Sendable, Hashable, CaseIterable { case xpcConnection case packageReceipt + case knownCatalog case plistContent case spotlight @@ -61,7 +62,7 @@ public extension Evidence { case .launchAgent, .launchDaemon, .loginItem, .appGroup, .container, .extension, .xpcConnection: return .system - case .packageReceipt, .plistContent: + case .packageReceipt, .knownCatalog, .plistContent: return .metadata case .spotlight, .spotlightBundleAttr, .spotlightCreator: return .content diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceSource.swift b/MacOSCleaner/Features/Uninstaller/EvidenceSource.swift index f5bbd4f..b3a4cf9 100644 --- a/MacOSCleaner/Features/Uninstaller/EvidenceSource.swift +++ b/MacOSCleaner/Features/Uninstaller/EvidenceSource.swift @@ -86,7 +86,7 @@ extension Evidence { .electronCache, .jetBrainsConfig, .flutterBuild, .launchAgent, .launchDaemon, .loginItem, .appGroup, .container, .extension, .xpcConnection, - .packageReceipt, .launchServicesRegistered: + .packageReceipt, .knownCatalog, .launchServicesRegistered: return .rule } } diff --git a/MacOSCleaner/Features/Uninstaller/KnownResidualCatalog.swift b/MacOSCleaner/Features/Uninstaller/KnownResidualCatalog.swift new file mode 100644 index 0000000..83d165c --- /dev/null +++ b/MacOSCleaner/Features/Uninstaller/KnownResidualCatalog.swift @@ -0,0 +1,1415 @@ +import Foundation + +/// Curated residual locations for apps known to break uninstallers. +/// Source of truth: this file (one-shot extracted from the problematic-apps +/// fixture base, hand-maintained onward). Paths use `~` for the user home and +/// may contain `*` globs in individual components. +/// +/// Never lists shared components (Google Keystone, Microsoft AutoUpdate, +/// Edge Updater), shared developer toolchains (~/.gradle, Android SDK) or +/// SIP-protected system apps (Safari). +public enum KnownResidualCatalog { + + public struct Entry: Sendable { + public let name: String + /// Lowercased exact bundle identifiers. + public let bundleIDs: Set + /// Lowercased bundle-ID family prefixes with a trailing dot (com.jetbrains.). + public let bundleIDPrefixes: [String] + public let pathTemplates: [String] + } + + /// Templates whose entry matches the identity's bundle ID; empty when unknown. + public static func pathTemplates(for identity: AppIdentity) -> [String] { + pathTemplates(bundleID: identity.bundleID) + } + + public static func pathTemplates(bundleID: String) -> [String] { + let bid = bundleID.lowercased() + guard !bid.isEmpty, !bid.hasPrefix("unknown.") else { return [] } + var templates: [String] = [] + for entry in entries where entry.bundleIDs.contains(bid) + || entry.bundleIDPrefixes.contains(where: { bid.hasPrefix($0) }) { + templates.append(contentsOf: entry.pathTemplates) + } + return templates + } + + /// Expands a template: `~` -> home, `*`/`?` glob components via directory listing. + /// Returns only existing paths. + public static func expand(template: String, home: String, fileManager: FileManager = .default) -> [String] { + CleanupPathExpander.expand(template, home: home, fileManager: fileManager) + } + + public static let entries: [Entry] = [ + // MARK: 1Password (developer tool for many) + Entry( + name: "1Password (developer tool for many)", + bundleIDs: ["com.1password.1password"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.config/op", + "~/.op", + "~/Library/Caches/com.1password.1password", + "~/Library/Containers/com.1password.1password", + "~/Library/Group Containers/2BUA8C4S2C.com.agilebits", + "~/Library/Preferences/com.1password.1password-helper.plist", + "~/Library/Preferences/com.1password.1password.plist", + ] + ), + // MARK: Alacritty + Entry( + name: "Alacritty", + bundleIDs: ["org.alacritty"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.config/alacritty", + "~/Library/Preferences/org.alacritty.plist", + ] + ), + // MARK: Alfred (developer-focused launcher) + Entry( + name: "Alfred (developer-focused launcher)", + bundleIDs: ["com.runningwithcrayons.alfred-preferences"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Alfred", + "~/Library/Caches/com.runningwithcrayons.Alfred", + "~/Library/Preferences/com.runningwithcrayons.Alfred-Preferences.plist", + "~/Library/Preferences/com.runningwithcrayons.Alfred.plist", + ] + ), + // MARK: Android Studio + Entry( + name: "Android Studio", + bundleIDs: ["com.google.android.studio"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/AndroidStudio*", + "~/Library/Application Support/Google/AndroidStudio*", + "~/Library/Caches/AndroidStudio*", + "~/Library/Caches/Google/AndroidStudio*", + "~/Library/Caches/JetBrains/AndroidStudio*", + "~/Library/HTTPStorages/com.google.android.studio", + "~/Library/Logs/AndroidStudio*", + "~/Library/Logs/Google/AndroidStudio*", + "~/Library/Preferences/AndroidStudio*", + "~/Library/Preferences/com.android.*", + "~/Library/Preferences/com.google.android.studio.plist", + "~/Library/Saved Application State/com.google.android.studio.savedState", + ] + ), + // MARK: Araxis Merge + Entry( + name: "Araxis Merge", + bundleIDs: ["com.araxis.merge"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Araxis Merge", + "~/Library/Preferences/com.araxis.merge.plist", + ] + ), + // MARK: Arc Browser + Entry( + name: "Arc Browser", + bundleIDs: ["company.thebrowser.browser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Arc", + "~/Library/Caches/Arc", + "~/Library/Caches/company.thebrowser.Browser", + "~/Library/HTTPStorages/company.thebrowser.Browser", + "~/Library/Logs/Arc", + "~/Library/Preferences/company.thebrowser.Browser.plist", + "~/Library/Saved Application State/company.thebrowser.Browser.savedState", + "~/Library/WebKit/company.thebrowser.Browser", + ] + ), + // MARK: Avast Secure Browser + Entry( + name: "Avast Secure Browser", + bundleIDs: ["com.avast.browser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/AvastSoftware/AvastSecureBrowser", + "~/Library/Caches/com.avast.browser", + "~/Library/Preferences/com.avast.browser.plist", + ] + ), + // MARK: Azure Data Studio + Entry( + name: "Azure Data Studio", + bundleIDs: ["com.microsoft.azuredatastudio"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/azuredatastudio", + "~/Library/Caches/com.microsoft.azuredatastudio", + "~/Library/Preferences/com.microsoft.azuredatastudio.plist", + "~/Library/Saved Application State/com.microsoft.azuredatastudio.savedState", + ] + ), + // MARK: Basilisk + Entry( + name: "Basilisk", + bundleIDs: ["org.basilisk.basilisk"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Basilisk", + "~/Library/Caches/org.basilisk.basilisk", + "~/Library/Preferences/org.basilisk.basilisk.plist", + ] + ), + // MARK: BBEdit + Entry( + name: "BBEdit", + bundleIDs: ["com.barebones.bbedit"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/BBEdit", + "~/Library/Caches/com.barebones.bbedit", + "~/Library/Preferences/com.barebones.bbedit.plist", + ] + ), + // MARK: Beyond Compare + Entry( + name: "Beyond Compare", + bundleIDs: ["com.scootersoftware.beyondcompare"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Beyond Compare", + "~/Library/Preferences/com.ScooterSoftware.BeyondCompare.plist", + ] + ), + // MARK: Bitwarden + Entry( + name: "Bitwarden", + bundleIDs: ["com.bitwarden.desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.config/Bitwarden CLI", + "~/Library/Application Support/Bitwarden", + "~/Library/Caches/com.bitwarden.desktop", + "~/Library/Preferences/com.bitwarden.desktop.plist", + ] + ), + // MARK: Brave Browser + Entry( + name: "Brave Browser", + bundleIDs: ["com.brave.browser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/BraveSoftware/Brave-Browser", + "~/Library/Caches/com.brave.Browser", + "~/Library/Caches/com.brave.Browser.ShipIt", + "~/Library/Logs/BraveSoftware", + "~/Library/Preferences/com.brave.Browser.plist", + "~/Library/Saved Application State/com.brave.Browser.savedState", + ] + ), + // MARK: Brave Browser Beta / Nightly + Entry( + name: "Brave Browser Beta / Nightly", + bundleIDs: ["com.brave.browser.beta", "com.brave.browser.nightly"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/BraveSoftware/Brave-Browser-Beta", + "~/Library/Application Support/BraveSoftware/Brave-Browser-Nightly", + "~/Library/Caches/com.brave.Browser.beta", + "~/Library/Caches/com.brave.Browser.nightly", + ] + ), + // MARK: Bruno + Entry( + name: "Bruno", + bundleIDs: ["com.usebruno.app"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Bruno", + "~/Library/Caches/com.usebruno.app", + "~/Library/Preferences/com.usebruno.app.plist", + ] + ), + // MARK: Camino (discontinued) + Entry( + name: "Camino (discontinued)", + bundleIDs: ["org.mozilla.camino"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Camino", + "~/Library/Preferences/org.mozilla.camino.plist", + ] + ), + // MARK: CCleaner Browser + Entry( + name: "CCleaner Browser", + bundleIDs: ["com.piriform.ccleaner.browser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/CCleaner Browser", + "~/Library/Caches/com.piriform.ccleaner.browser", + "~/Library/Preferences/com.piriform.ccleaner.browser.plist", + ] + ), + // MARK: Charles Proxy + Entry( + name: "Charles Proxy", + bundleIDs: ["com.xk72.charles"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Charles", + "~/Library/Caches/com.xk72.Charles", + "~/Library/Logs/Charles", + "~/Library/Preferences/com.xk72.Charles.plist", + ] + ), + // MARK: Chromium (unbranded) + Entry( + name: "Chromium (unbranded)", + bundleIDs: ["org.chromium.chromium"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Chromium", + "~/Library/Caches/org.chromium.Chromium", + "~/Library/Preferences/org.chromium.Chromium.plist", + ] + ), + // MARK: Coast by Opera (discontinued) + Entry( + name: "Coast by Opera (discontinued)", + bundleIDs: ["com.opera.coastmac"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Coast", + "~/Library/Preferences/com.opera.CoastMac.plist", + ] + ), + // MARK: Cursor (VS Code Fork) + Entry( + name: "Cursor (VS Code Fork)", + bundleIDs: ["com.todesktop.230313mzl4w4u92"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.cursor", + "~/Library/Application Support/Cursor", + "~/Library/Caches/com.todesktop.230313mzl4w4u92", + "~/Library/HTTPStorages/com.todesktop.230313mzl4w4u92", + "~/Library/Preferences/com.todesktop.230313mzl4w4u92.plist", + "~/Library/Saved Application State/com.todesktop.230313mzl4w4u92.savedState", + ] + ), + // MARK: Dash + Entry( + name: "Dash", + bundleIDs: ["com.kapeli.dashdoc"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Dash", + "~/Library/Caches/com.kapeli.dashdoc", + "~/Library/Preferences/com.kapeli.dashdoc.plist", + "~/Library/Saved Application State/com.kapeli.dashdoc.savedState", + ] + ), + // MARK: DataGrip (JetBrains) + Entry( + name: "DataGrip (JetBrains)", + bundleIDs: ["com.jetbrains.datagrip"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/JetBrains/DataGrip*", + "~/Library/Caches/JetBrains/DataGrip*", + "~/Library/Logs/JetBrains/DataGrip*", + "~/Library/Preferences/com.jetbrains.datagrip.plist", + ] + ), + // MARK: DBeaver + Entry( + name: "DBeaver", + bundleIDs: ["org.jkiss.dbeaver.core.product"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/DBeaverData", + "~/Library/Caches/org.jkiss.dbeaver.core.product", + "~/Library/DBeaverData", + "~/Library/Preferences/org.jkiss.dbeaver.core.product.plist", + "~/Library/Saved Application State/org.jkiss.dbeaver.core.product.savedState", + ] + ), + // MARK: DevDocs (desktop app) + Entry( + name: "DevDocs (desktop app)", + bundleIDs: ["io.devdocs.desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/DevDocs", + "~/Library/Caches/io.devdocs.desktop", + "~/Library/Preferences/io.devdocs.desktop.plist", + ] + ), + // MARK: DevUtils + Entry( + name: "DevUtils", + bundleIDs: ["com.devutils.app"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/DevUtils", + "~/Library/Preferences/com.devutils.app.plist", + ] + ), + // MARK: Docker Desktop + Entry( + name: "Docker Desktop", + bundleIDs: ["com.docker.docker"], + bundleIDPrefixes: [], + pathTemplates: [ + "/Library/LaunchDaemons/com.docker.socket.plist", + "/Library/LaunchDaemons/com.docker.vmnetd.plist", + "/Library/PrivilegedHelperTools/com.docker.vmnetd", + "~/Library/Application Support/Docker Desktop", + "~/Library/Caches/Docker Desktop", + "~/Library/Caches/com.docker.docker", + "~/Library/Containers/com.docker.docker", + "~/Library/Group Containers/group.com.docker", + "~/Library/HTTPStorages/com.docker.docker", + "~/Library/Logs/Docker Desktop", + "~/Library/Preferences/com.docker.docker.plist", + "~/Library/Preferences/com.docker.helper.plist", + "~/Library/Saved Application State/com.docker.docker.savedState", + ] + ), + // MARK: DuckDuckGo Browser + Entry( + name: "DuckDuckGo Browser", + bundleIDs: ["com.duckduckgo.macos.browser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/DuckDuckGo", + "~/Library/Caches/com.duckduckgo.macos.browser", + "~/Library/Preferences/com.duckduckgo.macos.browser.plist", + ] + ), + // MARK: Epic Privacy Browser + Entry( + name: "Epic Privacy Browser", + bundleIDs: ["com.hiddenreflex.epic"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Epic", + "~/Library/Caches/com.hiddenreflex.epic", + "~/Library/Preferences/com.hiddenreflex.epic.plist", + ] + ), + // MARK: Espresso + Entry( + name: "Espresso", + bundleIDs: ["com.macrabbit.espresso"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Espresso", + "~/Library/Preferences/com.macrabbit.Espresso.plist", + ] + ), + // MARK: Figma (Desktop) + Entry( + name: "Figma (Desktop)", + bundleIDs: ["com.figma.desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Figma", + "~/Library/Caches/com.figma.Desktop", + "~/Library/Caches/com.figma.Desktop.ShipIt", + "~/Library/Logs/Figma", + "~/Library/Preferences/com.figma.Desktop.plist", + "~/Library/Saved Application State/com.figma.Desktop.savedState", + ] + ), + // MARK: Firefox Developer Edition + Entry( + name: "Firefox Developer Edition", + bundleIDs: ["org.mozilla.firefoxdeveloperedition"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Firefox/Profiles/*.dev-edition-default*", + "~/Library/Caches/org.mozilla.firefoxdeveloperedition", + "~/Library/Preferences/org.mozilla.firefoxdeveloperedition.plist", + ] + ), + // MARK: Firefox ESR + Entry( + name: "Firefox ESR", + bundleIDs: ["org.mozilla.firefox_esr"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Firefox/Profiles/*.default-esr*", + "~/Library/Caches/org.mozilla.firefox_esr", + "~/Library/Preferences/org.mozilla.firefox_esr.plist", + ] + ), + // MARK: Firefox Nightly + Entry( + name: "Firefox Nightly", + bundleIDs: ["org.mozilla.nightly"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Firefox/Profiles/*.default-nightly*", + "~/Library/Caches/org.mozilla.nightly", + "~/Library/Preferences/org.mozilla.nightly.plist", + ] + ), + // MARK: Floorp + Entry( + name: "Floorp", + bundleIDs: ["net.ablaze.floorp"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Floorp", + "~/Library/Caches/net.ablaze.floorp", + "~/Library/Preferences/net.ablaze.floorp.plist", + ] + ), + // MARK: Fork + Entry( + name: "Fork", + bundleIDs: ["com.danpristupov.fork"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.DanPristupov.Fork", + "~/Library/Caches/com.DanPristupov.Fork", + "~/Library/Preferences/com.DanPristupov.Fork.plist", + ] + ), + // MARK: Framer + Entry( + name: "Framer", + bundleIDs: ["com.framer.desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Framer", + "~/Library/Caches/com.framer.desktop", + "~/Library/Preferences/com.framer.desktop.plist", + "~/Library/Saved Application State/com.framer.desktop.savedState", + ] + ), + // MARK: Ghostery Browser + Entry( + name: "Ghostery Browser", + bundleIDs: ["com.ghostery.browser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Ghostery", + "~/Library/Caches/com.ghostery.browser", + "~/Library/Preferences/com.ghostery.browser.plist", + ] + ), + // MARK: GitHub Desktop + Entry( + name: "GitHub Desktop", + bundleIDs: ["com.github.githubclient"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/GitHub Desktop", + "~/Library/Caches/com.github.GitHubClient", + "~/Library/Caches/com.github.GitHubClient.ShipIt", + "~/Library/HTTPStorages/com.github.GitHubClient", + "~/Library/Logs/GitHub Desktop", + "~/Library/Preferences/com.github.GitHubClient.plist", + "~/Library/Saved Application State/com.github.GitHubClient.savedState", + ] + ), + // MARK: GitKraken + Entry( + name: "GitKraken", + bundleIDs: ["com.axosoft.gitkraken"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/GitKraken", + "~/Library/Caches/com.axosoft.GitKraken", + "~/Library/Caches/com.axosoft.GitKraken.ShipIt", + "~/Library/Logs/GitKraken", + "~/Library/Preferences/com.axosoft.GitKraken.plist", + "~/Library/Saved Application State/com.axosoft.GitKraken.savedState", + ] + ), + // MARK: Gitpod Desktop + Entry( + name: "Gitpod Desktop", + bundleIDs: ["io.gitpod.gitpod-desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Gitpod", + "~/Library/Caches/io.gitpod.gitpod-desktop", + "~/Library/Preferences/io.gitpod.gitpod-desktop.plist", + ] + ), + // MARK: Google Chrome + Entry( + name: "Google Chrome", + bundleIDs: ["com.google.chrome"], + bundleIDPrefixes: [], + pathTemplates: [ + "/Library/Application Support/Google/Chrome", + "~/Library/Application Support/Google/Chrome", + "~/Library/Caches/Google/Chrome", + "~/Library/Caches/com.google.Chrome", + "~/Library/Caches/com.google.Chrome.ShipIt", + "~/Library/HTTPStorages/com.google.Chrome", + "~/Library/Logs/Google/Chrome", + "~/Library/Preferences/com.google.Chrome.helper.plist", + "~/Library/Preferences/com.google.Chrome.plist", + "~/Library/Saved Application State/com.google.Chrome.savedState", + "~/Library/WebKit/com.google.Chrome", + ] + ), + // MARK: Google Chrome Canary + Entry( + name: "Google Chrome Canary", + bundleIDs: ["com.google.chrome.canary"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Google/Chrome Canary", + "~/Library/Caches/com.google.Chrome.canary", + "~/Library/Preferences/com.google.Chrome.canary.plist", + "~/Library/Saved Application State/com.google.Chrome.canary.savedState", + ] + ), + // MARK: Hoppscotch + Entry( + name: "Hoppscotch", + bundleIDs: ["io.hoppscotch.desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Hoppscotch", + "~/Library/Caches/io.hoppscotch.desktop", + "~/Library/Preferences/io.hoppscotch.desktop.plist", + ] + ), + // MARK: HTTPie Desktop + Entry( + name: "HTTPie Desktop", + bundleIDs: ["io.httpie.desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/HTTPie", + "~/Library/Caches/io.httpie.desktop", + "~/Library/Preferences/io.httpie.desktop.plist", + ] + ), + // MARK: Hyper + Entry( + name: "Hyper", + bundleIDs: ["co.zeit.hyper"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.hyper.js", + "~/.hyper_plugins", + "~/Library/Application Support/Hyper", + "~/Library/Caches/co.zeit.hyper", + "~/Library/Preferences/co.zeit.hyper.plist", + ] + ), + // MARK: iCab + Entry( + name: "iCab", + bundleIDs: ["de.icab.icab"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/iCab", + "~/Library/Preferences/de.icab.iCab.plist", + ] + ), + // MARK: Insomnia + Entry( + name: "Insomnia", + bundleIDs: ["com.insomnia.app"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Insomnia", + "~/Library/Caches/com.insomnia.app", + "~/Library/Caches/com.insomnia.app.ShipIt", + "~/Library/Preferences/com.insomnia.app.plist", + "~/Library/Saved Application State/com.insomnia.app.savedState", + ] + ), + // MARK: iStat Menus + Entry( + name: "iStat Menus", + bundleIDs: ["com.bjango.istatmenus"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/iStat Menus", + "~/Library/Caches/com.bjango.istatmenus", + "~/Library/Preferences/com.bjango.istatmenus.plist", + "~/Library/Preferences/com.bjango.istatmenus.status.plist", + ] + ), + // MARK: iTerm2 + Entry( + name: "iTerm2", + bundleIDs: ["com.googlecode.iterm2"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/iTerm2", + "~/Library/Caches/com.googlecode.iterm2", + "~/Library/HTTPStorages/com.googlecode.iterm2", + "~/Library/Preferences/com.googlecode.iterm2.plist", + "~/Library/Saved Application State/com.googlecode.iterm2.savedState", + ] + ), + // MARK: JetBrains IDEs (IntelliJ IDEA, PyCharm, WebStorm, CLion, GoLand, Rider, DataGrip, RubyMine, PhpStorm, AppCode) + Entry( + name: "JetBrains IDEs (IntelliJ IDEA, PyCharm, WebStorm, CLion, GoLand, Rider, DataGrip, RubyMine, PhpStorm, AppCode)", + bundleIDs: [], + bundleIDPrefixes: ["com.jetbrains."], + pathTemplates: [ + "~/Library/Application Support/JetBrains", + "~/Library/Caches/JetBrains", + "~/Library/HTTPStorages/com.jetbrains.*", + "~/Library/Logs/JetBrains", + "~/Library/Preferences/com.jetbrains.*.plist", + "~/Library/Saved Application State/com.jetbrains.*.savedState", + "~/Library/WebKit/com.jetbrains.*", + ] + ), + // MARK: Kaleidoscope + Entry( + name: "Kaleidoscope", + bundleIDs: ["com.blackpixel.kaleidoscope"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Kaleidoscope", + "~/Library/Caches/com.blackpixel.kaleidoscope", + "~/Library/Preferences/com.blackpixel.kaleidoscope.plist", + ] + ), + // MARK: Karabiner-Elements + Entry( + name: "Karabiner-Elements", + bundleIDs: ["org.pqrs.karabiner-elements.preferences"], + bundleIDPrefixes: [], + pathTemplates: [ + "/Library/Application Support/org.pqrs/Karabiner-Elements", + "/Library/LaunchDaemons/org.pqrs.karabiner.agent.plist", + "/Library/LaunchDaemons/org.pqrs.karabiner.kextd.plist", + "~/.config/karabiner", + "~/.local/share/karabiner", + "~/Library/Preferences/org.pqrs.Karabiner-Elements.plist", + ] + ), + // MARK: KeePassXC + Entry( + name: "KeePassXC", + bundleIDs: ["org.keepassx.keepassxc"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/KeePassXC", + "~/Library/Caches/org.keepassx.keepassxc", + "~/Library/Preferences/org.keepassx.keepassxc.plist", + ] + ), + // MARK: Kitty + Entry( + name: "Kitty", + bundleIDs: ["net.kovidgoyal.kitty"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.cache/kitty", + "~/.config/kitty", + "~/Library/Preferences/net.kovidgoyal.kitty.plist", + ] + ), + // MARK: LibreWolf + Entry( + name: "LibreWolf", + bundleIDs: ["io.gitlab.librewolf-community"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/LibreWolf", + "~/Library/Caches/io.gitlab.librewolf-community", + "~/Library/Preferences/io.gitlab.librewolf-community.plist", + ] + ), + // MARK: Lunascape + Entry( + name: "Lunascape", + bundleIDs: ["jp.lunascape.lunascape"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Lunascape", + "~/Library/Caches/jp.lunascape.lunascape", + "~/Library/Preferences/jp.lunascape.lunascape.plist", + ] + ), + // MARK: Maccy (clipboard manager) + Entry( + name: "Maccy (clipboard manager)", + bundleIDs: ["org.p0deje.maccy"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Containers/org.p0deje.Maccy", + "~/Library/Preferences/org.p0deje.Maccy.plist", + ] + ), + // MARK: Maxthon Browser + Entry( + name: "Maxthon Browser", + bundleIDs: ["com.maxthon.mac.maxthon"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Maxthon", + "~/Library/Caches/com.maxthon.mac.maxthon", + "~/Library/Preferences/com.maxthon.mac.maxthon.plist", + ] + ), + // MARK: Microsoft Edge + Entry( + name: "Microsoft Edge", + bundleIDs: ["com.microsoft.edgemac"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Microsoft Edge", + "~/Library/Caches/com.microsoft.edgemac", + "~/Library/Caches/com.microsoft.edgemac.ShipIt", + "~/Library/HTTPStorages/com.microsoft.edgemac", + "~/Library/Logs/Microsoft Edge", + "~/Library/Preferences/com.microsoft.edgemac.plist", + "~/Library/Saved Application State/com.microsoft.edgemac.savedState", + ] + ), + // MARK: Microsoft Edge Dev / Beta / Canary + Entry( + name: "Microsoft Edge Dev / Beta / Canary", + bundleIDs: ["com.microsoft.edgemac.dev", "com.microsoft.edgemac.beta", "com.microsoft.edgemac.canary"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Microsoft Edge Beta", + "~/Library/Application Support/Microsoft Edge Canary", + "~/Library/Application Support/Microsoft Edge Dev", + "~/Library/Caches/com.microsoft.edgemac.Beta", + "~/Library/Caches/com.microsoft.edgemac.Canary", + "~/Library/Caches/com.microsoft.edgemac.Dev", + ] + ), + // MARK: MongoDB Compass + Entry( + name: "MongoDB Compass", + bundleIDs: ["com.mongodb.compass"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/MongoDB Compass", + "~/Library/Caches/com.mongodb.compass", + "~/Library/Preferences/com.mongodb.compass.plist", + "~/Library/Saved Application State/com.mongodb.compass.savedState", + ] + ), + // MARK: Mozilla Firefox + Entry( + name: "Mozilla Firefox", + bundleIDs: ["org.mozilla.firefox"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Firefox", + "~/Library/Caches/Firefox", + "~/Library/Caches/org.mozilla.firefox", + "~/Library/Logs/Firefox", + "~/Library/Preferences/org.mozilla.firefox.plist", + "~/Library/Saved Application State/org.mozilla.firefox.savedState", + ] + ), + // MARK: Mullvad Browser + Entry( + name: "Mullvad Browser", + bundleIDs: ["net.mullvad.mullvadbrowser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/MullvadBrowser", + "~/Library/Caches/net.mullvad.MullvadBrowser", + "~/Library/Preferences/net.mullvad.MullvadBrowser.plist", + ] + ), + // MARK: MySQL Workbench + Entry( + name: "MySQL Workbench", + bundleIDs: ["com.oracle.mysql.workbench"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/MySQL/Workbench", + "~/Library/Caches/com.oracle.mysql.workbench", + "~/Library/Preferences/com.oracle.mysql.workbench.plist", + ] + ), + // MARK: Navicat + Entry( + name: "Navicat", + bundleIDs: ["com.navicat.navicatpremium"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/PremiumSoft CyberTech/Navicat", + "~/Library/Caches/com.navicat.NavicatPremium", + "~/Library/Preferences/com.navicat.NavicatPremium.plist", + ] + ), + // MARK: Nova (Panic) + Entry( + name: "Nova (Panic)", + bundleIDs: ["com.panic.nova"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Nova", + "~/Library/Caches/com.panic.Nova", + "~/Library/Preferences/com.panic.Nova.plist", + "~/Library/Saved Application State/com.panic.Nova.savedState", + ] + ), + // MARK: OmniWeb + Entry( + name: "OmniWeb", + bundleIDs: ["com.omnigroup.omniweb5"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/OmniWeb", + "~/Library/Caches/com.omnigroup.OmniWeb5", + "~/Library/Preferences/com.omnigroup.OmniWeb5.plist", + ] + ), + // MARK: Opera + Entry( + name: "Opera", + bundleIDs: ["com.operasoftware.opera"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.operasoftware.Opera", + "~/Library/Caches/com.operasoftware.Opera", + "~/Library/Preferences/com.operasoftware.Opera.plist", + "~/Library/Saved Application State/com.operasoftware.Opera.savedState", + ] + ), + // MARK: Opera Developer + Entry( + name: "Opera Developer", + bundleIDs: ["com.operasoftware.operadeveloperedition"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.operasoftware.OperaDeveloperEdition", + "~/Library/Caches/com.operasoftware.OperaDeveloperEdition", + ] + ), + // MARK: Opera GX + Entry( + name: "Opera GX", + bundleIDs: ["com.operasoftware.operagx"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.operasoftware.OperaGX", + "~/Library/Caches/com.operasoftware.OperaGX", + "~/Library/Preferences/com.operasoftware.OperaGX.plist", + ] + ), + // MARK: OrbStack + Entry( + name: "OrbStack", + bundleIDs: ["dev.orbstack.orbstack"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.orbstack", + "~/Library/Application Support/OrbStack", + "~/Library/Caches/dev.orbstack.OrbStack", + "~/Library/Logs/OrbStack", + "~/Library/Preferences/dev.orbstack.OrbStack.plist", + "~/Library/Saved Application State/dev.orbstack.OrbStack.savedState", + ] + ), + // MARK: Orion + Entry( + name: "Orion", + bundleIDs: ["com.kagi.kagimacos"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Orion", + "~/Library/Caches/com.kagi.kagimacOS", + "~/Library/Preferences/com.kagi.kagimacOS.plist", + ] + ), + // MARK: Pale Moon + Entry( + name: "Pale Moon", + bundleIDs: ["org.palemoon.palemoon"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Pale Moon", + "~/Library/Caches/org.palemoon.PaleMoon", + "~/Library/Preferences/org.palemoon.PaleMoon.plist", + ] + ), + // MARK: Paste (clipboard manager) + Entry( + name: "Paste (clipboard manager)", + bundleIDs: ["com.wiheads.paste"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Paste", + "~/Library/Caches/com.wiheads.paste", + "~/Library/Containers/com.wiheads.paste", + "~/Library/Preferences/com.wiheads.paste.plist", + ] + ), + // MARK: pgAdmin 4 + Entry( + name: "pgAdmin 4", + bundleIDs: ["org.pgadmin.pgadmin4"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/pgAdmin", + "~/Library/Caches/org.pgadmin.pgadmin4", + "~/Library/Preferences/org.pgadmin.pgadmin4.plist", + "~/Library/Saved Application State/org.pgadmin.pgadmin4.savedState", + ] + ), + // MARK: Postman + Entry( + name: "Postman", + bundleIDs: ["com.postmanlabs.mac"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Postman", + "~/Library/Caches/com.postmanlabs.mac", + "~/Library/Caches/com.postmanlabs.mac.ShipIt", + "~/Library/HTTPStorages/com.postmanlabs.mac", + "~/Library/Logs/Postman", + "~/Library/Preferences/com.postmanlabs.mac.plist", + "~/Library/Saved Application State/com.postmanlabs.mac.savedState", + ] + ), + // MARK: Principle + Entry( + name: "Principle", + bundleIDs: ["com.danielhooper.principle"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Principle", + "~/Library/Preferences/com.danielhooper.principle.plist", + ] + ), + // MARK: ProtoPie + Entry( + name: "ProtoPie", + bundleIDs: ["studio.protopie"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/ProtoPie", + "~/Library/Preferences/studio.protopie.plist", + ] + ), + // MARK: Proxyman + Entry( + name: "Proxyman", + bundleIDs: ["com.proxyman.nsproxy"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.proxyman.NSProxy", + "~/Library/Caches/com.proxyman.NSProxy", + "~/Library/Preferences/com.proxyman.NSProxy.plist", + ] + ), + // MARK: Rancher Desktop + Entry( + name: "Rancher Desktop", + bundleIDs: ["io.rancher.desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "/Library/LaunchDaemons/io.rancher.desktop.helper.plist", + "/Library/PrivilegedHelperTools/io.rancher.desktop.helper", + "~/.local/share/rancher-desktop", + "~/.rd", + "~/Library/Application Support/rancher-desktop", + "~/Library/Caches/io.rancher.desktop", + "~/Library/Logs/rancher-desktop", + "~/Library/Preferences/io.rancher.desktop.plist", + "~/Library/Saved Application State/io.rancher.desktop.savedState", + ] + ), + // MARK: RapidAPI (Paw) + Entry( + name: "RapidAPI (Paw)", + bundleIDs: ["com.luckymarmot.paw"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Paw", + "~/Library/Caches/com.luckymarmot.Paw", + "~/Library/Preferences/com.luckymarmot.Paw.plist", + ] + ), + // MARK: Raycast (developer-focused launcher) + Entry( + name: "Raycast (developer-focused launcher)", + bundleIDs: ["com.raycast.macos"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.raycast.macos", + "~/Library/Caches/com.raycast.macos", + "~/Library/HTTPStorages/com.raycast.macos", + "~/Library/Preferences/com.raycast.macos.plist", + "~/Library/Saved Application State/com.raycast.macos.savedState", + ] + ), + // MARK: RedisInsight + Entry( + name: "RedisInsight", + bundleIDs: ["com.redis.redisinsight"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/RedisInsight", + "~/Library/Caches/com.redis.RedisInsight", + "~/Library/Preferences/com.redis.RedisInsight.plist", + ] + ), + // MARK: Roccat Browser + Entry( + name: "Roccat Browser", + bundleIDs: ["com.runecats.roccat"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Roccat", + "~/Library/Preferences/com.runecats.Roccat.plist", + ] + ), + // MARK: SeaMonkey + Entry( + name: "SeaMonkey", + bundleIDs: ["org.mozilla.seamonkey"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/SeaMonkey", + "~/Library/Caches/org.mozilla.seamonkey", + "~/Library/Preferences/org.mozilla.seamonkey.plist", + ] + ), + // MARK: Sequel Ace + Entry( + name: "Sequel Ace", + bundleIDs: ["com.sequel-ace.sequel-ace"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Caches/com.sequel-ace.sequel-ace", + "~/Library/Containers/com.sequel-ace.sequel-ace", + "~/Library/Group Containers/com.sequel-ace.sequel-ace", + "~/Library/Preferences/com.sequel-ace.sequel-ace.plist", + ] + ), + // MARK: Sequel Pro (discontinued) + Entry( + name: "Sequel Pro (discontinued)", + bundleIDs: ["com.sequelpro.sequelpro"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Sequel Pro", + "~/Library/Caches/com.sequelpro.SequelPro", + "~/Library/Preferences/com.sequelpro.SequelPro.plist", + ] + ), + // MARK: SigmaOS + Entry( + name: "SigmaOS", + bundleIDs: ["com.sigmaos.sigmaos.macos"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/SigmaOS", + "~/Library/Caches/com.sigmaos.sigmaos.macos", + "~/Library/Preferences/com.sigmaos.sigmaos.macos.plist", + ] + ), + // MARK: Simulator (iOS) + Entry( + name: "Simulator (iOS)", + bundleIDs: ["com.apple.iphonesimulator"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Caches/com.apple.dt.Xcode/DVTPortal", + "~/Library/Caches/com.apple.dt.Xcode/Downloads", + "~/Library/Developer/CoreSimulator", + ] + ), + // MARK: Sketch + Entry( + name: "Sketch", + bundleIDs: ["com.bohemiancoding.sketch3"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.bohemiancoding.sketch3", + "~/Library/Caches/com.bohemiancoding.sketch3", + "~/Library/Preferences/com.bohemiancoding.sketch3.plist", + "~/Library/Saved Application State/com.bohemiancoding.sketch3.savedState", + ] + ), + // MARK: Sleipnir + Entry( + name: "Sleipnir", + bundleIDs: ["com.fenrir-inc.sleipnir"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Sleipnir", + "~/Library/Caches/com.fenrir-inc.Sleipnir", + "~/Library/Preferences/com.fenrir-inc.Sleipnir.plist", + ] + ), + // MARK: Sourcetree + Entry( + name: "Sourcetree", + bundleIDs: ["com.torusknot.sourcetreenotmas"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/SourceTree", + "~/Library/Caches/com.torusknot.SourceTreeNotMAS", + "~/Library/Preferences/com.torusknot.SourceTreeNotMAS.plist", + "~/Library/Saved Application State/com.torusknot.SourceTreeNotMAS.savedState", + ] + ), + // MARK: Stainless + Entry( + name: "Stainless", + bundleIDs: ["com.mesadynamics.stainless"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Stainless", + "~/Library/Preferences/com.mesadynamics.Stainless.plist", + ] + ), + // MARK: Sublime Text + Entry( + name: "Sublime Text", + bundleIDs: ["com.sublimetext.4"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Sublime Text", + "~/Library/Application Support/Sublime Text 3", + "~/Library/Application Support/Sublime Text 4", + "~/Library/Application Support/Sublime Text*/Cache", + "~/Library/Application Support/Sublime Text*/Index", + "~/Library/Application Support/Sublime Text*/Installed Packages", + "~/Library/Application Support/Sublime Text*/Local/License.sublime_license", + "~/Library/Application Support/Sublime Text*/Local/Session.sublime_session", + "~/Library/Application Support/Sublime Text*/Packages/User", + "~/Library/Caches/com.sublimetext.4", + "~/Library/Preferences/com.sublimetext.4.plist", + "~/Library/Saved Application State/com.sublimetext.4.savedState", + ] + ), + // MARK: Tabby + Entry( + name: "Tabby", + bundleIDs: ["org.tabby"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/tabby", + "~/Library/Caches/org.tabby", + "~/Library/Preferences/org.tabby.plist", + ] + ), + // MARK: TablePlus + Entry( + name: "TablePlus", + bundleIDs: ["com.tableplus.tableplus"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.tableplus.TablePlus", + "~/Library/Caches/com.tableplus.TablePlus", + "~/Library/Containers/com.tableplus.TablePlus", + "~/Library/Logs/TablePlus", + "~/Library/Preferences/com.tableplus.TablePlus.plist", + "~/Library/Saved Application State/com.tableplus.TablePlus.savedState", + ] + ), + // MARK: TextMate + Entry( + name: "TextMate", + bundleIDs: ["com.macromates.textmate"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/TextMate", + "~/Library/Caches/com.macromates.TextMate", + "~/Library/Preferences/com.macromates.TextMate.plist", + ] + ), + // MARK: Tor Browser + Entry( + name: "Tor Browser", + bundleIDs: ["org.torproject.torbrowser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/TorBrowser-Data", + "~/Library/Caches/org.torproject.torbrowser", + "~/Library/Preferences/org.torproject.torbrowser.plist", + ] + ), + // MARK: Tower + Entry( + name: "Tower", + bundleIDs: ["com.fournova.tower3"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/com.fournova.Tower3", + "~/Library/Caches/com.fournova.Tower3", + "~/Library/Preferences/com.fournova.Tower3.plist", + ] + ), + // MARK: UTM + Entry( + name: "UTM", + bundleIDs: ["com.utmapp.utm"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/UTM", + "~/Library/Caches/com.utmapp.UTM", + "~/Library/Containers/com.utmapp.UTM", + "~/Library/Preferences/com.utmapp.UTM.plist", + "~/Library/Saved Application State/com.utmapp.UTM.savedState", + ] + ), + // MARK: Vagrant + Entry( + name: "Vagrant", + bundleIDs: ["com.vagrant.vagrant"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.vagrant.d", + "~/Library/Caches/com.vagrant.vagrant", + ] + ), + // MARK: Visual Studio Code + Entry( + name: "Visual Studio Code", + bundleIDs: ["com.microsoft.vscode"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.vscode", + "~/Library/Application Support/Code", + "~/Library/Caches/com.microsoft.VSCode", + "~/Library/Caches/com.microsoft.VSCode.ShipIt", + "~/Library/HTTPStorages/com.microsoft.VSCode", + "~/Library/Logs/Code", + "~/Library/Preferences/com.microsoft.VSCode.helper.plist", + "~/Library/Preferences/com.microsoft.VSCode.plist", + "~/Library/Saved Application State/com.microsoft.VSCode.savedState", + ] + ), + // MARK: Vivaldi + Entry( + name: "Vivaldi", + bundleIDs: ["com.vivaldi.vivaldi"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Vivaldi", + "~/Library/Caches/com.vivaldi.Vivaldi", + "~/Library/Caches/com.vivaldi.Vivaldi.ShipIt", + "~/Library/Preferences/com.vivaldi.Vivaldi.plist", + "~/Library/Saved Application State/com.vivaldi.Vivaldi.savedState", + ] + ), + // MARK: Warp + Entry( + name: "Warp", + bundleIDs: ["dev.warp.warp-stable"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.warp", + "~/Library/Application Support/dev.warp.Warp-Stable", + "~/Library/Caches/dev.warp.Warp-Stable", + "~/Library/HTTPStorages/dev.warp.Warp-Stable", + "~/Library/Preferences/dev.warp.Warp-Stable.plist", + "~/Library/Saved Application State/dev.warp.Warp-Stable.savedState", + ] + ), + // MARK: Waterfox + Entry( + name: "Waterfox", + bundleIDs: ["net.waterfox.waterfox"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Waterfox", + "~/Library/Caches/net.waterfox.waterfox", + "~/Library/Preferences/net.waterfox.waterfox.plist", + ] + ), + // MARK: WezTerm + Entry( + name: "WezTerm", + bundleIDs: ["com.github.wez.wezterm"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.config/wezterm", + "~/.wezterm.lua", + "~/Library/Preferences/com.github.wez.wezterm.plist", + ] + ), + // MARK: Windsurf (VS Code Fork by Codeium) + Entry( + name: "Windsurf (VS Code Fork by Codeium)", + bundleIDs: ["com.exafunction.windsurf"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.windsurf", + "~/Library/Application Support/Windsurf", + "~/Library/Caches/com.exafunction.windsurf", + "~/Library/Preferences/com.exafunction.windsurf.plist", + "~/Library/Saved Application State/com.exafunction.windsurf.savedState", + ] + ), + // MARK: Wireshark + Entry( + name: "Wireshark", + bundleIDs: ["org.wireshark.wireshark"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Wireshark", + "~/Library/Caches/org.wireshark.Wireshark", + "~/Library/Preferences/org.wireshark.Wireshark.plist", + ] + ), + // MARK: Xcode + Entry( + name: "Xcode", + bundleIDs: ["com.apple.dt.xcode"], + bundleIDPrefixes: [], + pathTemplates: [ + "/Library/Application Support/Xcode", + "~/Library/Caches/com.apple.dt.SourceKitService", + "~/Library/Caches/com.apple.dt.Xcode", + "~/Library/Caches/com.apple.dt.XcodePreviews", + "~/Library/Caches/org.swift.swiftpm", + "~/Library/Developer/CoreSimulator", + "~/Library/Developer/Xcode", + "~/Library/HTTPStorages/com.apple.dt.Xcode", + "~/Library/Logs/CoreSimulator", + "~/Library/Logs/DiagnosticReports/SourceKitService*", + "~/Library/Logs/DiagnosticReports/simulator*", + "~/Library/Preferences/com.apple.dt.Xcode.plist", + "~/Library/Preferences/com.apple.dt.xcodebuild.plist", + "~/Library/Saved Application State/com.apple.dt.Xcode.savedState", + "~/Library/WebKit/com.apple.dt.Xcode", + ] + ), + // MARK: Yandex Browser + Entry( + name: "Yandex Browser", + bundleIDs: ["ru.yandex.desktop.yandex-browser"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/Yandex/YandexBrowser", + "~/Library/Caches/ru.yandex.desktop.yandex-browser", + "~/Library/Preferences/ru.yandex.desktop.yandex-browser.plist", + ] + ), + // MARK: Zed Editor + Entry( + name: "Zed Editor", + bundleIDs: ["dev.zed.zed"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.config/zed", + "~/.zed", + "~/Library/Application Support/Zed", + "~/Library/Caches/dev.zed.Zed", + "~/Library/Logs/Zed", + "~/Library/Preferences/dev.zed.Zed.plist", + ] + ), + ] +} diff --git a/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift b/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift index 6aa3918..5923e4f 100644 --- a/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift +++ b/MacOSCleaner/Features/Uninstaller/Rules/BrowserRule.swift @@ -5,22 +5,41 @@ public struct BrowserRule: ApplicationRule { public let supportedBundleIDs: Set = [ "com.google.Chrome", "com.google.Chrome.canary", - "com.chromium.Chromium", + "org.chromium.Chromium", "com.brave.Browser", + "com.brave.Browser.beta", + "com.brave.Browser.nightly", "com.microsoft.edgemac", - "com.microsoft.edgemac.canary", + "com.microsoft.edgemac.Dev", + "com.microsoft.edgemac.Beta", + "com.microsoft.edgemac.Canary", "org.mozilla.firefox", "org.mozilla.firefoxdeveloperedition", + "org.mozilla.nightly", "company.thebrowser.Browser", + "com.operasoftware.Opera", + "com.operasoftware.OperaGX", + "com.operasoftware.OperaDeveloperEdition", + "com.vivaldi.Vivaldi", + "com.kagi.kagimacOS", + "org.torproject.torbrowser", + "com.duckduckgo.macos.browser", + "net.waterfox.waterfox", + "io.gitlab.librewolf-community", + "net.mullvad.MullvadBrowser", + "ru.yandex.desktop.yandex-browser", ] public let supportedTeamIDs: Set = [ "EQHXZ8M8AV", // Google "BFYZ25A2P4", // Brave + "G7HH3F8CAK", // Opera? ] public let supportedAppNames: Set = [ "Google Chrome", "Chrome", "Chromium", "Brave Browser", "Brave", - "Microsoft Edge", "Edge", "Firefox", "Firefox Developer Edition", - "Arc", + "Microsoft Edge", "Edge", "Firefox", "Firefox Developer Edition", "Firefox Nightly", + "Arc", "Opera", "Opera GX", "Opera Developer", "Vivaldi", + "Orion", "Tor Browser", "DuckDuckGo", "Waterfox", "LibreWolf", + "Mullvad Browser", "Yandex", ] private let browserArtifactDirs: Set = [ @@ -50,7 +69,6 @@ public struct BrowserRule: ApplicationRule { evidence.append(ArtifactEvidence(source: .appName, weight: 50)) } - // Google Chrome: ~/Library/{Application Support,Caches,Logs}/Google/Chrome let bid = identity.bundleID.lowercased() if bid.contains("google") && bid.contains("chrome") { if path.contains("/application support/google/") || path.contains("/caches/google/") || path.contains("/logs/google/") { @@ -63,6 +81,18 @@ public struct BrowserRule: ApplicationRule { } } + if bid.contains("brave") { + if path.contains("/bravesoftware/") || path.contains("/caches/com.brave.") { + evidence.append(ArtifactEvidence(source: .rule, weight: 70)) + } + } + + if bid.contains("operasoftware") || bid.contains("vivaldi") || bid.contains("edgemac") { + if path.contains(bid) || path.contains("/\(identity.appName.lowercased())") { + evidence.append(ArtifactEvidence(source: .bundleID, weight: 70)) + } + } + return evidence } } diff --git a/MacOSCleaner/Features/Uninstaller/ScoringWeights.swift b/MacOSCleaner/Features/Uninstaller/ScoringWeights.swift index 88d519f..efdbc66 100644 --- a/MacOSCleaner/Features/Uninstaller/ScoringWeights.swift +++ b/MacOSCleaner/Features/Uninstaller/ScoringWeights.swift @@ -23,6 +23,7 @@ public struct ScoringWeights: Sendable, Equatable { public var xpcConnection: Int = 60 public var packageReceipt: Int = 100 + public var knownCatalog: Int = 100 public var plistContent: Int = 80 public var spotlight: Int = 5 @@ -64,6 +65,7 @@ public struct ScoringWeights: Sendable, Equatable { case .extension: return self.extension case .xpcConnection: return xpcConnection case .packageReceipt: return packageReceipt + case .knownCatalog: return knownCatalog case .plistContent: return plistContent case .spotlight: return spotlight case .spotlightBundleAttr: return spotlightBundleAttr diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift index 7bae9f9..dc45b56 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift @@ -243,6 +243,9 @@ public actor UninstallerService { if collection.receiptPaths.contains(url) { evidences.insert(.packageReceipt) } + if collection.catalogPaths.contains(url) { + evidences.insert(.knownCatalog) + } await graph.record(evidences, for: url) // Attach via ParentLinker diff --git a/MacOSCleaner/Infrastructure/SafetyManager.swift b/MacOSCleaner/Infrastructure/SafetyManager.swift index ae99cb8..5788823 100644 --- a/MacOSCleaner/Infrastructure/SafetyManager.swift +++ b/MacOSCleaner/Infrastructure/SafetyManager.swift @@ -89,6 +89,11 @@ public struct SafetyManager: Sendable { "\(home)/Library/Application Support/Chrome/Default/Cookies", "\(home)/Library/Application Support/Google/Chrome/Default/Login Data", "\(home)/Library/Application Support/Google/Chrome/Default/Cookies", + // TCC / system integrity (cleanup.json never_delete) + "\(home)/Library/Application Support/com.apple.TCC", + "/Library/Application Support/com.apple.TCC", + "/var/db/dslocal", + "/private/var/db/dslocal", ] // Note: roots whose *contents* cleanup legitimately clears (Saved Application diff --git a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj index 30fe013..fb1b2df 100644 --- a/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj +++ b/MacOSCleaner/MacOSCleaner.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 09824927A11CC992B106D4CD /* ProcessInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A116D669586B4CCC7108FC /* ProcessInfoProvider.swift */; }; 0C6462E222FBB78EEDB1E781 /* CleanupEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F3794501798DF583436B09 /* CleanupEngine.swift */; }; 0D71B75F7521BAECEAEF7119 /* DockerRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8AC033173B97C266D97DF2 /* DockerRule.swift */; }; + 0DDAFBC7D19DAD12195DAF6D /* KnownResidualCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCCC5A6CCD0B3C085D6AA5E /* KnownResidualCatalog.swift */; }; 0EC337EF82F7F025681B3725 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 725A7CDFB17CB2FC1AC59560 /* Assets.xcassets */; }; 11A7D2871426F39EFA6D740B /* EvidenceProbeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED4199D09AC66B4B220BDC8 /* EvidenceProbeTests.swift */; }; 162CB83D7194D6BD718593A0 /* CommandRunning.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9BB7DDAA6BEB78C47B1EAD3 /* CommandRunning.swift */; }; @@ -113,6 +114,7 @@ 9E12B9110CAF741A51367B1C /* ProcessesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408FDF60DE1F0734F49ADE65 /* ProcessesView.swift */; }; 9EC173288EFFC99A7B34B3A7 /* DashboardView.swift.back in Resources */ = {isa = PBXBuildFile; fileRef = DB8F5E087C85389E6C6C2B26 /* DashboardView.swift.back */; }; 9F48B8272BC8F88521B69141 /* ScanActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1342D347B34E14E2DB3428 /* ScanActor.swift */; }; + A2DD08E0856A71B50F9A89C4 /* GeneratedCleanupPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBAF8D65EF28A9DB89B6020A /* GeneratedCleanupPaths.swift */; }; A50B617B0D6B7F32B53C879A /* ProcessGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12EE41C2EEF2151D9A20EAC7 /* ProcessGroup.swift */; }; A5582FB3CC44494C2D0C5863 /* StartupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD1B0FF19F3B77B06378FAD /* StartupService.swift */; }; A579926287D201B899842A4D /* IdentityCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DFE21E3297B6099B185C4 /* IdentityCache.swift */; }; @@ -127,6 +129,7 @@ AEBD6F435B4BC89EB6A27021 /* ProcessSafetyPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AFDCF6F0B36A4DCBEB9BC9 /* ProcessSafetyPolicy.swift */; }; AEEE3F61B192C14B2BA9741D /* ScanResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DABEFC1D64E9E06931BBF8 /* ScanResult.swift */; }; B001282F4B0F905C44783B6B /* DiskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 829209F31C2C64485ADD1F74 /* DiskItem.swift */; }; + B0F7AD70718B4A1F032CE914 /* KnownResidualCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751080B620CA460CE700B155 /* KnownResidualCatalogTests.swift */; }; B17DA1284BD059BAA16189CF /* TransactionJournal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 753F374AA91758652C409A39 /* TransactionJournal.swift */; }; B262FF81F64BA96FB492B9FD /* Postman.json in Resources */ = {isa = PBXBuildFile; fileRef = 2013004E1BB0E55214A2CC26 /* Postman.json */; }; B31A42615B4637F2D8C2F303 /* BackgroundItemsReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FA0CF16A34242F0F8DAD420 /* BackgroundItemsReaderTests.swift */; }; @@ -294,6 +297,7 @@ 725A7CDFB17CB2FC1AC59560 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 7443B86838A9D24B07071D8A /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 745899F2BC71D0F92EDC35A2 /* CleanupPathProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupPathProvider.swift; sourceTree = ""; }; + 751080B620CA460CE700B155 /* KnownResidualCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnownResidualCatalogTests.swift; sourceTree = ""; }; 753F374AA91758652C409A39 /* TransactionJournal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionJournal.swift; sourceTree = ""; }; 779BE716C48F3D16DAB0257D /* MacOSCleaner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacOSCleaner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 78EEF9CB2BE762A51083464F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = ""; }; @@ -318,6 +322,7 @@ 9C213F7BAD2CB83289015B67 /* SnapshotStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotStoreTests.swift; sourceTree = ""; }; 9E5214D1AD87C67377076C08 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 9F655CB3931FC097B4A8836C /* VerificationEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationEngineTests.swift; sourceTree = ""; }; + 9FCCC5A6CCD0B3C085D6AA5E /* KnownResidualCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnownResidualCatalog.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 = ""; }; @@ -393,6 +398,7 @@ F6F9B22B36D401D28509CF70 /* ArtifactClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactClassifier.swift; sourceTree = ""; }; 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 = ""; }; FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvidenceSource.swift; sourceTree = ""; }; FDFA9788CE80DD0C46A4DBCF /* GlassOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassOverlayManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -489,6 +495,7 @@ 5ED4199D09AC66B4B220BDC8 /* EvidenceProbeTests.swift */, B86C1024E8C23494818532A4 /* EvidenceSourceTests.swift */, 966FFAFDB46A9A4BD17A38C0 /* FileScannerTests.swift */, + 751080B620CA460CE700B155 /* KnownResidualCatalogTests.swift */, 4C881B157A7F5ACBF84D7E64 /* LanguageManagerTests.swift */, B8F86A55A2FB7771CD468CEC /* LaunchServiceManagerTests.swift */, 670B4B2D58E43D6491C71D88 /* LSRegisterCacheTests.swift */, @@ -593,6 +600,7 @@ 1D16F75C43CE1DD60ADDFE8E /* EvidenceGraph.swift */, C950BCC1062A364886439405 /* EvidenceProbe.swift */, FDDF1D35AF956F43314C4153 /* EvidenceSource.swift */, + 9FCCC5A6CCD0B3C085D6AA5E /* KnownResidualCatalog.swift */, 44C2514ADFBB5E6A83A2FB55 /* ParentLinker.swift */, C145D57FE94451F56F45C7DD /* PlistAnalyzer.swift */, F9D80391D31B2181E0D29B17 /* ScoringWeights.swift */, @@ -659,6 +667,7 @@ 745899F2BC71D0F92EDC35A2 /* CleanupPathProvider.swift */, 4D39CB998D05F06C7122FCA3 /* CleanupStateMachine.swift */, 0161725DC48B94D957228EB3 /* EmbeddedCleanupPaths.swift */, + FBAF8D65EF28A9DB89B6020A /* GeneratedCleanupPaths.swift */, 753F374AA91758652C409A39 /* TransactionJournal.swift */, ); path = Cleanup; @@ -925,6 +934,7 @@ 11A7D2871426F39EFA6D740B /* EvidenceProbeTests.swift in Sources */, F00B1892329F1ED50C46574B /* EvidenceSourceTests.swift in Sources */, 624F501955DDCE47F965A24C /* FileScannerTests.swift in Sources */, + B0F7AD70718B4A1F032CE914 /* KnownResidualCatalogTests.swift in Sources */, 2D9E139399502E0900C1DA6F /* LSRegisterCacheTests.swift in Sources */, E9773085984D381C6D8A9F53 /* LanguageManagerTests.swift in Sources */, BC7D6B5A297C83B50F760387 /* LaunchServiceManagerTests.swift in Sources */, @@ -1007,6 +1017,7 @@ 4F36E18C78A15EADBF60C039 /* FileManager+Size.swift in Sources */, 7C59F9EB60B6D24E70FCFB2D /* FileScanner.swift in Sources */, 597C46E020148F9810D9763B /* FinalCutProRule.swift in Sources */, + A2DD08E0856A71B50F9A89C4 /* GeneratedCleanupPaths.swift in Sources */, 4249D6B3C9C4B39B14606220 /* GitClientsRule.swift in Sources */, D4E234A2498F162618A4B633 /* GlassOverlayManager.swift in Sources */, 410FDB9E38FA3E5F873F1D0B /* GlassOverlayView.swift in Sources */, @@ -1014,6 +1025,7 @@ A579926287D201B899842A4D /* IdentityCache.swift in Sources */, 7E95E7129D7B44DCFE10E2C6 /* JetBrainsRule.swift in Sources */, A771995E76D2909084B5E292 /* KarabinerElementsRule.swift in Sources */, + 0DDAFBC7D19DAD12195DAF6D /* KnownResidualCatalog.swift in Sources */, 7DEF61AD385AC14996379126 /* LSRegisterCache.swift in Sources */, 9A045237ED42AC3CC1C06031 /* LanguageManager.swift in Sources */, DDF1A3BD13DF7AA1C39EA144 /* LaunchServiceManager.swift in Sources */, diff --git a/MacOSCleaner/MacOSCleanerTests/KnownResidualCatalogTests.swift b/MacOSCleaner/MacOSCleanerTests/KnownResidualCatalogTests.swift new file mode 100644 index 0000000..009a373 --- /dev/null +++ b/MacOSCleaner/MacOSCleanerTests/KnownResidualCatalogTests.swift @@ -0,0 +1,127 @@ +import XCTest +@testable import MacOSCleaner + +final class KnownResidualCatalogTests: XCTestCase { + + private func identity( + bundleID: String, + appName: String, + vendorNames: Set = [], + isDocker: Bool = false, + isJetBrains: Bool = false + ) -> AppIdentity { + AppIdentity( + bundleID: bundleID, + appName: appName, + bundleName: appName, + bundleVersion: nil, + executableName: appName, + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/\(appName).app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: vendorNames, + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: isJetBrains, isFlutter: false, + isJava: false, isQt: false, isDocker: isDocker + ) + } + + func test_chromeHasCatalogTemplates() { + let templates = KnownResidualCatalog.pathTemplates(bundleID: "com.google.Chrome") + XCTAssertFalse(templates.isEmpty) + XCTAssertTrue(templates.contains { $0.contains("Application Support/Google/Chrome") }) + XCTAssertTrue(templates.contains { $0.contains("Library/Caches") }) + XCTAssertFalse(templates.contains { $0.lowercased().contains("keystone") }) + XCTAssertFalse(templates.contains { $0.lowercased().contains("googlesoftwareupdate") }) + } + + func test_dockerHasCatalogTemplates() { + let templates = KnownResidualCatalog.pathTemplates(bundleID: "com.docker.docker") + XCTAssertFalse(templates.isEmpty) + XCTAssertTrue(templates.contains { $0.contains("group.com.docker") || $0.contains("com.docker.docker") }) + XCTAssertFalse(templates.contains { $0 == "~/.docker" || $0.hasPrefix("~/.docker/") }) + } + + func test_jetbrainsPrefixMatchesFamily() { + let templates = KnownResidualCatalog.pathTemplates(bundleID: "com.jetbrains.intellij") + XCTAssertFalse(templates.isEmpty) + XCTAssertTrue(templates.contains { $0.contains("JetBrains") }) + } + + func test_safariExcludedFromCatalog() { + XCTAssertTrue(KnownResidualCatalog.pathTemplates(bundleID: "com.apple.Safari").isEmpty) + } + + func test_unknownBundleReturnsEmpty() { + XCTAssertTrue(KnownResidualCatalog.pathTemplates(bundleID: "unknown.com.foo").isEmpty) + XCTAssertTrue(KnownResidualCatalog.pathTemplates(bundleID: "").isEmpty) + } + + func test_expandFindsExistingExactPath() throws { + let home = NSTemporaryDirectory() + "KnownResidualCatalogTests-\(UUID().uuidString)" + let target = home + "/Library/Caches/com.google.Chrome" + try FileManager.default.createDirectory(atPath: target, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: home) } + + let found = KnownResidualCatalog.expand( + template: "~/Library/Caches/com.google.Chrome", + home: home + ) + XCTAssertEqual(found, [target]) + } + + func test_collectorIncludesCatalogPath() async throws { + let home = NSHomeDirectory() + let cachePath = "\(home)/Library/Caches/com.google.Chrome" + let created: Bool + if FileManager.default.fileExists(atPath: cachePath) { + created = false + } else { + try FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) + created = true + } + defer { + if created { try? FileManager.default.removeItem(atPath: cachePath) } + } + + let collection = await CandidateCollector().collectDetailed( + identity: identity(bundleID: "com.google.Chrome", appName: "Google Chrome", vendorNames: ["Google"]), + mode: .safe + ) + XCTAssertTrue( + collection.catalogPaths.contains { $0.path == cachePath }, + "Catalog must surface ~/Library/Caches/com.google.Chrome" + ) + XCTAssertTrue(collection.candidates.contains { $0.path == cachePath }) + } + + func test_confidenceKnownCatalogIsGuaranteed() { + let assessment = ConfidenceEngine.assess( + [.knownCatalog], + identity: identity(bundleID: "com.google.Chrome", appName: "Google Chrome") + ) + XCTAssertEqual(assessment.tier, .guaranteed) + XCTAssertGreaterThanOrEqual(assessment.score, 100) + } + + func test_browserRuleMatchesOpera() { + let rule = BrowserRule() + XCTAssertTrue(rule.matches(identity: identity(bundleID: "com.operasoftware.Opera", appName: "Opera"))) + } + + func test_embeddedBrowserPathsIncludeAppSupportCaches() { + let paths = EmbeddedCleanupPaths.paths(for: .browserCaches).map(\.path) + XCTAssertTrue(paths.contains { $0.contains("Google/Chrome") && $0.contains("Cache") }) + XCTAssertTrue(paths.contains { $0.contains("com.operasoftware.Opera") || $0.contains("Opera") }) + } + + func test_generatedCleanupPathsMerged() { + let paths = EmbeddedCleanupPaths.paths(for: .browserCaches).map(\.path) + let generated = GeneratedCleanupPaths.browserCaches.map(\.path) + XCTAssertFalse(generated.isEmpty) + for g in generated.prefix(5) { + XCTAssertTrue(paths.contains(g), "Missing merged path \(g)") + } + } +} diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index ed1c8fb..942885f 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -536,6 +536,8 @@ "uninstaller.evidence.xpcConnection.description" = "An XPC connection used by the app."; "uninstaller.evidence.packageReceipt.title" = "Package Receipt"; "uninstaller.evidence.packageReceipt.description" = "Registered via a package receipt."; +"uninstaller.evidence.knownCatalog.title" = "Known Residual"; +"uninstaller.evidence.knownCatalog.description" = "Listed in the curated residual catalog for this app."; "uninstaller.evidence.plistContent.title" = "Plist Content"; "uninstaller.evidence.plistContent.description" = "Property list contains the app's name or bundle ID."; "uninstaller.evidence.spotlight.title" = "Spotlight Index"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index b297168..2747a99 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -539,6 +539,8 @@ "uninstaller.evidence.xpcConnection.description" = "Una conexión XPC utilizada por la aplicación."; "uninstaller.evidence.packageReceipt.title" = "Recibo de paquete"; "uninstaller.evidence.packageReceipt.description" = "Registrado mediante un recibo de paquete."; +"uninstaller.evidence.knownCatalog.title" = "Residuo conocido"; +"uninstaller.evidence.knownCatalog.description" = "Incluido en el catálogo de residuos de esta aplicación."; "uninstaller.evidence.plistContent.title" = "Contenido de Plist"; "uninstaller.evidence.plistContent.description" = "La lista de propiedades contiene el nombre o Bundle ID de la aplicación."; "uninstaller.evidence.spotlight.title" = "Índice de Spotlight"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index 0afaf1b..f015885 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -526,6 +526,8 @@ "uninstaller.evidence.xpcConnection.description" = "XPC-соединение, используемое приложением."; "uninstaller.evidence.packageReceipt.title" = "Квитанция пакета"; "uninstaller.evidence.packageReceipt.description" = "Зарегистрировано через квитанцию пакета."; +"uninstaller.evidence.knownCatalog.title" = "Известный остаток"; +"uninstaller.evidence.knownCatalog.description" = "Указан в каталоге остатков для этого приложения."; "uninstaller.evidence.plistContent.title" = "Содержимое Plist"; "uninstaller.evidence.plistContent.description" = "Plist содержит имя или Bundle ID приложения."; "uninstaller.evidence.spotlight.title" = "Индекс Spotlight"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index 49dd124..8a3307d 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -522,6 +522,8 @@ "uninstaller.evidence.xpcConnection.description" = "XPC-з'єднання, яке використовує додаток."; "uninstaller.evidence.packageReceipt.title" = "Квитанція пакета"; "uninstaller.evidence.packageReceipt.description" = "Зареєстровано через квитанцію пакета."; +"uninstaller.evidence.knownCatalog.title" = "Відомий залишок"; +"uninstaller.evidence.knownCatalog.description" = "Вказано в каталозі залишків для цього додатка."; "uninstaller.evidence.plistContent.title" = "Вміст Plist"; "uninstaller.evidence.plistContent.description" = "Plist містить назву або Bundle ID додатка."; "uninstaller.evidence.spotlight.title" = "Індекс Spotlight"; diff --git a/README.md b/README.md index 32130bd..7fe0a29 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Build: XcodeGen](https://img.shields.io/badge/Build-XcodeGen-black.svg?logo=xcode&logoColor=white)](https://github.com/yonaskolb/XcodeGen) [![Version: 2.0.0](https://img.shields.io/badge/Release-2.0.0-brightgreen.svg)]() -🧹 Free up disk space by cleaning caches, temp files, app leftovers, and more. Everything goes to Trash — nothing is gone forever unless you say so. +🧹 Free up disk space by cleaning caches, temp files, app leftovers, and more. Review candidates first, confirm what to remove, and recover from Trash when you need to. --- @@ -34,13 +34,13 @@ ## Features -🧠 **Apple Intelligence** — Native local AI explanations powered by `FoundationModels` (requires macOS 26.0+). Operates fully offline, executing on the Neural Engine (Apple Silicon) with zero cost and maximum privacy. Explains files, caches, running processes, and startup agents in detail to help you make informed decisions. Prompts are optimized in English for higher model reasoning, with explanations output in your preferred UI language (English, Русский, Українська, Español). Fully toggleable in settings, featuring real-time model status tracking. +🧠 **Apple Intelligence** — Native local AI explanations powered by `FoundationModels` (requires macOS 26.0+). Operates fully offline on supported Apple Silicon Macs. Explains files, caches, running processes, and startup agents to help you decide what is safe to remove. Prompts are optimized in English for higher model reasoning, with explanations output in your preferred UI language (English, Русский, Українська, Español). Fully toggleable in settings, with real-time model status tracking. 🌍 **Fully Localized** — English, Русский, Українська, Español. All UI, errors, logs, and system info translated dynamically. Dates and byte counts format automatically for your language. **Dashboard** 📊 — redesigned with native macOS aesthetics: `controlBackgroundColor`, rounded cards, and SF Symbols. Disk usage chart, system info (model, CPU, RAM, macOS version), cleanup history and stats. -**Smart Cleanup** 🔍 — scans 54 categories with 298 built-in cleaning paths: +**Smart Cleanup** 🔍 — scans 54 categories with 450+ built-in cleaning paths: - **App Caches** — Google, Spotify, JetBrains, opencode, browsers (Safari, Chrome, Firefox, Edge, Brave, Vivaldi, Arc), messengers (Telegram, Discord, Slack, Signal, WeChat, Teams) - **Package Managers** — Homebrew, npm, yarn, pnpm, CocoaPods @@ -52,6 +52,7 @@ - **App Containers** — sandboxed caches in Containers + Group Containers - **Dotfile Caches** — AI CLI tools (opencode, Claude, Gemini, Codex, Aider), dev tools (npm logs, Terraform, Helm, Bazel, ccache, vcpkg) - **Scattered Junk** — .DS_Store, __MACOSX, stray logs, Windows metadata (Thumbs.db, desktop.ini), broken symlinks +- **Orphaned Remnants** — known leftovers from removed apps via `KnownResidualCatalog` - **Orphaned Files** — leftovers from uninstalled apps in HTTPStorages, WebKit, Cookies, /Users/Shared - **Old IDE Versions** — cleans system caches for VS Code, Cursor, Windsurf, Zed, Sublime Text, Eclipse, Atom; detects and removes leftover JetBrains cache/log directories for no-longer-installed products; cleans old Android Studio version caches (keeps latest); removes stale CachedData subdirectories for VS Code, Cursor, Windsurf (keeps latest version) - **Large Files** — old DMG/pkg/iso/zip installers, node_modules (recursive), iPhone backups, IPSW firmware @@ -98,15 +99,17 @@ Cleanup tasks run in parallel across all available cores for maximum speed. All - **Clean iMovie / Final Cut** — includes render files and libraries (off by default) - **Clean Sleep Image** — removes hibernation image file (off by default) +**Disk Space** 📁 — browse large folders and files, inspect sizes, and move selected items to Trash. + **Process Manager** ⚙️ — redesigned with modern macOS styling. Lists running processes, lets you terminate or force-kill them. Critical system processes (kernel_task, launchd, WindowServer) are protected. **Startup Services** 🚀 — redesigned with modern macOS styling. Shows all LaunchAgents from `~/Library/LaunchAgents`, their load status, and lets you enable/disable them. -**App Uninstaller** 🗑️ — finds installed apps, scans 5 levels deep for residual files using 14 types of evidence (Bundle ID, Team ID, Spotlight, Plist contents, and more). Shows total reclaimable space and real-time scan progress. Tailored rules for over 95 popular apps including Docker, Parallels, Adobe CC, MS Office, Discord, Figma, and more. +**App Uninstaller** 🗑️ — finds installed apps, scans up to 5 levels deep for residual files using 30 types of evidence (Bundle ID, Team ID, Spotlight, Plist contents, known catalog paths, and more). Shows total reclaimable space and real-time scan progress. Tailored rules for popular apps including Docker, Parallels, Adobe CC, MS Office, Discord, Figma, JetBrains, browsers, and more. -- **Scan Modes (Safe / Balanced)** — choose between *Safe* mode (exact matches only, no Spotlight, highest confidence files) and *Balanced* mode (full deep scan including Spotlight and fuzzy matching) to tailor uninstallation aggressiveness +- **Scan Modes (Safe / Balanced)** — choose between *Safe* mode (depth 3, exact matches only, no Spotlight, highest confidence files) and *Balanced* mode (depth 5, full deep scan including Spotlight and fuzzy matching) to tailor uninstallation aggressiveness - **Background Deep Scanning** — apps are scanned thoroughly in the background; the UI updates in real time as each app's total size is finalized -- **Evidence-Based Forensics** — each candidate file is scored against 14 evidence types: identity, code signing, system integration, metadata, content analysis, graph relationships, and Launch Services registration +- **Evidence-Based Forensics** — each candidate file is scored against 30 evidence types: identity, code signing, system integration, metadata, content analysis, graph relationships, and Launch Services registration - **Confidence Tiers** — `.guaranteed` (critical evidence), `.veryLikely`, `.possible`, or `.ignore` - **Developer Components** — detects and offers to clean Android SDK, Gradle/Maven, Xcode DerivedData, iOS Simulators, Flutter pub-cache, Docker containers, and Homebrew artifacts - **Reveal in Finder** — quick action to show any related file or folder in Finder before deleting @@ -115,22 +118,23 @@ Cleanup tasks run in parallel across all available cores for maximum speed. All **Smart Updates** 🔄 — automatic, lightweight background check for new versions on startup directly via GitHub Releases. Get gently notified when a new update is ready, without background daemons, persistent tracking, or extra dependencies. -**Settings** — rebuilt with native macOS `Form` styles to match System Settings. Light/dark/system theme, languages (English, Русский, Українська, Español), notifications, scan-on-startup, Trash behavior, and more. +**Settings** — rebuilt with native macOS `Form` styles to match System Settings. Light/dark/system theme, languages (English, Русский, Українська, Español), notifications, scan-on-startup, Trash behavior, Apple Intelligence toggle, and more. --- ## How It Works -Runs on Apple Silicon (M1–M5) with full parallelism — cleanup categories execute concurrently across all available cores. File scanning is done with a stack-based iterator that batches work and deduplicates inodes. Size calculations are cached to avoid redundant work. All cleanup paths are embedded as static Swift arrays — no runtime JSON parsing. +Runs on Apple Silicon (M1–M5) with full parallelism — cleanup categories execute concurrently across all available cores. File scanning is done with a stack-based iterator that batches work and deduplicates inodes. Size calculations are cached to avoid redundant work. Cleanup paths are embedded as static Swift arrays (`EmbeddedCleanupPaths` + `GeneratedCleanupPaths`) — no runtime JSON parsing. --- ## Safety 🛡️ -- Everything goes to Trash via `trashItem(at:)` — always recoverable +- Disk Space and App Uninstaller move files to Trash via `trashItem(at:)` — recoverable by default +- Smart Cleanup removes selected cache and temporary data after confirmation - `SafetyManager` blocks access to `/System`, `/usr`, `/bin`, `~/.ssh`, and other critical paths - `ProcessSafetyPolicy` protects system-critical processes from termination -- Permanent deletion is opt-in and clearly marked in the UI +- Permanent deletion and automatic Trash emptying are opt-in and clearly marked in the UI - Apps are closed before cleanup (graceful terminate → force-kill after 3s) - Full Disk Access is requested at startup @@ -146,7 +150,7 @@ Runs on Apple Silicon (M1–M5) with full parallelism — cleanup categories exe --- -## Quick Start +## Build from Source **Requirements:** macOS 26.0+, Xcode 18+, [XcodeGen](https://github.com/yonaskolb/XcodeGen) @@ -154,108 +158,22 @@ Runs on Apple Silicon (M1–M5) with full parallelism — cleanup categories exe cd MacOSCleaner xcodegen open MacOSCleaner.xcodeproj -# Cmd+R to run, Cmd+U to run tests -``` - ---- - -## Building a Distributable .app - -### Option 1: Xcode (Recommended) - -```bash -cd MacOSCleaner -xcodegen -open MacOSCleaner.xcodeproj -``` - -In Xcode: **Product → Archive** → **Distribute App** → **Copy App** → choose destination. - -### Option 2: Command Line - -```bash -cd MacOSCleaner -xcodegen - -xcodebuild -project MacOSCleaner.xcodeproj \ - -scheme MacOSCleaner \ - -configuration Release \ - -derivedDataPath build \ - -archivePath build/MacOSCleaner.xcarchive \ - archive - -xcodebuild -exportArchive \ - -archivePath build/MacOSCleaner.xcarchive \ - -exportPath build/Export \ - -exportOptionsPlist ExportOptions.plist -``` - -Create `ExportOptions.plist`: - -```xml - - - - - method - mac-application - destination - export - signingStyle - automatic - - ``` -### Option 3: Debug Build - -```bash -cd MacOSCleaner -xcodebuild -project MacOSCleaner.xcodeproj \ - -scheme MacOSCleaner \ - -configuration Debug \ - -derivedDataPath build -``` +Use **⌘R** to run, **⌘U** to test, or **Product → Archive** to create a distributable app. --- -## Code Signing - -**Ad-hoc** (local testing only): -```bash -codesign --force --deep --sign - "/path/to/MacOSCleaner.app" -``` -Gatekeeper will block this on other Macs unless they right-click → Open. - -**Developer ID** (recommended for sharing): -```bash -codesign --force --deep --options runtime \ - --sign "Developer ID Application: Your Name (TEAM_ID)" \ - "/path/to/MacOSCleaner.app" - -xcrun notarytool submit "/path/to/MacOSCleaner.app" \ - --apple-id "your@email.com" \ - --team-id "TEAM_ID" \ - --password "app-specific-password" \ - --wait - -xcrun stapler staple "/path/to/MacOSCleaner.app" -``` - -**Verify:** -```bash -codesign --verify --deep --strict --verbose=2 "/path/to/MacOSCleaner.app" -spctl --assess --type execute --verbose "/path/to/MacOSCleaner.app" -``` +## Troubleshooting -**⚠️ Fix damaged app attributes** (if mac asks you to move app to trash): +**⚠️ Fix damaged app attributes** (if macOS asks you to move app to trash): ```bash sudo xattr -r -c /path/to/MacOSCleaner.app ``` --- -**Logs:** open `Console.app` → filter by subsystem `com.alextkdev.macos-cleaner`. +**Logs:** open `Console.app` → filter by subsystem `input.MacOSCleaner`. --- @@ -267,4 +185,4 @@ Found a bug or have an idea? [Open an issue](https://github.com/AlexTkDev/MacOSC ## License -Custom Non-Commercial License — free to use, study, and fork for personal or educational purposes. Commercial use and redistribution are not permitted. See [LICENSE](LICENSE) for details. +Source-available Personal Use Only License — free to use and study for personal, non-commercial purposes. Commercial use, redistribution, and derivative works require explicit permission. See [LICENSE](LICENSE) for details. From 75aba636488171c95722c6afa390ef42b61ad2de Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 15:56:33 +0300 Subject: [PATCH 16/23] refactor: update archive icon to doc.zipper and adjust footer spacer height --- MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift | 2 +- MacOSCleaner/Features/Uninstaller/UninstallerView.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift index 718678d..d2c3921 100644 --- a/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift +++ b/MacOSCleaner/Features/DiskAnalyzer/DiskAnalyzerView.swift @@ -325,7 +325,7 @@ struct DiskItemRow: View { case .photo: return "photo.fill" case .apps: return "app.badge.fill" case .docs: return "doc.text.fill" - case .archives: return "doc.zip.fill" + case .archives: return "doc.zipper" default: return "doc.fill" } } diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift index c2ba079..54d2010 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerView.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerView.swift @@ -309,7 +309,8 @@ struct UninstallerView: View { } } - Spacer(minLength: 40) + Spacer() + .frame(height: 20) // Action Area ViewThatFits(in: .horizontal) { From a926ec445c68ef15611c7f97673ec82fb0f8f6c6 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 17:53:42 +0300 Subject: [PATCH 17/23] feat: enhance uninstallation process with improved safety checks, integrate homebrew application detection, and refine cleanup logic for protected paths --- .../Domains/Cleanup/CleanupEngine.swift | 19 ++- .../Features/Uninstaller/AppDiscovery.swift | 117 ++++++++++++++++- .../Uninstaller/CandidateCollector.swift | 57 ++++++++- .../DeveloperComponentsDetector.swift | 18 ++- .../Features/Uninstaller/EvidenceProbe.swift | 4 + .../Uninstaller/KnownResidualCatalog.swift | 32 +++++ .../Uninstaller/UninstallerService.swift | 36 ++++-- .../Infrastructure/FileCleanupActor.swift | 4 + .../Infrastructure/SafetyManager.swift | 92 ++++++++++++- .../Infrastructure/TrashManager.swift | 4 +- .../MacOSCleanerTests/AppDiscoveryTests.swift | 25 ++++ .../CandidateCollectorTests.swift | 121 ++++++++++++++++++ .../CleanupEngineTests.swift | 19 +++ .../DeveloperComponentsDetectorTests.swift | 22 ++++ .../EvidenceProbeTests.swift | 40 ++++++ .../KnownResidualCatalogTests.swift | 14 ++ .../SafetyManagerTests.swift | 62 +++++++++ .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/ru.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + 21 files changed, 665 insertions(+), 25 deletions(-) diff --git a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift index f8fe90e..68720fc 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupEngine.swift @@ -2097,6 +2097,10 @@ extension CleanupEngine { if !isEntryInstalled(entry, installedApps: installedApps) { let entryPath = "\(scanDir)/\(entry)" + guard (try? safetyManager.validate(url: URL(fileURLWithPath: entryPath))) != nil else { + progress?(.log(" \(entry) — protected, skipped")) + continue + } let entrySize = await getDirectorySizeWithTimeout(entryPath, timeout: .seconds(5)) if entrySize > 1024 * 1024 { // > 1 MB orphanCount += 1 @@ -2212,6 +2216,10 @@ extension CleanupEngine { } if !isEntryInstalled(entry, installedApps: installedApps) { let entryPath = "\(httpStorages)/\(entry)" + guard (try? safetyManager.validate(url: URL(fileURLWithPath: entryPath))) != nil else { + progress?(.log(" \(entry) — protected, skipped")) + continue + } let entrySize = await getDirectorySizeWithTimeout(entryPath, timeout: .seconds(5)) if entrySize > 1024 * 1024 { if dryRun { @@ -2234,6 +2242,10 @@ extension CleanupEngine { if entry.hasPrefix("com.apple.") { continue } if !isEntryInstalled(entry, installedApps: installedApps) { let entryPath = "\(cookiesDir)/\(entry)" + guard (try? safetyManager.validate(url: URL(fileURLWithPath: entryPath))) != nil else { + progress?(.log(" \(entry) — protected, skipped")) + continue + } let entrySize = (try? fm.attributesOfItem(atPath: entryPath)[.size] as? Int64) ?? 0 if entrySize > 1024 * 1024 { if dryRun { @@ -2260,6 +2272,10 @@ extension CleanupEngine { } if !isEntryInstalled(entry, installedApps: installedApps) { let entryPath = "\(webkitDir)/\(entry)" + guard (try? safetyManager.validate(url: URL(fileURLWithPath: entryPath))) != nil else { + progress?(.log(" \(entry) — protected, skipped")) + continue + } let entrySize = await getDirectorySizeWithTimeout(entryPath, timeout: .seconds(5)) if entrySize > 1024 * 1024 { if dryRun { @@ -2841,7 +2857,8 @@ extension CleanupEngine { let chromeBase = "\(home)/Library/Application Support/Google/Chrome/Default" let chromeBaseRoot = "\(home)/Library/Application Support/Google/Chrome" - let subdirs = ["Cache", "Code Cache", "GPUCache", "Service Worker", "Session Storage"] + // Session Storage intentionally excluded: it holds per-site session state. + let subdirs = ["Cache", "Code Cache", "GPUCache", "Service Worker"] let rootSubdirs = ["GrShaderCache", "ShaderCache"] // Check if Chrome is running and warn diff --git a/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift b/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift index 403586b..38b9fe8 100644 --- a/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift +++ b/MacOSCleaner/Features/Uninstaller/AppDiscovery.swift @@ -1,12 +1,23 @@ import Foundation public actor AppDiscovery { + public static let defaultHomebrewCellarDirectories = [ + URL(fileURLWithPath: "/opt/homebrew/Cellar", isDirectory: true), + URL(fileURLWithPath: "/usr/local/Cellar", isDirectory: true), + ] + private let fileManager: FileManager private let commandRunner: CommandRunner + private let homebrewCellarDirectories: [URL] - public init(fileManager: FileManager = .default, commandRunner: CommandRunner = CommandRunner()) { + public init( + fileManager: FileManager = .default, + commandRunner: CommandRunner = CommandRunner(), + homebrewCellarDirectories: [URL] = AppDiscovery.defaultHomebrewCellarDirectories + ) { self.fileManager = fileManager self.commandRunner = commandRunner + self.homebrewCellarDirectories = homebrewCellarDirectories } public func findAll() async -> [URL] { @@ -25,6 +36,14 @@ public actor AppDiscovery { } } + // Homebrew formulae can expose user-facing apps directly inside a keg + // (for example python@3.x/3.x.y/IDLE 3.app). Scan only the fixed + // Cellar/formula/version/*.app shape; never recurse through formula contents. + urls.append(contentsOf: Self.homebrewApplications( + in: homebrewCellarDirectories, + fileManager: fileManager + )) + // Application Support (Google Updater, etc.) let appSupportDirs = [ "\(NSHomeDirectory())/Library/Application Support", @@ -59,6 +78,102 @@ public actor AppDiscovery { return Array(Set(urls)).filter { !isSystemComponent($0) } } + static func homebrewApplications( + in cellarDirectories: [URL], + fileManager: FileManager = .default + ) -> [URL] { + var applications = Set() + for cellar in cellarDirectories { + guard let formulae = directoryContents(at: cellar, fileManager: fileManager) else { continue } + for formula in formulae where isDirectory(formula) { + applications.formUnion(homebrewApplications( + inFormulaRoot: formula, + fileManager: fileManager + )) + } + } + return applications.sorted { $0.path < $1.path } + } + + static func homebrewFormulaApplications( + containing bundleURL: URL, + cellarDirectories: [URL] = defaultHomebrewCellarDirectories, + fileManager: FileManager = .default + ) -> Set { + guard let location = homebrewFormulaLocation( + containing: bundleURL, + cellarDirectories: cellarDirectories + ), let formulae = directoryContents(at: location.cellar, fileManager: fileManager) else { + return [] + } + + let familyName = homebrewFormulaFamilyName(location.formulaName) + var applications = Set() + for formula in formulae where isDirectory(formula) + && homebrewFormulaFamilyName(formula.lastPathComponent) == familyName { + applications.formUnion(homebrewApplications( + inFormulaRoot: formula, + fileManager: fileManager + )) + } + return applications + } + + private static func homebrewFormulaLocation( + containing bundleURL: URL, + cellarDirectories: [URL] + ) -> (cellar: URL, formulaName: String)? { + let bundlePath = bundleURL.standardizedFileURL.path + for cellar in cellarDirectories { + let cellarPath = cellar.standardizedFileURL.path + let prefix = cellarPath + "/" + guard bundlePath.hasPrefix(prefix) else { continue } + let relativePath = String(bundlePath.dropFirst(prefix.count)) + guard let formulaName = relativePath.split(separator: "/").first else { continue } + return (cellar, String(formulaName)) + } + return nil + } + + private static func homebrewFormulaFamilyName(_ formulaName: String) -> String { + String(formulaName.split(separator: "@", maxSplits: 1).first ?? Substring(formulaName)) + } + + private static func homebrewApplications( + inFormulaRoot formulaRoot: URL, + fileManager: FileManager + ) -> Set { + guard let versions = directoryContents(at: formulaRoot, fileManager: fileManager) else { + return [] + } + + var applications = Set() + for version in versions where isDirectory(version) { + let receipt = version.appendingPathComponent("INSTALL_RECEIPT.json") + guard fileManager.fileExists(atPath: receipt.path), + let contents = directoryContents(at: version, fileManager: fileManager) + else { + continue + } + for item in contents where item.pathExtension.lowercased() == "app" && isDirectory(item) { + applications.insert(item) + } + } + return applications + } + + private static func directoryContents(at url: URL, fileManager: FileManager) -> [URL]? { + try? fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + } + + private static func isDirectory(_ url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + /// Apple's own apps are uninstallable only when installed in /Applications (Xcode, GarageBand, etc.). /// A `com.apple.*` bundle anywhere else (Application Support, DerivedData) is an OS component /// (e.g. Script Editor templates in /Library/Application Support/Script Editor) and must not be listed. diff --git a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift index 54cb3b5..1722656 100644 --- a/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift +++ b/MacOSCleaner/Features/Uninstaller/CandidateCollector.swift @@ -2,7 +2,7 @@ import Foundation public struct CandidateCollection: Sendable { public let candidates: Set - /// Subset of `candidates` that came from a pkgutil receipt — guaranteed app files. + /// Subset of `candidates` owned by a package-manager receipt — guaranteed app files. public let receiptPaths: Set /// Subset from KnownResidualCatalog — high-confidence curated residuals. public let catalogPaths: Set @@ -11,10 +11,23 @@ public struct CandidateCollection: Sendable { public actor CandidateCollector { private let fileManager: FileManager private let commandRunner: any CommandRunning - - public init(fileManager: FileManager = .default, commandRunner: any CommandRunning = CommandRunner()) { + private let homebrewCellarDirectories: [URL] + private let darwinCacheDirectory: URL + + public init( + fileManager: FileManager = .default, + commandRunner: any CommandRunning = CommandRunner(), + homebrewCellarDirectories: [URL] = AppDiscovery.defaultHomebrewCellarDirectories, + darwinCacheDirectory: URL? = nil + ) { self.fileManager = fileManager self.commandRunner = commandRunner + self.homebrewCellarDirectories = homebrewCellarDirectories + let cacheDirectory = darwinCacheDirectory + ?? fileManager.temporaryDirectory + .deletingLastPathComponent() + .appendingPathComponent("C", isDirectory: true) + self.darwinCacheDirectory = cacheDirectory.resolvingSymlinksInPath() } public func collect(identity: AppIdentity, mode: ScanMode = .balanced) async -> Set { @@ -68,6 +81,11 @@ public actor CandidateCollector { candidates.formUnion(await shallowScan(url, identity: identity, mode: mode)) } + // Current user's Darwin cache root (/private/var/folders/.../C). + // Exact bundle-ID prefixes find helper caches without scanning other users, + // temp files, or generic vendor names. + candidates.formUnion(collectDarwinCachePaths(identity: identity)) + // 2. Deep scan critical folders let deepFolders = [ "\(home)/Library/Application Support", @@ -84,8 +102,17 @@ public actor CandidateCollector { candidates.formUnion(await deepScan(url, identity: identity, depth: 0, maxDepth: maxDepth)) } - // 3. pkgutil receipts - let receiptPaths = await collectReceiptPaths(identity: identity) + // 3. Package-manager receipts. Homebrew can keep the same app bundle in + // several versioned formulae (python@3.12 and python@3.14). + var receiptPaths = await collectPkgutilReceiptPaths(identity: identity) + let homebrewApplications = AppDiscovery.homebrewFormulaApplications( + containing: identity.bundleURL, + cellarDirectories: homebrewCellarDirectories, + fileManager: fileManager + ) + receiptPaths.formUnion(homebrewApplications.filter { + Bundle(url: $0)?.bundleIdentifier?.caseInsensitiveCompare(identity.bundleID) == .orderedSame + }) candidates.formUnion(receiptPaths) // 4. mdfind (balanced only) @@ -226,6 +253,24 @@ public actor CandidateCollector { return CandidateCollection(candidates: candidates, receiptPaths: receiptPaths, catalogPaths: catalogPaths) } + private func collectDarwinCachePaths(identity: AppIdentity) -> Set { + let bundleID = identity.bundleID.lowercased() + guard !bundleID.isEmpty, !bundleID.hasPrefix("unknown."), + let contents = try? fileManager.contentsOfDirectory( + at: darwinCacheDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + else { + return [] + } + + return Set(contents.filter { + let name = $0.lastPathComponent.lowercased() + return name == bundleID || name.hasPrefix(bundleID + ".") + }) + } + private func collectCatalogPaths(identity: AppIdentity, home: String) -> Set { var found = Set() for template in KnownResidualCatalog.pathTemplates(for: identity) { @@ -238,7 +283,7 @@ public actor CandidateCollector { /// Files recorded in installer receipts for packages whose id matches the bundle ID. /// Paths inside the app bundle are skipped — the bundle is removed as a whole anyway. - private func collectReceiptPaths(identity: AppIdentity) async -> Set { + private func collectPkgutilReceiptPaths(identity: AppIdentity) async -> Set { guard !identity.bundleID.isEmpty, !identity.bundleID.hasPrefix("unknown.") else { return [] } var packageIDs: Set = [identity.bundleID] diff --git a/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift b/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift index cf5e737..5ea21f5 100644 --- a/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift +++ b/MacOSCleaner/Features/Uninstaller/DeveloperComponentsDetector.swift @@ -4,10 +4,11 @@ public enum DeveloperComponentsDetector { public static func detect( appName: String, bundleID: String?, - fileManager: FileManager = .default + fileManager: FileManager = .default, + homeDirectory: URL? = nil ) async -> [UninstallerService.RelatedCleanupComponent] { let fm = fileManager - let home = NSHomeDirectory() + let home = (homeDirectory ?? fm.homeDirectoryForCurrentUser).path var components: [UninstallerService.RelatedCleanupComponent] = [] let lowerName = appName.lowercased() let lowerID = bundleID?.lowercased() ?? "" @@ -35,6 +36,19 @@ public enum DeveloperComponentsDetector { )) } } + let androidDataURL = URL(fileURLWithPath: "\(home)/.android") + if fm.fileExists(atPath: androidDataURL.path) { + let androidDataSize = getDirectorySize(url: androidDataURL) + if androidDataSize > 0 { + components.append(UninstallerService.RelatedCleanupComponent( + title: "developer.android_data".localized, + category: .androidCaches, + sizeBytes: androidDataSize, + url: androidDataURL, + isSelected: false + )) + } + } } if lowerID == "com.apple.dt.xcode" || (lowerName == "xcode" && lowerID.hasPrefix("com.apple.dt")) { diff --git a/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift b/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift index 4ec80d5..3eaa5c0 100644 --- a/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift +++ b/MacOSCleaner/Features/Uninstaller/EvidenceProbe.swift @@ -35,6 +35,10 @@ public actor EvidenceProbe { } else if lowerFileName.hasPrefix(lowerBundleID + ".") { evidence.insert(.bundleIDPrefix) } + if url.pathExtension.lowercased() == "app", + Bundle(url: url)?.bundleIdentifier?.lowercased() == lowerBundleID { + evidence.insert(.bundleIDExact) + } if lowerFileName == lowerAppName { evidence.insert(.appNameExact) diff --git a/MacOSCleaner/Features/Uninstaller/KnownResidualCatalog.swift b/MacOSCleaner/Features/Uninstaller/KnownResidualCatalog.swift index 83d165c..9d65420 100644 --- a/MacOSCleaner/Features/Uninstaller/KnownResidualCatalog.swift +++ b/MacOSCleaner/Features/Uninstaller/KnownResidualCatalog.swift @@ -99,6 +99,24 @@ public enum KnownResidualCatalog { "~/Library/Saved Application State/com.google.android.studio.savedState", ] ), + // MARK: Antigravity IDE + Entry( + name: "Antigravity IDE", + bundleIDs: ["com.google.antigravity-ide"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/.antigravity", + "~/.antigravity-ide", + "~/Library/Application Support/Antigravity IDE", + "~/Library/Application Support/com.google.antigravity-ide", + "~/Library/Caches/com.google.antigravity-ide", + "~/Library/Caches/com.google.antigravity-ide.ShipIt", + "~/Library/HTTPStorages/com.google.antigravity-ide", + "~/Library/Logs/Antigravity IDE", + "~/Library/Preferences/com.google.antigravity-ide*.plist", + "~/Library/Saved Application State/com.google.antigravity-ide.savedState", + ] + ), // MARK: Araxis Merge Entry( name: "Araxis Merge", @@ -870,6 +888,20 @@ public enum KnownResidualCatalog { "~/Library/Preferences/com.omnigroup.OmniWeb5.plist", ] ), + // MARK: OpenCode + Entry( + name: "OpenCode", + bundleIDs: ["ai.opencode.desktop"], + bundleIDPrefixes: [], + pathTemplates: [ + "~/Library/Application Support/ai.opencode.desktop", + "~/Library/Caches/ai.opencode.desktop", + "~/Library/Caches/ai.opencode.desktop.ShipIt", + "~/Library/HTTPStorages/ai.opencode.desktop", + "~/Library/Preferences/ai.opencode.desktop*.plist", + "~/Library/Saved Application State/ai.opencode.desktop.savedState", + ] + ), // MARK: Opera Entry( name: "Opera", diff --git a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift index dc45b56..bfcc9db 100644 --- a/MacOSCleaner/Features/Uninstaller/UninstallerService.swift +++ b/MacOSCleaner/Features/Uninstaller/UninstallerService.swift @@ -175,7 +175,7 @@ public actor UninstallerService { } private func discoverAndIndex(_ url: URL) async throws -> AppInfo { - try safetyManager.validate(url: url) + try safetyManager.validate(url: url, policy: .uninstall) let identity = await AppIdentity.resolve(from: url, commandRunner: commandRunner) await identityCache.set(bundleID: identity.bundleID, identity: identity) @@ -270,7 +270,7 @@ public actor UninstallerService { let ruleScore = rule.evidence(for: node.url, identity: identity).reduce(0) { $0 + $1.weight } let assessment = ConfidenceEngine.assess(node.evidence, ruleScore: ruleScore, identity: identity) guard assessment.tier >= minimumTier else { continue } - guard (try? safetyManager.validate(url: node.url)) != nil else { continue } + guard (try? safetyManager.validate(url: node.url, policy: .uninstall)) != nil else { continue } guard !Self.isProtectedMailPath(node.url.path) else { continue } guard node.url.path != identity.bundleURL.path else { continue } guard !identity.bundleURL.path.hasPrefix(node.url.path + "/") else { continue } @@ -280,7 +280,9 @@ public actor UninstallerService { guard fileManager.fileExists(atPath: node.url.path, isDirectory: &isDir) else { continue } let fileSize = await getDirectorySize(url: node.url) - let risk: DeletionRisk = node.url.path.contains("Preferences") ? .normal : .safe + // Browser user data carries logins/sessions — never label it "safe". + let risk: DeletionRisk = (node.url.path.contains("Preferences") || safetyManager.isBrowserUserDataPath(node.url.path)) + ? .normal : .safe let file = RelatedFile( url: node.url, @@ -365,7 +367,7 @@ public actor UninstallerService { } if bypassTrash { - try safetyManager.validate(url: app.url) + try safetyManager.validate(url: app.url, policy: .uninstall) do { try fileManager.removeItem(at: app.url) Logger.uninstaller.info("Permanently removed: \(app.url.path, privacy: .public)") @@ -375,7 +377,7 @@ public actor UninstallerService { } for target in deletionTargets { do { - try safetyManager.validate(url: target) + try safetyManager.validate(url: target, policy: .uninstall) try fileManager.removeItem(at: target) Logger.uninstaller.debug("Removed: \(target.path, privacy: .public)") } catch { @@ -384,7 +386,7 @@ public actor UninstallerService { } } else { do { - _ = try await trashManager.trashItem(at: app.url) + _ = try await trashManager.trashItem(at: app.url, policy: .uninstall) Logger.uninstaller.info("Trashed: \(app.url.path, privacy: .public)") } catch { Logger.uninstaller.error("trashItem failed '\(app.url.path, privacy: .public)': \(error.localizedDescription, privacy: .public)") @@ -392,7 +394,7 @@ public actor UninstallerService { } for target in deletionTargets { do { - _ = try await trashManager.trashItem(at: target) + _ = try await trashManager.trashItem(at: target, policy: .uninstall) Logger.uninstaller.debug("Trashed: \(target.path, privacy: .public)") } catch { Logger.uninstaller.warning("trashItem related '\(target.lastPathComponent, privacy: .public)': \(error.localizedDescription, privacy: .public)") @@ -471,11 +473,7 @@ public actor UninstallerService { for app in apps { let key = "\(app.bundleID ?? "")-\(app.name)" if let existing = merged[key] { - var mainApp = existing - if app.url.path.hasPrefix("/Applications") && !existing.url.path.hasPrefix("/Applications") { - mainApp = app - } - merged[key] = mainApp + merged[key] = preferredApp(existing, app) } else { merged[key] = app } @@ -483,6 +481,20 @@ public actor UninstallerService { return Array(merged.values) } + private func preferredApp(_ lhs: AppInfo, _ rhs: AppInfo) -> AppInfo { + let lhsIsStandard = lhs.url.path.hasPrefix("/Applications/") + let rhsIsStandard = rhs.url.path.hasPrefix("/Applications/") + if lhsIsStandard != rhsIsStandard { + return rhsIsStandard ? rhs : lhs + } + + let versionOrder = lhs.version.compare(rhs.version, options: .numeric) + if versionOrder != .orderedSame { + return versionOrder == .orderedAscending ? rhs : lhs + } + return lhs.url.path <= rhs.url.path ? lhs : rhs + } + private func dedupAndSort(_ items: [(file: RelatedFile, tier: ConfidenceTier)]) -> [RelatedFile] { let sortedByPath = items.map(\.file).sorted { $0.url.path.count < $1.url.path.count } var deduplicated: [URL: RelatedFile] = [:] diff --git a/MacOSCleaner/Infrastructure/FileCleanupActor.swift b/MacOSCleaner/Infrastructure/FileCleanupActor.swift index a879b58..43e5457 100644 --- a/MacOSCleaner/Infrastructure/FileCleanupActor.swift +++ b/MacOSCleaner/Infrastructure/FileCleanupActor.swift @@ -52,6 +52,10 @@ public actor FileCleanupActor { var removedCount = 0 for item in contents { let itemURL = url.appendingPathComponent(item) + guard (try? safetyManager.validate(url: itemURL)) != nil else { + progress?(.log(" \(Self.shortPath(itemURL.path)) — protected, skipped")) + continue + } try? fm.removeItem(at: itemURL) removedCount += 1 } diff --git a/MacOSCleaner/Infrastructure/SafetyManager.swift b/MacOSCleaner/Infrastructure/SafetyManager.swift index 5788823..8673a7b 100644 --- a/MacOSCleaner/Infrastructure/SafetyManager.swift +++ b/MacOSCleaner/Infrastructure/SafetyManager.swift @@ -6,6 +6,13 @@ public enum SafetyError: Error, Equatable { case protectedPath(String) } +/// Context of a deletion request. Regular cleanup must never touch login/session +/// data; a full app uninstall is allowed to remove the app's user data. +public enum DeletionPolicy: Sendable { + case cleanup + case uninstall +} + public struct SafetyManager: Sendable { private let refuseList: [String] private let allowedExceptions: [String] @@ -18,6 +25,17 @@ public struct SafetyManager: Sendable { // (e.g. ~/Library/Preferences itself vs. an app's plist inside it). Exact match only. private let exactRefuseList: Set + // Browser user-data roots holding logins, cookies and site sessions. + // Under .cleanup only well-known cache subdirectories inside them may be deleted. + private let browserUserDataRoots: [String] + + // Lowercased directory names inside browser user-data roots that are safe caches. + private let browserCacheDirNames: Set + + // Lowercased basenames of credential/session files (Chromium family) protected + // anywhere under Application Support during cleanup. + private let credentialFileNames: Set + public init(allowedExceptions: [String] = []) { let home = NSHomeDirectory() self.refuseList = [ @@ -96,6 +114,38 @@ public struct SafetyManager: Sendable { "/private/var/db/dslocal", ] + let appSupport = "\(home)/Library/Application Support" + self.browserUserDataRoots = [ + "\(appSupport)/Google/Chrome", + "\(appSupport)/Google/Chrome Beta", + "\(appSupport)/Google/Chrome Canary", + "\(appSupport)/Google/Chrome Dev", + "\(appSupport)/Chrome", + "\(appSupport)/Chromium", + "\(appSupport)/BraveSoftware", + "\(appSupport)/Microsoft Edge", + "\(appSupport)/Microsoft Edge Beta", + "\(appSupport)/Microsoft Edge Canary", + "\(appSupport)/Microsoft Edge Dev", + "\(appSupport)/Arc/User Data", + "\(appSupport)/Firefox/Profiles", + "\(appSupport)/Vivaldi", + "\(appSupport)/Yandex/YandexBrowser", + "\(appSupport)/com.operasoftware.Opera", + "\(appSupport)/com.operasoftware.OperaGX", + "\(appSupport)/com.operasoftware.OperaDeveloperEdition", + ] + self.browserCacheDirNames = [ + "cache", "code cache", "gpucache", + "shadercache", "grshadercache", "crashpad", "service worker", + // Firefox profile caches + "cache2", "startupcache", "thumbnails", + ] + self.credentialFileNames = [ + "login data", "login data for account", "cookies", + "web data", "account web data", "local state", "secure preferences", + ] + // Note: roots whose *contents* cleanup legitimately clears (Saved Application // State, ~/Library/LaunchAgents, /Library/LaunchDaemons) are not listed — // FileCleanupActor validates the root itself before touching children. @@ -119,7 +169,7 @@ public struct SafetyManager: Sendable { ] } - public func validate(url: URL) throws { + public func validate(url: URL, policy: DeletionPolicy = .cleanup) throws { guard url.isFileURL else { throw SafetyError.pathNormalizationFailed } @@ -144,6 +194,10 @@ public struct SafetyManager: Sendable { throw SafetyError.protectedPath(p) } + if policy == .cleanup, let refused = cleanupProtectedPath(p) { + throw SafetyError.protectedPath(refused) + } + let isException = allowedExceptions.contains { exception in p == exception || p.hasPrefix(exception + "/") } @@ -168,6 +222,42 @@ public struct SafetyManager: Sendable { } } + /// True for paths inside (or being) a browser user-data root — logins, cookies, profiles. + public func isBrowserUserDataPath(_ path: String) -> Bool { + let standardized = URL(fileURLWithPath: path).standardizedFileURL.path + return browserUserDataRoots.contains { standardized == $0 || standardized.hasPrefix($0 + "/") } + } + + /// Cleanup-only protection: login/session data survives regular cleanup. + /// Returns the path that must be protected, or nil when deletion is allowed. + private func cleanupProtectedPath(_ path: String) -> String? { + for root in browserUserDataRoots { + // The root itself, or an ancestor directory whose removal would take + // the root with it (e.g. ~/Library/Application Support/Google). + if path == root || root.hasPrefix(path + "/") { + return path + } + guard path.hasPrefix(root + "/") else { continue } + let components = path.dropFirst(root.count + 1) + .split(separator: "/") + .map { $0.lowercased() } + if components.contains(where: { browserCacheDirNames.contains($0) }) { + return nil + } + return path + } + + // Credential/session files of any app under Application Support + // (Electron and Chromium-based apps share the same file names). + guard path.contains("/Application Support/") else { return nil } + var basename = URL(fileURLWithPath: path).lastPathComponent.lowercased() + for suffix in ["-journal", "-wal", "-shm"] where basename.hasSuffix(suffix) { + basename = String(basename.dropLast(suffix.count)) + break + } + return credentialFileNames.contains(basename) ? path : nil + } + /// Allows deletion of VM/container user data under Documents or Desktop when clearly app-owned. static func isVirtualizationUserDataResidual(_ path: String) -> Bool { let home = NSHomeDirectory().lowercased() diff --git a/MacOSCleaner/Infrastructure/TrashManager.swift b/MacOSCleaner/Infrastructure/TrashManager.swift index 6f73336..ec94db1 100644 --- a/MacOSCleaner/Infrastructure/TrashManager.swift +++ b/MacOSCleaner/Infrastructure/TrashManager.swift @@ -21,8 +21,8 @@ public actor TrashManager { } @discardableResult - public func trashItem(at url: URL) throws -> URL { - try safetyManager.validate(url: url) + public func trashItem(at url: URL, policy: DeletionPolicy = .cleanup) throws -> URL { + try safetyManager.validate(url: url, policy: policy) var resultingURL: NSURL? do { diff --git a/MacOSCleaner/MacOSCleanerTests/AppDiscoveryTests.swift b/MacOSCleaner/MacOSCleanerTests/AppDiscoveryTests.swift index d498700..fe8c919 100644 --- a/MacOSCleaner/MacOSCleanerTests/AppDiscoveryTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/AppDiscoveryTests.swift @@ -8,4 +8,29 @@ final class AppDiscoveryTests: XCTestCase { XCTAssertFalse(urls.isEmpty) XCTAssertTrue(urls.contains { $0.pathExtension == "app" }) } + + func test_homebrewApplications_findsOnlyTopLevelAppsInReceiptedKegs() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AppDiscoveryTests-\(UUID().uuidString)", isDirectory: true) + let cellar = root.appendingPathComponent("Cellar", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let keg = cellar.appendingPathComponent("python@3.14/3.14.6", isDirectory: true) + let idle = keg.appendingPathComponent("IDLE 3.app", isDirectory: true) + let launcher = keg.appendingPathComponent("Python Launcher 3.app", isDirectory: true) + let nested = keg.appendingPathComponent("lib/Nested.app", isDirectory: true) + try FileManager.default.createDirectory(at: idle, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: launcher, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try Data("{}".utf8).write(to: keg.appendingPathComponent("INSTALL_RECEIPT.json")) + + let unreceipted = cellar.appendingPathComponent("other/1.0/Hidden.app", isDirectory: true) + try FileManager.default.createDirectory(at: unreceipted, withIntermediateDirectories: true) + + let applications = AppDiscovery.homebrewApplications(in: [cellar]) + XCTAssertEqual( + Set(applications.map { $0.resolvingSymlinksInPath().path }), + Set([idle, launcher].map { $0.resolvingSymlinksInPath().path }) + ) + } } diff --git a/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift b/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift index 590c2b6..288f8a8 100644 --- a/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/CandidateCollectorTests.swift @@ -172,4 +172,125 @@ final class CandidateCollectorTests: XCTestCase { let candidates = await collector.collect(identity: identity) XCTAssertTrue(candidates.contains { $0.path == chromeDir.path }) } + + func test_collect_marksAppsFromSameHomebrewFormulaAsReceiptPaths() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CandidateCollectorTests-\(UUID().uuidString)", isDirectory: true) + let cellar = root.appendingPathComponent("Cellar", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + func makeKeg(formula: String, version: String) throws -> [URL] { + let keg = cellar.appendingPathComponent("\(formula)/\(version)", isDirectory: true) + let idle = keg.appendingPathComponent("IDLE 3.app", isDirectory: true) + let launcher = keg.appendingPathComponent("Python Launcher 3.app", isDirectory: true) + for (app, bundleID) in [ + (idle, "org.python.IDLE"), + (launcher, "org.python.PythonLauncher"), + ] { + let contents = app.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + let plist: [String: Any] = [ + "CFBundleIdentifier": bundleID, + "CFBundleName": app.deletingPathExtension().lastPathComponent, + "CFBundleExecutable": app.deletingPathExtension().lastPathComponent, + ] + let data = try PropertyListSerialization.data( + fromPropertyList: plist, + format: .xml, + options: 0 + ) + try data.write(to: contents.appendingPathComponent("Info.plist")) + } + try Data("{}".utf8).write(to: keg.appendingPathComponent("INSTALL_RECEIPT.json")) + return [idle, launcher] + } + + let oldApps = try makeKeg(formula: "python@3.12", version: "3.12.13_4") + let currentApps = try makeKeg(formula: "python@3.14", version: "3.14.6") + let unrelatedApps = try makeKeg(formula: "ruby@3.4", version: "3.4.1") + let identity = AppIdentity( + bundleID: "org.python.IDLE", + appName: "IDLE 3", + bundleName: "IDLE", + bundleVersion: "3.14.6", + executableName: "IDLE", + teamID: nil, + signingAuthority: nil, + bundleURL: currentApps[0], + isAppStore: false, isSandboxed: false, isAdHocSigned: true, + vendorNames: ["python"], + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let runner = MockCommandRunner() + runner.runDelay = .zero + let collection = await CandidateCollector( + commandRunner: runner, + homebrewCellarDirectories: [cellar] + ).collectDetailed(identity: identity, mode: .safe) + + let expectedPaths = Set([oldApps[0], currentApps[0]].map { + $0.resolvingSymlinksInPath().path + }) + XCTAssertEqual( + Set(collection.receiptPaths.map { $0.resolvingSymlinksInPath().path }), + expectedPaths + ) + XCTAssertTrue(expectedPaths.isSubset(of: Set( + collection.candidates.map { $0.resolvingSymlinksInPath().path } + ))) + let unrelatedPaths = Set(unrelatedApps.map { $0.resolvingSymlinksInPath().path }) + XCTAssertTrue(unrelatedPaths.isDisjoint(with: Set( + collection.receiptPaths.map { $0.resolvingSymlinksInPath().path } + ))) + let launcherPaths = Set([oldApps[1], currentApps[1]].map { + $0.resolvingSymlinksInPath().path + }) + XCTAssertTrue(launcherPaths.isDisjoint(with: Set( + collection.receiptPaths.map { $0.resolvingSymlinksInPath().path } + ))) + } + + func test_collect_findsOnlyExactBundleFamilyInDarwinCache() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CandidateCollectorDarwinCache-\(UUID().uuidString)", isDirectory: true) + let cacheRoot = root.appendingPathComponent("C", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let marker = UUID().uuidString.lowercased() + let bundleID = "com.example.\(marker)" + let ownCache = cacheRoot.appendingPathComponent("\(bundleID).helper", isDirectory: true) + let foreignCache = cacheRoot.appendingPathComponent("com.example.other.helper", isDirectory: true) + try FileManager.default.createDirectory(at: ownCache, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: foreignCache, withIntermediateDirectories: true) + + let identity = AppIdentity( + bundleID: bundleID, + appName: "DarwinCacheTest", + bundleName: nil, + bundleVersion: nil, + executableName: "DarwinCacheTest", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/Applications/DarwinCacheTest.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: false, + vendorNames: [], + helperNames: [], frameworkNames: [], xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let runner = MockCommandRunner() + runner.runDelay = .zero + let candidates = await CandidateCollector( + commandRunner: runner, + homebrewCellarDirectories: [], + darwinCacheDirectory: cacheRoot + ).collect(identity: identity, mode: .safe) + + let ownPath = ownCache.resolvingSymlinksInPath().path + let foreignPath = foreignCache.resolvingSymlinksInPath().path + XCTAssertTrue(candidates.contains { $0.resolvingSymlinksInPath().path == ownPath }) + XCTAssertFalse(candidates.contains { $0.resolvingSymlinksInPath().path == foreignPath }) + } } diff --git a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift index 5cf7241..5829dd0 100644 --- a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift @@ -78,6 +78,25 @@ struct CleanupEngineTests { #expect(!FileManager.default.fileExists(atPath: file2.path)) } + @Test("Clean contents preserves credential files") + func cleanContentsPreservesCredentialFiles() async throws { + let engine = CleanupEngine() + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/MacOSCleanerTests_Cred_\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let cookies = dir.appendingPathComponent("Cookies") + let junk = dir.appendingPathComponent("junk.txt") + try "session".write(to: cookies, atomically: true, encoding: .utf8) + try "junk".write(to: junk, atomically: true, encoding: .utf8) + + _ = try await engine.cleanContents(of: dir.path, dryRun: false) + + #expect(FileManager.default.fileExists(atPath: cookies.path)) + #expect(!FileManager.default.fileExists(atPath: junk.path)) + } + @Test("Clean contents dry run preserves files") func cleanContentsDryRunPreservesFiles() async throws { let engine = CleanupEngine() diff --git a/MacOSCleaner/MacOSCleanerTests/DeveloperComponentsDetectorTests.swift b/MacOSCleaner/MacOSCleanerTests/DeveloperComponentsDetectorTests.swift index 4467a48..505b955 100644 --- a/MacOSCleaner/MacOSCleanerTests/DeveloperComponentsDetectorTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/DeveloperComponentsDetectorTests.swift @@ -21,6 +21,28 @@ final class DeveloperComponentsDetectorTests: XCTestCase { XCTAssertNotNil(components) } + func test_detect_reportsAndroidUserDataWithoutSelectingSharedData() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("DeveloperComponentsDetectorTests-\(UUID().uuidString)", isDirectory: true) + let androidData = home.appendingPathComponent(".android", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + try FileManager.default.createDirectory(at: androidData, withIntermediateDirectories: true) + try Data(repeating: 1, count: 4096) + .write(to: androidData.appendingPathComponent("devices.xml")) + + let components = await DeveloperComponentsDetector.detect( + appName: "Android Studio", + bundleID: "com.google.android.studio", + fileManager: .default, + homeDirectory: home + ) + + let component = components.first { $0.url.path == androidData.path } + XCTAssertNotNil(component) + XCTAssertEqual(component?.category, .androidCaches) + XCTAssertEqual(component?.isSelected, false) + } + func test_detect_returnsXcodeComponents() async { let components = await DeveloperComponentsDetector.detect( appName: "Xcode", diff --git a/MacOSCleaner/MacOSCleanerTests/EvidenceProbeTests.swift b/MacOSCleaner/MacOSCleanerTests/EvidenceProbeTests.swift index 7cf73ab..1f97bb3 100644 --- a/MacOSCleaner/MacOSCleanerTests/EvidenceProbeTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/EvidenceProbeTests.swift @@ -311,6 +311,46 @@ final class EvidenceProbeTests: XCTestCase { XCTAssertFalse(EvidenceProbe.wordBoundaryMatch("searchmarquis", "arc")) } + func test_probe_appBundle_readsExactBundleIdentifier() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("EvidenceProbeTests-\(UUID().uuidString)", isDirectory: true) + let app = root.appendingPathComponent("Another Copy.app", isDirectory: true) + let contents = app.appendingPathComponent("Contents", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + let plist: [String: Any] = [ + "CFBundleIdentifier": "org.python.IDLE", + "CFBundleName": "IDLE", + "CFBundleExecutable": "IDLE", + ] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: contents.appendingPathComponent("Info.plist")) + + let identity = AppIdentity( + bundleID: "org.python.IDLE", + appName: "IDLE 3", + bundleName: "IDLE", + bundleVersion: "3.14.6", + executableName: "IDLE", + teamID: nil, + signingAuthority: nil, + bundleURL: URL(fileURLWithPath: "/opt/homebrew/Cellar/python@3.14/3.14.6/IDLE 3.app"), + isAppStore: false, isSandboxed: false, isAdHocSigned: true, + vendorNames: ["python"], helperNames: [], frameworkNames: [], + xpcServiceNames: [], plugInNames: [], + isElectron: false, isJetBrains: false, isFlutter: false, + isJava: false, isQt: false, isDocker: false + ) + let runner = MockCommandRunner() + runner.runDelay = .zero + let evidence = await EvidenceProbe( + commandRunner: runner, + codesignCache: CodesignCache(), + plistCache: PlistContentCache() + ).probe(url: app, identity: identity) + XCTAssertTrue(evidence.contains(.bundleIDExact)) + } + func test_probe_noEvidence() async { let codesignCache = CodesignCache() let plistCache = PlistContentCache() diff --git a/MacOSCleaner/MacOSCleanerTests/KnownResidualCatalogTests.swift b/MacOSCleaner/MacOSCleanerTests/KnownResidualCatalogTests.swift index 009a373..f3743cc 100644 --- a/MacOSCleaner/MacOSCleanerTests/KnownResidualCatalogTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/KnownResidualCatalogTests.swift @@ -49,6 +49,20 @@ final class KnownResidualCatalogTests: XCTestCase { XCTAssertTrue(templates.contains { $0.contains("JetBrains") }) } + func test_antigravityIncludesVerifiedDotDirectories() { + let templates = KnownResidualCatalog.pathTemplates(bundleID: "com.google.antigravity-ide") + XCTAssertTrue(templates.contains("~/.antigravity")) + XCTAssertTrue(templates.contains("~/.antigravity-ide")) + XCTAssertTrue(templates.contains("~/Library/Application Support/Antigravity IDE")) + } + + func test_openCodeIncludesAppSpecificDataButNotSharedCLIConfig() { + let templates = KnownResidualCatalog.pathTemplates(bundleID: "ai.opencode.desktop") + XCTAssertTrue(templates.contains("~/Library/Application Support/ai.opencode.desktop")) + XCTAssertTrue(templates.contains("~/Library/Caches/ai.opencode.desktop.ShipIt")) + XCTAssertFalse(templates.contains { $0.hasPrefix("~/.config/opencode") }) + } + func test_safariExcludedFromCatalog() { XCTAssertTrue(KnownResidualCatalog.pathTemplates(bundleID: "com.apple.Safari").isEmpty) } diff --git a/MacOSCleaner/MacOSCleanerTests/SafetyManagerTests.swift b/MacOSCleaner/MacOSCleanerTests/SafetyManagerTests.swift index 38bc7b6..35ed8e1 100644 --- a/MacOSCleaner/MacOSCleanerTests/SafetyManagerTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/SafetyManagerTests.swift @@ -163,6 +163,68 @@ final class SafetyManagerTests: XCTestCase { } } + func testCleanupBlocksBrowserUserDataPaths() { + let blocked = [ + "\(home!)/Library/Application Support/Google/Chrome", + "\(home!)/Library/Application Support/Google/Chrome/Default", + "\(home!)/Library/Application Support/Google/Chrome/Default/Login Data", + "\(home!)/Library/Application Support/Google/Chrome/Default/Network/Cookies", + "\(home!)/Library/Application Support/Google/Chrome/Profile 1/Cookies", + // Ancestor whose removal would take the profile root with it + "\(home!)/Library/Application Support/Google", + "\(home!)/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Data", + "\(home!)/Library/Application Support/Firefox/Profiles/abcd.default-release/logins.json", + ] + for path in blocked { + let url = URL(fileURLWithPath: path) + XCTAssertThrowsError(try safetyManager.validate(url: url), "Cleanup must not delete \(path)") + } + } + + func testCleanupAllowsBrowserCacheSubdirectories() { + let allowed = [ + "\(home!)/Library/Application Support/Google/Chrome/Default/Cache", + "\(home!)/Library/Application Support/Google/Chrome/Default/Code Cache/js", + "\(home!)/Library/Application Support/Google/Chrome/GrShaderCache", + "\(home!)/Library/Application Support/Google/Chrome/Crashpad", + "\(home!)/Library/Application Support/Firefox/Profiles/abcd.default-release/cache2", + ] + for path in allowed { + let url = URL(fileURLWithPath: path) + XCTAssertNoThrow(try safetyManager.validate(url: url), "Cleanup must allow cache path \(path)") + } + } + + func testUninstallPolicyAllowsBrowserUserDataRemoval() { + let allowed = [ + "\(home!)/Library/Application Support/Google/Chrome", + "\(home!)/Library/Application Support/Google/Chrome/Default", + "\(home!)/Library/Application Support/Google", + ] + for path in allowed { + let url = URL(fileURLWithPath: path) + XCTAssertNoThrow(try safetyManager.validate(url: url, policy: .uninstall), "Uninstall must allow \(path)") + } + // Credential files stay hard-protected even during uninstall + let loginData = URL(fileURLWithPath: "\(home!)/Library/Application Support/Google/Chrome/Default/Login Data") + XCTAssertThrowsError(try safetyManager.validate(url: loginData, policy: .uninstall)) + } + + func testCleanupBlocksCredentialFilesInApplicationSupport() { + let blocked = [ + "\(home!)/Library/Application Support/Slack/Cookies", + "\(home!)/Library/Application Support/Slack/Cookies-journal", + "\(home!)/Library/Application Support/SomeApp/Login Data", + "\(home!)/Library/Application Support/SomeApp/Local State", + ] + for path in blocked { + let url = URL(fileURLWithPath: path) + XCTAssertThrowsError(try safetyManager.validate(url: url), "Cleanup must not delete \(path)") + } + let cache = URL(fileURLWithPath: "\(home!)/Library/Application Support/Slack/Cache") + XCTAssertNoThrow(try safetyManager.validate(url: cache)) + } + func testVirtualizationUserDataUnderDocumentsAllowed() { let vmPath = "\(home!)/Documents/my_project/Orbstack_files" let url = URL(fileURLWithPath: vmPath) diff --git a/MacOSCleaner/Resources/en.lproj/Localizable.strings b/MacOSCleaner/Resources/en.lproj/Localizable.strings index 942885f..4fbfedb 100644 --- a/MacOSCleaner/Resources/en.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/en.lproj/Localizable.strings @@ -481,6 +481,7 @@ /* Developer Components */ "developer.android_sdk" = "Android SDK"; +"developer.android_data" = "Android data and virtual devices"; "developer.gradle_cache" = "Gradle Cache"; "developer.xcode_derived_data" = "Xcode Derived Data"; "developer.ios_simulators" = "iOS Simulators"; diff --git a/MacOSCleaner/Resources/es.lproj/Localizable.strings b/MacOSCleaner/Resources/es.lproj/Localizable.strings index 2747a99..fa8b21e 100644 --- a/MacOSCleaner/Resources/es.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/es.lproj/Localizable.strings @@ -484,6 +484,7 @@ // Developer Components "developer.android_sdk" = "Android SDK"; +"developer.android_data" = "Datos de Android y dispositivos virtuales"; "developer.gradle_cache" = "Caché de Gradle"; "developer.xcode_derived_data" = "Xcode Derived Data"; "developer.ios_simulators" = "Simuladores de iOS"; diff --git a/MacOSCleaner/Resources/ru.lproj/Localizable.strings b/MacOSCleaner/Resources/ru.lproj/Localizable.strings index f015885..9e11222 100644 --- a/MacOSCleaner/Resources/ru.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/ru.lproj/Localizable.strings @@ -471,6 +471,7 @@ /* Developer Components */ "developer.android_sdk" = "Android SDK"; +"developer.android_data" = "Данные Android и виртуальные устройства"; "developer.gradle_cache" = "Кэш Gradle"; "developer.xcode_derived_data" = "Xcode Derived Data"; "developer.ios_simulators" = "Симуляторы iOS"; diff --git a/MacOSCleaner/Resources/uk.lproj/Localizable.strings b/MacOSCleaner/Resources/uk.lproj/Localizable.strings index 8a3307d..c551508 100644 --- a/MacOSCleaner/Resources/uk.lproj/Localizable.strings +++ b/MacOSCleaner/Resources/uk.lproj/Localizable.strings @@ -467,6 +467,7 @@ /* Developer Components */ "developer.android_sdk" = "Android SDK"; +"developer.android_data" = "Дані Android і віртуальні пристрої"; "developer.gradle_cache" = "Кеш Gradle"; "developer.xcode_derived_data" = "Xcode Derived Data"; "developer.ios_simulators" = "Симулятори iOS"; From d1fa9d35092752a8cceacd62ec562e9796146ac1 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 18:11:55 +0300 Subject: [PATCH 18/23] feat: add localized group title mapping for cleanup categories, enhance selected size calculation to deduplicate paths, and improve item management logic --- .../CleanupCategory+FixtureMapping.swift | 17 +++++++ .../Domains/Cleanup/CleanupCoordinator.swift | 18 ++++++-- .../Domains/Cleanup/CleanupItemManager.swift | 24 ++++++++-- .../Cleanup/GeneratedCleanupPaths.swift | 3 -- .../CleanupEngineTests.swift | 45 +++++++++++++++++++ 5 files changed, 97 insertions(+), 10 deletions(-) diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift b/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift index 60cb68b..5ab2043 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCategory+FixtureMapping.swift @@ -6,6 +6,14 @@ extension CleanupCategory { "category.\(rawValue)".localized } + /// Maps scanner/engine group labels to the localized category title used in the preview UI. + public static func localizedGroupTitle(for scannerLabel: String) -> String { + for category in CleanupCategory.allCases where category.previewLabels.contains(scannerLabel) { + return category.localizedTitle + } + return scannerLabel + } + public var previewLabels: Set { var labels: Set = [localizedTitle] @@ -17,22 +25,29 @@ extension CleanupCategory { labels.insert("Package managers") labels.formUnion(["Homebrew cache", "npm cache", "yarn cache", "pnpm store", "CocoaPods cache"]) case .gradleMaven: + labels.insert("Gradle + Maven") labels.insert("Gradle caches + wrapper + daemon") case .flutterDart: + labels.insert("Flutter / Dart") labels.insert("Dart/Flutter package caches") case .xcode: + labels.insert("Xcode") labels.insert("Xcode cleanup") case .iosSimulators: + labels.insert("iOS Simulators") labels.insert("Simulator caches") case .androidCaches: + labels.insert("Android caches") labels.insert("Android build caches") case .androidSDK: + labels.insert("Android SDK") labels.insert("Android SDK cleanup") case .ideCaches: labels.insert("IDE / Electron caches") case .browserCaches: labels.insert("Browser caches") case .messagingMedia: + labels.insert("Messaging / media") labels.insert("Messaging / media caches") case .docker: labels.insert("Docker") @@ -43,6 +58,7 @@ extension CleanupCategory { case .systemCaches: labels.insert("System caches") case .appContainers: + labels.insert("App containers") labels.insert("App container caches") case .dotfileCaches: labels.insert("Dotfile caches") @@ -83,6 +99,7 @@ extension CleanupCategory { case .chromeExtraCaches: labels.insert("Chrome Extra Caches") case .ideOldVersions: + labels.insert("Old IDE Versions") labels.insert("Old IDE versions") case .launchAgents: labels.insert("Launch Agents") diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift index b862cee..51eb850 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift @@ -369,15 +369,16 @@ public final class CleanupCoordinator: @unchecked Sendable { } case .categoryResult(let category, let label, let freedMB): Logger.coordinator.debug("CategoryResult event: cat=\(category), label=\(label, privacy: .public), freed=\(freedMB)") - if freedMB > 0 { + if freedMB > 0, !hasPathBackedPreviewItems(for: category, label: label) { self.itemManager.appendPreviewItem(label, size: freedMB, deletable: true, parentName: category, description: nil) } case .log(let message): self.pendingLogs.append(message) self.scheduleLogFlushIfNeeded() case .fileItem(let path, let sizeBytes, let modificationDate, let isDirectory, let category, let parentName): - let effectiveParent = parentName ?? category - self.itemManager.appendFileItem(path: path, sizeBytes: sizeBytes, modificationDate: modificationDate, isDirectory: isDirectory, category: category, parentName: effectiveParent) + let localizedCategory = CleanupCategory.localizedGroupTitle(for: category) + let effectiveParent = parentName.map { CleanupCategory.localizedGroupTitle(for: $0) } ?? localizedCategory + self.itemManager.appendFileItem(path: path, sizeBytes: sizeBytes, modificationDate: modificationDate, isDirectory: isDirectory, category: localizedCategory, parentName: effectiveParent) } } @@ -391,6 +392,17 @@ public final class CleanupCoordinator: @unchecked Sendable { } } + @MainActor + private func hasPathBackedPreviewItems(for category: String, label: String) -> Bool { + itemManager.items.contains { item in + let matchesCategory = item.label == category + || CleanupCategory.localizedGroupTitle(for: item.label) == category + || item.label == label + guard matchesCategory else { return false } + return item.children.contains { $0.path != nil } + } + } + @MainActor private func flushLogs() { if !pendingLogs.isEmpty { diff --git a/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift b/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift index 94582be..4783417 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupItemManager.swift @@ -32,7 +32,8 @@ public final class CleanupItemManager { } public var selectedSizeBytes: Int64 { - items.reduce(0) { $0 + Self.selectedSizeBytes(for: $1) } + var seenPaths = Set() + return items.reduce(0) { $0 + Self.selectedSizeBytes(for: $1, seenPaths: &seenPaths) } } // MARK: - File Item Append (new hierarchical flow) @@ -214,12 +215,27 @@ public final class CleanupItemManager { } } - private static func selectedSizeBytes(for item: CleanupPreviewItem) -> Int64 { + private static func selectedSizeBytes(for item: CleanupPreviewItem, seenPaths: inout Set) -> Int64 { if item.children.isEmpty { - return item.isSelected ? item.sizeBytes : 0 + guard item.isSelected else { return 0 } + if let path = item.path { + let normalized = normalizePath(path) + guard seenPaths.insert(normalized).inserted else { return 0 } + } + return item.sizeBytes } - return item.children.reduce(0) { $0 + selectedSizeBytes(for: $1) } + return item.children.reduce(0) { $0 + selectedSizeBytes(for: $1, seenPaths: &seenPaths) } + } + + private static func normalizePath(_ path: String) -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return (path as NSString).standardizingPath.replacingOccurrences(of: home, with: "~") + } + + private static func selectedSizeBytes(for item: CleanupPreviewItem) -> Int64 { + var seenPaths = Set() + return selectedSizeBytes(for: item, seenPaths: &seenPaths) } private func hasPreviewItem(for category: CleanupCategory) -> Bool { diff --git a/MacOSCleaner/Domains/Cleanup/GeneratedCleanupPaths.swift b/MacOSCleaner/Domains/Cleanup/GeneratedCleanupPaths.swift index e690cc1..52692cb 100644 --- a/MacOSCleaner/Domains/Cleanup/GeneratedCleanupPaths.swift +++ b/MacOSCleaner/Domains/Cleanup/GeneratedCleanupPaths.swift @@ -69,8 +69,6 @@ public enum GeneratedCleanupPaths { CleanupPath(path: "~/Library/Caches/com.sublimetext.4", category: .ideCaches), CleanupPath(path: "~/Library/Caches/com.torusknot.SourceTreeNotMAS", category: .ideCaches), CleanupPath(path: "~/Library/Caches/dev.zed.Zed", category: .ideCaches), - CleanupPath(path: "~/Library/Developer/CoreSimulator/Caches", category: .ideCaches), - CleanupPath(path: "~/Library/Developer/Xcode/DerivedData", category: .ideCaches), ] public static let appCaches: [CleanupPath] = [ @@ -166,7 +164,6 @@ public enum GeneratedCleanupPaths { CleanupPath(path: "~/.aws/cache", category: .dotfileCaches), CleanupPath(path: "~/.cargo/registry/cache", category: .dotfileCaches), CleanupPath(path: "~/.config/gcloud/cache", category: .dotfileCaches), - CleanupPath(path: "~/.gradle/caches", category: .dotfileCaches), CleanupPath(path: "~/.kube/cache", category: .dotfileCaches), CleanupPath(path: "~/.minikube/cache", category: .dotfileCaches), CleanupPath(path: "~/.pyenv/cache", category: .dotfileCaches), diff --git a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift index 5829dd0..86252ad 100644 --- a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift @@ -737,6 +737,51 @@ struct CleanupEngineTests { #expect(categories.contains(.chromeExtraCaches)) } + // MARK: - CleanupItemManager selection totals + + @Test("Selected size deduplicates the same path across categories") + func selectedSizeDeduplicatesPaths() { + let manager = CleanupItemManager() + let sharedPath = "~/.gradle/caches" + + manager.appendFileItem( + path: sharedPath, + sizeBytes: 4_000_000_000, + modificationDate: nil, + isDirectory: true, + category: "Gradle + Maven", + parentName: nil + ) + manager.appendFileItem( + path: sharedPath, + sizeBytes: 4_000_000_000, + modificationDate: nil, + isDirectory: true, + category: "Dotfile caches", + parentName: nil + ) + + #expect(manager.selectedSizeBytes == 4_000_000_000) + } + + @Test("Selected size ignores duplicate aggregate rows when path children exist") + func selectedSizeIgnoresAggregateDuplicates() { + let manager = CleanupItemManager() + let path = "~/Library/Developer/Xcode/DerivedData" + + manager.appendFileItem( + path: path, + sizeBytes: 30_000_000_000, + modificationDate: nil, + isDirectory: true, + category: "IDE / Electron caches", + parentName: nil + ) + manager.appendPreviewItem("IDE / Electron caches", size: 30_000, deletable: true, parentName: "IDE / Electron caches", description: nil) + + #expect(manager.selectedSizeBytes == 30_000_000_000) + } + // MARK: - Helpers private func createTempCacheDir() -> URL { From 6f80099b9af466ad2ee382a4eafecc8ac7666c44 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 18:25:21 +0300 Subject: [PATCH 19/23] fix: prevent adding cleaned items with zero freed bytes to the results list for more accurate cleanup reporting --- MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift index 51eb850..6c8f708 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift @@ -174,7 +174,9 @@ public final class CleanupCoordinator: @unchecked Sendable { for result in results { self.totalFreedMB += result.freedMB self.totalFreedBytes += result.freedBytes - self.cleanedItems.append(CleanupResultItem(label: result.label, freedMB: result.freedMB, freedBytes: result.freedBytes)) + if result.freedBytes > 0 { + self.cleanedItems.append(CleanupResultItem(label: result.label, freedMB: result.freedMB, freedBytes: result.freedBytes)) + } records.append(OperationRecord(id: UUID(), itemPath: result.label, status: "success", bytesFreed: result.freedBytes)) } @@ -197,7 +199,9 @@ public final class CleanupCoordinator: @unchecked Sendable { let trashLabel = "trash_user_label".localized self.totalFreedMB += deletedMB self.totalFreedBytes += deletedBytes - self.cleanedItems.append(CleanupResultItem(label: trashLabel, freedMB: deletedMB, freedBytes: deletedBytes)) + if deletedBytes > 0 { + self.cleanedItems.append(CleanupResultItem(label: trashLabel, freedMB: deletedMB, freedBytes: deletedBytes)) + } records.append(OperationRecord(id: UUID(), itemPath: "~/.Trash", status: "success", bytesFreed: deletedBytes)) } From 8e8058515f30263c20d082d7c7ae8e02e9548934 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 18:40:42 +0300 Subject: [PATCH 20/23] feat: improve directory size calculations by prioritizing allocated size over logical size, enhance cleanup logic with size cache invalidation, and add error handling for file deletions --- .../Domains/Cleanup/CleanupCoordinator.swift | 1 + .../Infrastructure/DirectorySizeCache.swift | 23 +++- .../Infrastructure/FileCleanupActor.swift | 120 +++++++++++++++--- .../Infrastructure/FileManager+Size.swift | 16 ++- .../CleanupEngineTests.swift | 19 +++ 5 files changed, 149 insertions(+), 30 deletions(-) diff --git a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift index 6c8f708..81d2bd1 100644 --- a/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift +++ b/MacOSCleaner/Domains/Cleanup/CleanupCoordinator.swift @@ -71,6 +71,7 @@ public final class CleanupCoordinator: @unchecked Sendable { try self.stateMachine.transition(to: .scanning) self.itemManager.clear() + FileManager.clearSizeCache() self.lastError = nil self.scriptLogs = [] self.pendingLogs = [] diff --git a/MacOSCleaner/Infrastructure/DirectorySizeCache.swift b/MacOSCleaner/Infrastructure/DirectorySizeCache.swift index 12b5621..50290be 100644 --- a/MacOSCleaner/Infrastructure/DirectorySizeCache.swift +++ b/MacOSCleaner/Infrastructure/DirectorySizeCache.swift @@ -46,15 +46,18 @@ public actor DirectorySizeCache { public func invalidate(_ path: String) { cache.removeValue(forKey: path) + FileManager.clearSizeCache() } public func invalidateParent(of path: String) { let parent = (path as NSString).deletingLastPathComponent cache.removeValue(forKey: parent) + FileManager.clearSizeCache() } public func invalidateAll() { cache.removeAll() + FileManager.clearSizeCache() } public func getCachedSize(_ path: String) -> Int64? { @@ -70,13 +73,16 @@ public actor DirectorySizeCache { } } + /// Reclaimable disk footprint (allocated blocks), not logical/apparent size. + /// APFS clones in DerivedData can report tens of GB logical while only + /// hundreds of MB are actually freed on delete. private func computeSize(_ path: String) -> CachedDirectoryInfo { let url = URL(fileURLWithPath: path) var size: Int64 = 0 var fileCount: Int = 0 guard let enumerator = fm.enumerator( at: url, - includingPropertiesForKeys: [.fileSizeKey], + includingPropertiesForKeys: [.fileSizeKey, .totalFileAllocatedSizeKey, .isDirectoryKey], options: [] ) else { return CachedDirectoryInfo(size: 0, fileCount: 0, timestamp: Date()) } @@ -84,10 +90,6 @@ public actor DirectorySizeCache { if Task.isCancelled { break } - if fileCount >= 100000 { - // Prevent infinite or extremely long size calculations - break - } let shouldExclude = FileManager.shouldExclude(url: fileURL) if shouldExclude { if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { @@ -96,8 +98,15 @@ public actor DirectorySizeCache { continue } fileCount += 1 - if let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]) { - size += Int64(values.fileSize ?? 0) + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .totalFileAllocatedSizeKey]) else { + continue + } + // Prefer allocated (physical) size — what deleting actually reclaims. + // Use allocated even when 0 (true sparse files); only fall back if key missing. + if let allocated = values.totalFileAllocatedSize { + size += Int64(allocated) + } else if let fileSize = values.fileSize { + size += Int64(fileSize) } } return CachedDirectoryInfo(size: size, fileCount: fileCount, timestamp: Date()) diff --git a/MacOSCleaner/Infrastructure/FileCleanupActor.swift b/MacOSCleaner/Infrastructure/FileCleanupActor.swift index 43e5457..04e6ed0 100644 --- a/MacOSCleaner/Infrastructure/FileCleanupActor.swift +++ b/MacOSCleaner/Infrastructure/FileCleanupActor.swift @@ -10,6 +10,9 @@ public actor FileCleanupActor { private let sizeCache: DirectorySizeCache private let fm = FileManager.default + /// Skip scan preview for items that reclaim nothing meaningful on disk. + private static let minPreviewBytes: Int64 = 1024 + public init(safetyManager: SafetyManager = SafetyManager(), sizeCache: DirectorySizeCache = DirectorySizeCache()) { self.safetyManager = safetyManager self.sizeCache = sizeCache @@ -32,12 +35,23 @@ public actor FileCleanupActor { fm.fileExists(atPath: path, isDirectory: &isDir) if !isDir.boolValue { - let size = (try? fm.attributesOfItem(atPath: path)[.size] as? Int64) ?? 0 + let size = Self.physicalSize(of: path, fm: fm) if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(Self.formatBytes(size))")) + guard size >= Self.minPreviewBytes else { return (0, nil) } return (size, Self.fileItemForPath(path, size: size, isDirectory: false)) } - try? fm.removeItem(at: url) + do { + try fm.removeItem(at: url) + } catch { + progress?(.log(" \(Self.shortPath(path)) — delete failed: \(error.localizedDescription)")) + Logger.fileActor.error("Delete failed \(path, privacy: .public): \(error.localizedDescription, privacy: .public)") + return (0, nil) + } + guard !fm.fileExists(atPath: path) else { + progress?(.log(" \(Self.shortPath(path)) — still present after delete, not counting")) + return (0, nil) + } progress?(.log(" \(Self.shortPath(path)) — removed, freed \(Self.formatBytes(size))")) return (size, nil) } @@ -45,25 +59,35 @@ public actor FileCleanupActor { let before = await getDirectorySize(path) if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(Self.formatBytes(before))")) + guard before >= Self.minPreviewBytes else { return (0, nil) } return (before, Self.fileItemForPath(path, size: before, isDirectory: true)) } let contents = try fm.contentsOfDirectory(atPath: path) var removedCount = 0 + var failedCount = 0 for item in contents { let itemURL = url.appendingPathComponent(item) guard (try? safetyManager.validate(url: itemURL)) != nil else { progress?(.log(" \(Self.shortPath(itemURL.path)) — protected, skipped")) continue } - try? fm.removeItem(at: itemURL) - removedCount += 1 + do { + try fm.removeItem(at: itemURL) + removedCount += 1 + } catch { + failedCount += 1 + progress?(.log(" \(Self.shortPath(itemURL.path)) — delete failed: \(error.localizedDescription)")) + Logger.fileActor.error("Delete failed \(itemURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + } } await sizeCache.invalidate(path) let after = await getDirectorySize(path) let freed = max(0, before - after) - if freed > 0 { + if failedCount > 0 { + progress?(.log(" \(Self.shortPath(path)) — removed \(removedCount), failed \(failedCount), freed \(Self.formatBytes(freed))")) + } else if freed > 0 { progress?(.log(" \(Self.shortPath(path)) — removed \(removedCount) items, freed \(Self.formatBytes(freed))")) } return (freed, nil) @@ -81,11 +105,22 @@ public actor FileCleanupActor { if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(Self.formatBytes(before))")) + guard before >= Self.minPreviewBytes else { return (0, nil) } return (before, Self.fileItemForPath(path, size: before, isDirectory: true)) } - try? fm.removeItem(atPath: path) + do { + try fm.removeItem(atPath: path) + } catch { + progress?(.log(" \(Self.shortPath(path)) — delete failed: \(error.localizedDescription)")) + Logger.fileActor.error("Delete failed \(path, privacy: .public): \(error.localizedDescription, privacy: .public)") + return (0, nil) + } await sizeCache.invalidate(path) + guard !fm.fileExists(atPath: path) else { + progress?(.log(" \(Self.shortPath(path)) — still present after delete, not counting")) + return (0, nil) + } progress?(.log(" \(Self.shortPath(path)) — removed, freed \(Self.formatBytes(before))")) return (before, nil) } @@ -98,17 +133,28 @@ public actor FileCleanupActor { progress?(.log(" \(Self.shortPath(path)) — not found, skipped")) return (0, nil) } - let attrs = try fm.attributesOfItem(atPath: path) - let size = (attrs[.size] as? Int64) ?? 0 + let size = Self.physicalSize(of: path, fm: fm) + let attrs = try? fm.attributesOfItem(atPath: path) if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(Self.formatBytes(size))")) - let modDate = attrs[.modificationDate] as? Date + guard size >= Self.minPreviewBytes else { return (0, nil) } + let modDate = attrs?[.modificationDate] as? Date let item = CleanupFileItem(path: path, sizeBytes: size, modificationDate: modDate, isDirectory: false) return (size, item) } - try? fm.removeItem(atPath: path) + do { + try fm.removeItem(atPath: path) + } catch { + progress?(.log(" \(Self.shortPath(path)) — delete failed: \(error.localizedDescription)")) + Logger.fileActor.error("Delete failed \(path, privacy: .public): \(error.localizedDescription, privacy: .public)") + return (0, nil) + } + guard !fm.fileExists(atPath: path) else { + progress?(.log(" \(Self.shortPath(path)) — still present after delete, not counting")) + return (0, nil) + } progress?(.log(" \(Self.shortPath(path)) — removed, freed \(Self.formatBytes(size))")) return (size, nil) } @@ -133,15 +179,26 @@ public actor FileCleanupActor { if isDir.boolValue { size = await getDirectorySize(itemURL.path) } else { - size = (attrs?[.size] as? Int64) ?? 0 + size = Self.physicalSize(of: itemURL.path, fm: fm) + } + if dryRun { + freed += size + removedCount += 1 + } else { + do { + try fm.removeItem(at: itemURL) + guard !fm.fileExists(atPath: itemURL.path) else { continue } + freed += size + removedCount += 1 + } catch { + progress?(.log(" \(Self.shortPath(itemURL.path)) — delete failed: \(error.localizedDescription)")) + } } - if !dryRun { try? fm.removeItem(at: itemURL) } - freed += size - removedCount += 1 } } if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(removedCount) items older than \(days) days (\(Self.formatBytes(freed)))")) + guard freed >= Self.minPreviewBytes else { return (0, nil) } return (freed, Self.fileItemForPath(path, size: freed, isDirectory: true)) } else { progress?(.log(" \(Self.shortPath(path)) — removed \(removedCount) old items, freed \(Self.formatBytes(freed))")) @@ -172,15 +229,26 @@ public actor FileCleanupActor { } let attrs = try? fm.attributesOfItem(atPath: itemPath) if let modDate = attrs?[.modificationDate] as? Date, modDate < cutoffDate { - let size = (attrs?[.size] as? Int64) ?? 0 - if !dryRun { try? fm.removeItem(atPath: itemPath) } - freed += size - removedCount += 1 + let size = Self.physicalSize(of: itemPath, fm: fm) + if dryRun { + freed += size + removedCount += 1 + } else { + do { + try fm.removeItem(atPath: itemPath) + guard !fm.fileExists(atPath: itemPath) else { continue } + freed += size + removedCount += 1 + } catch { + progress?(.log(" \(Self.shortPath(itemPath)) — delete failed: \(error.localizedDescription)")) + } + } } } if dryRun { progress?(.log(" \(Self.shortPath(path)) — \(removedCount) items older than \(days) days (\(Self.formatBytes(freed)))")) + guard freed >= Self.minPreviewBytes else { return (0, nil) } return (freed, Self.fileItemForPath(path, size: freed, isDirectory: true)) } else { progress?(.log(" \(Self.shortPath(path)) — removed \(removedCount) old items, freed \(Self.formatBytes(freed))")) @@ -200,7 +268,7 @@ public actor FileCleanupActor { } func emitFileItem(_ item: CleanupFileItem?, category: String?, parentName: String?, progress: (@Sendable (CleanupEngineEvent) -> Void)?) { - guard let item else { return } + guard let item, item.sizeBytes >= Self.minPreviewBytes else { return } progress?(.fileItem( path: item.path, sizeBytes: item.sizeBytes, @@ -218,6 +286,20 @@ public actor FileCleanupActor { return CleanupFileItem(path: path, sizeBytes: size, modificationDate: modDate, isDirectory: isDirectory) } + /// Physical allocated size for a single file (or directory via getDirectorySize). + static func physicalSize(of path: String, fm: FileManager) -> Int64 { + let url = URL(fileURLWithPath: path) + if let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileSizeKey]) { + if let allocated = values.totalFileAllocatedSize { + return Int64(allocated) + } + if let fileSize = values.fileSize { + return Int64(fileSize) + } + } + return (try? fm.attributesOfItem(atPath: path)[.size] as? Int64) ?? 0 + } + static func formatBytes(_ bytes: Int64) -> String { if bytes < 1024 { return String(format: "format_bytes_b".localized, bytes) } if bytes < 1024 * 1024 { return String(format: "format_bytes_kb".localized, Double(bytes) / 1024) } diff --git a/MacOSCleaner/Infrastructure/FileManager+Size.swift b/MacOSCleaner/Infrastructure/FileManager+Size.swift index 2e70dcb..1b377d1 100644 --- a/MacOSCleaner/Infrastructure/FileManager+Size.swift +++ b/MacOSCleaner/Infrastructure/FileManager+Size.swift @@ -137,15 +137,23 @@ extension FileManager { continue } - guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .totalFileAllocatedSizeKey]), - let fileSize = values.fileSize else { continue } + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .totalFileAllocatedSizeKey]) else { + continue + } - if let allocated = values.totalFileAllocatedSize, allocated > 0, + if let fileSize = values.fileSize, + let allocated = values.totalFileAllocatedSize, allocated > 0, fileSize / allocated >= FileManager.sparseFileRatioThreshold { continue } - size += Int64(fileSize) + // Prefer allocated (physical) size — APFS clones inflate logical fileSize. + // allocated == 0 is valid for sparse files; only fall back if key is missing. + if let allocated = values.totalFileAllocatedSize { + size += Int64(allocated) + } else if let fileSize = values.fileSize { + size += Int64(fileSize) + } } FileManager._sizeCacheLock.lock() diff --git a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift index 86252ad..9485424 100644 --- a/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift +++ b/MacOSCleaner/MacOSCleanerTests/CleanupEngineTests.swift @@ -782,6 +782,25 @@ struct CleanupEngineTests { #expect(manager.selectedSizeBytes == 30_000_000_000) } + @Test("Directory size uses allocated bytes not logical APFS clone size") + func directorySizeUsesAllocatedBytes() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("MacOSCleanerPhysicalSize_\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + // Sparse file: logical 100 MB, allocated ~0 + let sparse = dir.appendingPathComponent("sparse.bin") + FileManager.default.createFile(atPath: sparse.path, contents: nil) + let handle = try FileHandle(forWritingTo: sparse) + try handle.truncate(atOffset: 100 * 1024 * 1024) + try handle.close() + + let cache = DirectorySizeCache() + let size = await cache.getSize(for: dir.path) + #expect(size < 5 * 1024 * 1024, "Must use allocated size, got \(size)") + } + // MARK: - Helpers private func createTempCacheDir() -> URL { From 76342497d9d9a05631b73fb179050bb792411535 Mon Sep 17 00:00:00 2001 From: Oleksandr Tk Date: Fri, 17 Jul 2026 18:51:28 +0300 Subject: [PATCH 21/23] docs: update README documentation and refresh project screenshots to reflect recent UI and feature enhancements --- README.md | 24 ++++++++++++-------- assets/screenshots/About.png | Bin 627866 -> 666862 bytes assets/screenshots/Cleanup_Scan.png | Bin 685321 -> 825194 bytes assets/screenshots/Cleanup_Scan_Results.png | Bin 624416 -> 627275 bytes assets/screenshots/Cleanup_page.png | Bin 666686 -> 727511 bytes assets/screenshots/Dashboard.png | Bin 631053 -> 793880 bytes assets/screenshots/Disk_analyzer.png | Bin 0 -> 913869 bytes assets/screenshots/Permission_screen.png | Bin 755345 -> 879905 bytes assets/screenshots/Processes.png | Bin 631097 -> 730138 bytes assets/screenshots/Settings_1.png | Bin 771428 -> 772504 bytes assets/screenshots/Settings_2.png | Bin 704314 -> 786957 bytes assets/screenshots/Startup_Services.png | Bin 638187 -> 825063 bytes assets/screenshots/Uninstaller.png | Bin 945802 -> 1321137 bytes assets/screenshots/Uninstaller_scan.png | Bin 728784 -> 744307 bytes 14 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 assets/screenshots/Disk_analyzer.png diff --git a/README.md b/README.md index 7fe0a29..bd49ad7 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,16 @@ ## Screenshots

- - + +

- - + +

- 📷 View all screenshots + 📷 View all screenshots

--- @@ -77,6 +77,7 @@ - **Internet Plug-Ins** — legacy browser plug-ins - **Shared File Lists** — Finder sidebar / recent items lists - **iCloud Cloud Documents** — iCloud document cache (opt-in) +- **User Logs** — system and user-level logs (`~/Library/Logs`, `/Library/Logs`) - **Photos Library Cache** — Photos.app library cache - **Voice Memos** — Voice Memos recordings (opt-in) - **GarageBand / Logic** — project files and caches (opt-in) @@ -93,19 +94,22 @@ Cleanup tasks run in parallel across all available cores for maximum speed. All **Cleanup Options** — toggles before scan: - **Clean .DS_Store files** — removes Finder metadata from directories (off by default) +- **Clean Maven repository** — removes Maven cached dependencies from `~/.m2/repository` (off by default) +- **Clean Go module cache** — removes Go package caches from `GOMODCACHE` (off by default) +- **Clean .dart_tool in projects** — scans and cleans Flutter/Dart development builds in common project locations (off by default) - **Clean iCloud Documents** — includes iCloud document cache (off by default) - **Clean Voice Memos** — includes Voice Memos recordings (off by default) - **Clean GarageBand / Logic** — includes project files and caches (off by default) - **Clean iMovie / Final Cut** — includes render files and libraries (off by default) - **Clean Sleep Image** — removes hibernation image file (off by default) -**Disk Space** 📁 — browse large folders and files, inspect sizes, and move selected items to Trash. +**Disk Space Analyzer** 📁 — scan any custom folder to browse its subdirectories and files sorted by size. Features categorized breakdowns (Videos, Audio, Photos, Apps, Documents, Archives) and lets you reveal items in Finder or move them to the Trash directly from the app. -**Process Manager** ⚙️ — redesigned with modern macOS styling. Lists running processes, lets you terminate or force-kill them. Critical system processes (kernel_task, launchd, WindowServer) are protected. +**Process Manager** ⚙️ — redesigned with modern macOS styling. Lists running processes in Flat or Grouped views. Sorts by CPU, memory, name, or threads. Supports terminating/force-killing individual processes, multiple selection, or entire groups. Critical system processes (kernel_task, launchd, WindowServer) are protected automatically. Custom user Whitelists and Blacklists let you prevent accidental termination of specific apps or quickly close blacklisted ones. -**Startup Services** 🚀 — redesigned with modern macOS styling. Shows all LaunchAgents from `~/Library/LaunchAgents`, their load status, and lets you enable/disable them. +**Startup Services** 🚀 — redesigned with modern macOS styling. Scans LaunchAgents and LaunchDaemons from both user-level (`~/Library`) and system-level (`/Library`) directories. Categorizes them automatically into My Services (User), Third-party, and System services. Allows you to load/unload or stop active services (asking for permissions via AppleScript when necessary), and configure custom vendor prefixes (System Vendors) to protect specific services from accidental modification. -**App Uninstaller** 🗑️ — finds installed apps, scans up to 5 levels deep for residual files using 30 types of evidence (Bundle ID, Team ID, Spotlight, Plist contents, known catalog paths, and more). Shows total reclaimable space and real-time scan progress. Tailored rules for popular apps including Docker, Parallels, Adobe CC, MS Office, Discord, Figma, JetBrains, browsers, and more. +**App Uninstaller** 🗑️ — drag and drop any `.app` bundle directly or select from the list. Finds installed apps, scans up to 5 levels deep for residual files using 30 types of evidence (Bundle ID, Team ID, Spotlight, Plist contents, known catalog paths, and more). Shows total reclaimable space and real-time scan progress. Tailored rules for popular apps including Docker, Parallels, Adobe CC, MS Office, Discord, Figma, JetBrains, browsers, and more. - **Scan Modes (Safe / Balanced)** — choose between *Safe* mode (depth 3, exact matches only, no Spotlight, highest confidence files) and *Balanced* mode (depth 5, full deep scan including Spotlight and fuzzy matching) to tailor uninstallation aggressiveness - **Background Deep Scanning** — apps are scanned thoroughly in the background; the UI updates in real time as each app's total size is finalized @@ -118,7 +122,7 @@ Cleanup tasks run in parallel across all available cores for maximum speed. All **Smart Updates** 🔄 — automatic, lightweight background check for new versions on startup directly via GitHub Releases. Get gently notified when a new update is ready, without background daemons, persistent tracking, or extra dependencies. -**Settings** — rebuilt with native macOS `Form` styles to match System Settings. Light/dark/system theme, languages (English, Русский, Українська, Español), notifications, scan-on-startup, Trash behavior, Apple Intelligence toggle, and more. +**Settings** — rebuilt with native macOS `Form` styles to match System Settings. Light/dark/system theme, languages (English, Русский, Українська, Español), notifications, scan-on-startup, Trash behavior (Empty Trash During Cleanup, Bypass Trash on Uninstall, Empty Trash Immediately), Apple Intelligence toggle, custom System Vendors, and more. --- diff --git a/assets/screenshots/About.png b/assets/screenshots/About.png index 719b0ec077d77d30869e41be9c1d55f259b1b3a7..ebccd7e250ea1afeea497ae5127cb3d903dadd62 100644 GIT binary patch literal 666862 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 z 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