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

- Release + Release macOS 26+ Swift 6 SwiftUI @@ -54,12 +54,12 @@ ## 📸 Screenshots

- Dashboard - App Uninstaller + Dashboard + App Uninstaller

- Smart Cleanup - Process Manager + Smart Cleanup + Process Manager

@@ -83,12 +83,12 @@ ## ✨ Features -- **Smart Cleanup:** scans 54 categories and 1,770 rules across 251 applications, 66 developer toolchains (Xcode, Docker, Rust `target`, Go `pkg`, Python `.venv`, Node `node_modules`), local AI models (Ollama, MLX, Hugging Face), and APFS purgeable space. -- Forensic uninstaller inspects 30 evidence types (Bundle ID, Team ID, Spotlight metadata, Launch Services) to find remnants, with root-level helper removal and rollback snapshots. +- **Smart Cleanup:** scans 55+ categories across system caches, 275+ applications, 85+ developer toolchains & package managers (Xcode, Docker, `uv`, `mise`, Rust, Go, Python, Node), local AI models & coding assistants (Claude Code, Ollama, MLX, Hugging Face, WhisperKit), and safe Time Machine snapshot thinning (`thinlocalsnapshots`). +- **Forensic Uninstaller:** inspects 30 evidence types (Bundle ID, Team ID, Spotlight metadata, Launch Services) to trace remnants across 1,800+ known application paths and heuristics, with standalone orphaned residuals discovery, confidence score tiers, post-uninstall review sheets, and root-level helper removal. - **Duplicate Finder:** identifies duplicate files through a 3-stage pipeline (file size matching, 4 KB header checksum, full SHA-256 verification). -- Disk space analyzer groups directory contents by file type (Videos, Audio, Photos, Documents, Archives) for inspection. +- **Disk Space Analyzer:** hierarchical folder drill-down with breadcrumb navigation, honest APFS allocated block sizing (`totalFileAllocatedSize`), Quick Look previews (`Space`), recursive category filters (Videos, Audio, Photos, Documents, Archives), and dataless iCloud item skip protection. - **Process and Service Manager:** monitors live CPU and RAM usage with termination safeguards for critical processes (`kernel_task`, `launchd`), plus LaunchAgents and LaunchDaemons toggling. -- Native system integrations include on-device `FoundationModels` explanations for unknown caches, Siri and App Intents automation, Liquid Glass keyboard navigation (`⌘,`, `⌘C`, `⌘F`, `⌘R`), and 10 language localizations. +- **Native System Integrations:** on-device `FoundationModels` explanations for unknown caches, Siri and App Intents automation, Liquid Glass materials with keyboard navigation (`⌘,`, `⌘C`, `⌘F`, `⌘R`, `⌘⌫`), and 10 language localizations. Complete feature breakdowns and path specifications are documented in the [MacOSCleaner Wiki](https://github.com/AlexTkDev/MacOSCleaner/wiki). @@ -117,7 +117,6 @@ Build and run with **⌘R**, or run tests with **⌘U**. - Star Repository — [Star the repository](https://github.com/AlexTkDev/MacOSCleaner/stargazers) and watch releases for update notifications. - Discussions — [Ask questions, propose features, and share feedback.](https://github.com/AlexTkDev/MacOSCleaner/discussions) -- Roadmap — [Track upcoming tasks for the next release.](https://github.com/AlexTkDev/MacOSCleaner/discussions/14) - Issue Tracker — [Report bugs or suggest rule improvements.](https://github.com/AlexTkDev/MacOSCleaner/issues) - Documentation — [Architecture overview and developer guides.](https://github.com/AlexTkDev/MacOSCleaner/wiki) - Cursor Codebase — [Explore repository online and open in Cursor.](https://cursor.com/codebase/alextkdev/MacOSCleaner/tree/release) diff --git a/assets/screenshots/About_v2_1_1.png b/assets/screenshots/About_v2_1_1.png deleted file mode 100644 index e00ac20..0000000 Binary files a/assets/screenshots/About_v2_1_1.png and /dev/null differ diff --git a/assets/screenshots/About_v2_2.png b/assets/screenshots/About_v2_2.png new file mode 100644 index 0000000..b3ab842 Binary files /dev/null and b/assets/screenshots/About_v2_2.png differ diff --git a/assets/screenshots/Cleanup_Scan_Results_v2_1.png b/assets/screenshots/Cleanup_Scan_Results_v2_1.png deleted file mode 100644 index b34e827..0000000 Binary files a/assets/screenshots/Cleanup_Scan_Results_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Cleanup_Scan_Results_v2_2.png b/assets/screenshots/Cleanup_Scan_Results_v2_2.png new file mode 100644 index 0000000..19eea7c Binary files /dev/null and b/assets/screenshots/Cleanup_Scan_Results_v2_2.png differ diff --git a/assets/screenshots/Cleanup_Scan_v2_1.png b/assets/screenshots/Cleanup_Scan_v2_1.png deleted file mode 100644 index 2c07ac2..0000000 Binary files a/assets/screenshots/Cleanup_Scan_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Cleanup_Scan_v2_2.png b/assets/screenshots/Cleanup_Scan_v2_2.png new file mode 100644 index 0000000..d199d33 Binary files /dev/null and b/assets/screenshots/Cleanup_Scan_v2_2.png differ diff --git a/assets/screenshots/Cleanup_page_v2_1.png b/assets/screenshots/Cleanup_page_v2_1.png deleted file mode 100644 index 5e9a91f..0000000 Binary files a/assets/screenshots/Cleanup_page_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Cleanup_page_v2_2.png b/assets/screenshots/Cleanup_page_v2_2.png new file mode 100644 index 0000000..3cd68c7 Binary files /dev/null and b/assets/screenshots/Cleanup_page_v2_2.png differ diff --git a/assets/screenshots/Dashboard_v2_1.png b/assets/screenshots/Dashboard_v2_1.png deleted file mode 100644 index 2544a83..0000000 Binary files a/assets/screenshots/Dashboard_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Dashboard_v2_2.png b/assets/screenshots/Dashboard_v2_2.png new file mode 100644 index 0000000..7989dda Binary files /dev/null and b/assets/screenshots/Dashboard_v2_2.png differ diff --git a/assets/screenshots/Disk_analyzer_v2_1.png b/assets/screenshots/Disk_analyzer_v2_1.png deleted file mode 100644 index 86ff7cd..0000000 Binary files a/assets/screenshots/Disk_analyzer_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Disk_analyzer_v2_2.png b/assets/screenshots/Disk_analyzer_v2_2.png new file mode 100644 index 0000000..1c423c3 Binary files /dev/null and b/assets/screenshots/Disk_analyzer_v2_2.png differ diff --git a/assets/screenshots/Duplicate_Finder_v2_1.png b/assets/screenshots/Duplicate_Finder_v2_1.png deleted file mode 100644 index ca74dba..0000000 Binary files a/assets/screenshots/Duplicate_Finder_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Duplicate_Finder_v2_2.png b/assets/screenshots/Duplicate_Finder_v2_2.png new file mode 100644 index 0000000..117e69d Binary files /dev/null and b/assets/screenshots/Duplicate_Finder_v2_2.png differ diff --git a/assets/screenshots/Permission_screen_v2_1.png b/assets/screenshots/Permission_screen_v2_2.png similarity index 100% rename from assets/screenshots/Permission_screen_v2_1.png rename to assets/screenshots/Permission_screen_v2_2.png diff --git a/assets/screenshots/Processes_v2_1.png b/assets/screenshots/Processes_v2_1.png deleted file mode 100644 index 43729fa..0000000 Binary files a/assets/screenshots/Processes_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Processes_v2_2.png b/assets/screenshots/Processes_v2_2.png new file mode 100644 index 0000000..aa4a454 Binary files /dev/null and b/assets/screenshots/Processes_v2_2.png differ diff --git a/assets/screenshots/Settings_AI_v2_1.png b/assets/screenshots/Settings_AI_v2_1.png deleted file mode 100644 index 8efd085..0000000 Binary files a/assets/screenshots/Settings_AI_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Settings_AI_v2_2.png b/assets/screenshots/Settings_AI_v2_2.png new file mode 100644 index 0000000..75df7f5 Binary files /dev/null and b/assets/screenshots/Settings_AI_v2_2.png differ diff --git a/assets/screenshots/Settings_Advanced_v2_1.png b/assets/screenshots/Settings_Advanced_v2_1.png deleted file mode 100644 index c4f227c..0000000 Binary files a/assets/screenshots/Settings_Advanced_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Settings_Advanced_v2_2.png b/assets/screenshots/Settings_Advanced_v2_2.png new file mode 100644 index 0000000..962037e Binary files /dev/null and b/assets/screenshots/Settings_Advanced_v2_2.png differ diff --git a/assets/screenshots/Settings_Cleanup_v2_1.png b/assets/screenshots/Settings_Cleanup_v2_1.png deleted file mode 100644 index 3e456ef..0000000 Binary files a/assets/screenshots/Settings_Cleanup_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Settings_Cleanup_v2_2.png b/assets/screenshots/Settings_Cleanup_v2_2.png new file mode 100644 index 0000000..38ddea6 Binary files /dev/null and b/assets/screenshots/Settings_Cleanup_v2_2.png differ diff --git a/assets/screenshots/Settings_General_v2_1_1.png b/assets/screenshots/Settings_General_v2_1_1.png deleted file mode 100644 index fb296e2..0000000 Binary files a/assets/screenshots/Settings_General_v2_1_1.png and /dev/null differ diff --git a/assets/screenshots/Settings_General_v2_2.png b/assets/screenshots/Settings_General_v2_2.png new file mode 100644 index 0000000..9993258 Binary files /dev/null and b/assets/screenshots/Settings_General_v2_2.png differ diff --git a/assets/screenshots/Settings_Processes_v2_1.png b/assets/screenshots/Settings_Processes_v2_1.png deleted file mode 100644 index aa6f8f9..0000000 Binary files a/assets/screenshots/Settings_Processes_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Settings_Processes_v2_2.png b/assets/screenshots/Settings_Processes_v2_2.png new file mode 100644 index 0000000..dfc6d0d Binary files /dev/null and b/assets/screenshots/Settings_Processes_v2_2.png differ diff --git a/assets/screenshots/Startup_Services_v2_1.png b/assets/screenshots/Startup_Services_v2_1.png deleted file mode 100644 index 25fa36a..0000000 Binary files a/assets/screenshots/Startup_Services_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Startup_Services_v2_2.png b/assets/screenshots/Startup_Services_v2_2.png new file mode 100644 index 0000000..3decc30 Binary files /dev/null and b/assets/screenshots/Startup_Services_v2_2.png differ diff --git a/assets/screenshots/Uninstaller_2_versions_v2_1.png b/assets/screenshots/Uninstaller_2_versions_v2_2.png similarity index 100% rename from assets/screenshots/Uninstaller_2_versions_v2_1.png rename to assets/screenshots/Uninstaller_2_versions_v2_2.png diff --git a/assets/screenshots/Uninstaller_Leftovers_v2_2.png b/assets/screenshots/Uninstaller_Leftovers_v2_2.png new file mode 100644 index 0000000..3e38674 Binary files /dev/null and b/assets/screenshots/Uninstaller_Leftovers_v2_2.png differ diff --git a/assets/screenshots/Uninstaller_Shared_v2_1.png b/assets/screenshots/Uninstaller_Shared_v2_1.png deleted file mode 100644 index ae1ffde..0000000 Binary files a/assets/screenshots/Uninstaller_Shared_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Uninstaller_scan_v2_1.png b/assets/screenshots/Uninstaller_scan_v2_2.png similarity index 100% rename from assets/screenshots/Uninstaller_scan_v2_1.png rename to assets/screenshots/Uninstaller_scan_v2_2.png diff --git a/assets/screenshots/Uninstaller_v2_1.png b/assets/screenshots/Uninstaller_v2_1.png deleted file mode 100644 index 9cbd43d..0000000 Binary files a/assets/screenshots/Uninstaller_v2_1.png and /dev/null differ diff --git a/assets/screenshots/Uninstaller_v2_2.png b/assets/screenshots/Uninstaller_v2_2.png new file mode 100644 index 0000000..943081b Binary files /dev/null and b/assets/screenshots/Uninstaller_v2_2.png differ